-梅头像
linux(10)进程间通信封面图

linux(10)进程间通信

前言:

依旧月更博主这一块,今天早上八点多爬起来打算要写来着,配一个Ubuntu环境花了一个小时,不过现在至少可以在vscode上面写代码了,不用再看那个雷霆vim了,写的时候真的能气死我,废话少说,依旧前言大纲环节

今天我们来学一下进程间通信:

首先我们先来聊一下进程间通信这个东西,(它诞生的目的?)

之后我们来看一看,早期我们使用的进程间通信方法(匿名管道)

在了解了匿名管道之后,我们来聊一聊命名管道

之后呢,我们再来学习一下system V标准(共享内存,消息队列,信号量)

其中,我们主要来聊一聊共享内存,因为以上三个东西,都是一个标准,所以使用起来大同小异,所以学会一个另外两个也就会了

最后我们来对进程间通信做一个总结,内核是如何组织ipc资源的?(ipc就是进程间通信)

正文:

进程间通信:

目的:

①. 数据传输:⼀个进程需要将它的数据发送给另⼀个进程
②. 资源共享:多个进程之间共享同样的资源。
③. 通知事件:⼀个进程需要向另⼀个或⼀组进程发送消息,通知它(它们)发⽣了某种事件(如进程终⽌时要通知⽗进程)。
④.  进程控制:有些进程希望完全控制另⼀个进程的执⾏(如Debug进程),此时控制进程希望能够拦截另⼀个进程的所有陷⼊和异常,并能够及时知道它的状态改变

发展历程:

①  管道

②  system V 进程间通信

③  POSIX 进程间通信

匿名管道:

管道是什么?

我们把一个进程连接到另一个进程的数据流称为一个管道,这是unix中最古老的进程间通信的方式(linux不就是在unix之上延伸过来的吗)

匿名管道如何使用?

我们使用的是pipe函数:

函数原型:

int pipe(int fd[2]);

  • fd[0]:管道读端,只能 read
  • fd[1]:管道写端,只能 write

我们可以把0,看成一个张开的嘴,那不就是用嘴读么

:

同理我们可以把1看成一支笔,那不就是写么;

示例代码:

#include<unistd.h>
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include <sys/wait.h>
int main()
{
    int fd[2];
    pipe(fd);
    //fd[0]是读端,fd[1]是写端
    //我们今天让子进程读,父进程写
    pid_t pid=fork();
    //子进程拿到的返回值是0,父进程拿到的是子进程的pid
    if(pid==0)
    {
        //子进程
        close(fd[1]);
        char buf[100];
        int n=read(fd[0],buf,sizeof(buf)-1);
        //为什么减一?因为我们从管道里读数据,管道里的数据可没有\0的规定,所以我们要预留一个位置,给它手动加上\0
        buf[n]='\0';
        printf("子进程读到了:%s",buf);
        exit(0);
    }
    //父进程
    close(fd[0]);
    char msg[]="hello kg";
    write(fd[1],msg,strlen(msg));
    wait(NULL);
    return 0;
}
我们运行以后的结果是:
wuchun@VM-0-10-ubuntu:~/10_6$ gcc -o guandao guandao.c
wuchun@VM-0-10-ubuntu:~/10_6$ ./guandao
子进程读到了:hello kgwuchun@VM-0-10-ubuntu:~/10_6$ 

所以这样我们就实现了父进程写,子进程读的过程;那么具体是怎么实现的?我们来看看底层原理。

不过在此之前,我们先来简单说一下

vscode的使用:

我们在编写完代码以后,按ctrl+s,那么代码才会保存。

那么下面那个窗口哪里来的呢?我们按ctrl+~就出现了下面的界面,我们可以直接在下面进行命令行操作,就相当于我们的xshell了。

匿名管道底层原理:

真难画......

可以见得,父进程创建子进程后,子进程的fd表,也是拷贝的父进程的表。父进程使用pipe函数,从而父进程的表中,fd[0]指向了读的file,fd[1]指向了写的file,而子进程拷贝了父进程的表后,指向和父进程一样。

//这里的管道文件,file指向同一个inode,从而管理着同一个内核缓冲区,但是这个匿名管道,它虽然有自己的inode,但是它不会写到磁盘里

那么这时候,我把父进程的读端关了,那么它就只能写了,我把子进程的写端关了,那么它就只能读了,从而通过这样,我们就可以通过让父子进程看到同一个内核缓冲区,从而在内核缓冲区中,进行通信。

那么我们总结一下匿名管道:

小总结:

匿名管道的数据存在内核内存里,不会写入磁盘;管道(inode + 缓冲区 + 一对 struct file)是 pipe () 系统调用,由内核创建。

谁来创建?

调用 pipe(fd) 这个系统调用 → 内核来创建: 内核一次性生成三样东西:

① 1 个管道 inode(内存 inode,记录管道属性、引用计数)

② 1 块内核环形缓冲区(存放管道字节数据)

③ 2 个 struct file(读端、写端,绑定这个 inode)

匿名管道一个父进程创建多个管道:

那么,如果我一个父进程,我要同时和好几个子进程通信,我不就要创建好几个管道么,就比如说,我们要创建一个内存池:(父进程写,所有子进程都是读)

那么我们理想的情况是,父进程把所有写端的fd保存起来,然后在要写的时候,选择fd,然后向对应的子进程发送信息,那么假如我这样写:

 for(int i=0;i<4;i++)
    {
        fork();
        //......
    }

最后在等待的时候是会出问题的!!!!

为什么呢?

我们此时发现,父进程关闭了3,保留了4(写),同时子进程1关闭了4,保留了3(读)

那么我们如果再创建一个子进程呢?我们还记得,fd表的规律是,选择最小的未使用的fd序号,所以对父进程而言,新的读还是3(因为先前的被关了),而新的写是5,而子进程2又会继承父进程的fd表,所以这个子进程2,就有了子进程1的写端(4)!!!!

那么由此得出,我们继续创建子进程,每一个子进程,都会有先前所有子进程的写端!!!

从而结论是,所有的子进程读端都是3,写端则看它是第几个子进程咯

匿名管道读端写端的四种情况:

  1. 读正常 && 写满 缓冲区写满,write 阻塞,等读端读走一部分数据腾出空间,写才继续。
  2. 写正常 && 读空 缓冲区没有数据,read 阻塞,等待写端写入数据。
  3. 写关闭 && 读正常 所有写端 fd 全部 close。read读到数据正常返回;读完剩余数据后,再次 read 返回0(代表读到 EOF)。
  4. 读关闭 && 写正常 所有读端 fd 全部 close。进程调用 write,会收到 SIGPIPE 信号,进程默认直接被杀掉;write 返回 - 1,errno=EPIPE。

测试1:

#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
#include <sys/wait.h>

int main()
{
    int fd[2];
    pipe(fd);
    pid_t pid = fork();
    if(pid == 0)
    {
        close(fd[0]);
        char buf[5000];
        printf("子进程开始写大量数据\n");
        write(fd[1],buf,5000);
        printf("写完成!\n");
        close(fd[1]);
        exit(0);
    }
    else
    {
        sleep(3); //等子写,缓冲区写满,子write阻塞
        printf("父进程准备读\n");
        char recv[5000];
        read(fd[0],recv,5000);
        printf("父读取成功\n");
        wait(NULL);
    }
    return 0;
}
wuchun@VM-0-10-ubuntu:~/10_6$ ./ceshi1
子进程开始写大量数据
写完成!
父进程准备读
父读取成功

测试2:

#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
#include <sys/wait.h>

int main()
{
    int fd[2];
    pipe(fd);
    pid_t pid = fork();
    if(pid == 0)
    {
        close(fd[1]);
        char buf[100];
        printf("子进程准备读,缓冲区为空,read阻塞\n");
        read(fd[0],buf,100);
        printf("子读到数据:%s\n",buf);
        close(fd[0]);
        exit(0);
    }
    else
    {
        sleep(3);
        printf("父开始写入\n");
        write(fd[1],"hello",5);
        close(fd[1]);
        wait(NULL);
    }
    return 0;
}

wuchun@VM-0-10-ubuntu:~/10_6$ ./ceshi2
子进程准备读,缓冲区为空,read阻塞
父开始写入
子读到数据:hello

测试3:

#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
#include <sys/wait.h>

int main()
{
    int fd[2];
    pipe(fd);
    pid_t pid = fork();
    if(pid == 0)
    {
        close(fd[0]);
        write(fd[1],"abc123",6);
        printf("子写完,关闭写端\n");
        close(fd[1]);
        exit(0);
    }
    else
    {
        sleep(1);
        char buf[100];
        int n = read(fd[0],buf,100);
        printf("读到%d字节:%s\n",n,buf);
        n = read(fd[0],buf,100);
        printf("再次read返回:%d\n",n); //返回0 EOF
        wait(NULL);
    }
    return 0;
}

测试4:

#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
#include <sys/wait.h>

int main()
{
    int fd[2];
    pipe(fd);
    pid_t pid = fork();
    if(pid == 0)
    {
        close(fd[1]);
        close(fd[0]); //子直接关闭唯一读端
        sleep(2);
        exit(0);
    }
    else
    {
        sleep(1);
        printf("父准备写,读端已经全部关闭\n");
        write(fd[1],"test",4); //触发SIGPIPE,进程终止
        printf("write不会执行到这里\n");
        wait(NULL);
    }
    return 0;
}

同步与互斥:

我们先引入一组概念:

共享资源:多个进程都能访问的资源。

临界资源:共享资源里,一次只允许一个进程访问的那一类

而我们的匿名管道,很明显就是临界资源:

而访问临界资源的时候,我们要遵守的原则是,同步与互斥:

互斥:

多个进程不能同时访问同一个临界资源,同一时间只允许一个进程进行操作;

同步:

进程之间按照约定顺序运行,一个等待另一个,协调先后次序

例如:

①缓冲区空 → 读进程阻塞,等着写进程写入数据;

②缓冲区满 → 写进程阻塞,等着读进程拿走数据。

特点:

在匿名管道中,通信的双方必须是有血缘关系的(父子,兄弟之类)

命名管道:

而如果我们想要不依赖血缘关系就达成使用管道通信呢?

我们就要用命名管道了:

那么怎么用?

用法:

int mkfifo(const char *pathname, mode_t mode);
  • 功能:在磁盘创建 FIFO 特殊文件,生成对应的 inode。只创建文件,不打开
  • 参数:
    • pathname:管道文件路径名
    • mode:文件权限,如 0664
  • 返回值:成功返回 0;失败返回 - 1,设置 errno

来个例子:

我们今天来实现从client向server传消息

client.c

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
int main()
{
    int wfd=open("mypipe",O_WRONLY);
    char writebuf[1000];
    while(1)
    {
        printf("please enter:\n");
        int s=read(0,writebuf,sizeof(writebuf)-1);
        writebuf[s]=0;
        write(wfd,writebuf,strlen(writebuf));
    }
    return 0;
}

server.c

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
    //server是读
    if(mkfifo("mypipe",0664)==-1)
    {
        perror("mkfifo");
        exit(1);
    }
    int rfd=open("mypipe",O_RDONLY);
    char readbuf[1000];
    while(1)
    {
        printf("等待client输入\n");
        int n=read(rfd,readbuf,sizeof(readbuf)-1);
        if(n>0)
        {
            readbuf[n]=0;
            printf("client said:%s",readbuf);
        }
    }
    return 0;
}

makefile:

.PHONY:all
all:server client
server:server.c
	gcc -o $@ $^
client:client.c
	gcc -o $@ $^
.PHONY:clean
clean:
	rm server client mypipe

这里因为要同时make两个的话,实际上只能make出第一个,所以我们直接在第一行来一个all,让all的产生依赖server和client,那么all,server,client入栈,从而全部生成;

那么我们一个进程输入:

一个进程输出:

这样就实现了我们的命名管道,我们可以发现,我们的client和server,什么关系都没有,但是可以通过命名管道,从而实现进程间通信;

那么我们可以发现,命名管道和匿名管道的共性是什么?

共性:

先让一个进程创建管道,再让要进行通信的进程获取;

表格对比:

对比项匿名管道 pipe命名管道 mkfifo(FIFO)
创建方式pipe()系统调用mkfifo()函数 / mkfifo命令
存在形式没有实体文件,只有内核缓冲区存在管道文件(在磁盘有 inode,但不存数据)
通信进程关系只能用于有亲缘关系进程(父子 / 兄弟)无亲缘关系进程也能通信
访问方式依靠继承文件描述符像普通文件一样open打开,拿到 fd
生命周期进程全部退出,管道自动销毁管道文件本身永久存在,所有打开进程关闭后内核缓冲区释放
数据存储数据存在内核缓冲区,不在磁盘数据同样只存在内核缓冲区,磁盘只留文件入口
通信方向半双工,单向数据流半双工,单向数据流
同步互斥内核自带同步、互斥机制内核自带同步、互斥机制
读写规则数据读取后缓冲区清除,不可重复读数据读取后缓冲区清除,不可重复读

画图理解:

共享内存:

以上是我们对管道的理解,那么,我们想象一下:

如果说:

我们都知道,一个进程的task_struct里面存着vm_area_struct的链表头,也存着指向页表的指针,那么只要我们在物理内存里面,把同一块物理内存映射到不同的两个进程里,那么我们是不是就可以实现进程间通信,而且一旦达成,就不再需要使用什么系统调用,write,read,这不是很快么

这种想法当然可以,所以我们直接来共享内存:

来看看共享内存数据结构长啥样:

struct shmid_ds {
    struct ipc_perm shm_perm;    // IPC权限:uid/gid、读写权限、key
    int shm_segsz;               // 共享内存段大小(字节)
    __kernel_time_t shm_atime;   // 最后一次shmat挂载时间
    __kernel_time_t shm_dtime;   // 最后一次shmdt卸载时间
    __kernel_time_t shm_ctime;   // 最后一次修改shmid_ds属性的时间
    __kernel_ipc_pid_t shm_cpid; // 创建该共享内存段的进程pid
    __kernel_ipc_pid_t shm_lpid; // 最后一次执行shmat/shmdt的进程pid
    unsigned short shm_nattch;   // 当前挂载的进程数量(重点!引用计数)
    unsigned short shm_unused;   // 兼容保留字段
    void *shm_unused2;
    void *shm_unused3;
};

这里的第一个成员:

struct ipc_perm shm_perm很重要,我们把它长啥样也扒出来:

struct ipc_perm {
    key_t __key;       // shmget传入的key值
    uid_t uid;         // 所有者用户ID
    gid_t gid;         // 所有者组ID
    uid_t cuid;        // 创建者用户ID
    gid_t cgid;        // 创建者组ID
    unsigned short mode;  // 权限位(类似文件rwx权限)
    unsigned short __seq; // 序列号,IPC对象的序号
};

如何使用呢?

1. shmget 创建 / 获取共享内存段
int shmget(key_t key, size_t size, int shmflg);
  • key:IPC 键,当做共享内存的名字
  • size:共享内存字节大小,必须是页的整数倍
  • shmflg:IPC_CREAT 创建;IPC_CREAT|IPC_EXCL 不存在才创建;加上权限如0666
  • 返回:成功返回 shmid;失败-1
2. shmat 挂载(关联到进程虚拟地址空间)
void *shmat(int shmid, const void *shmaddr, int shmflg);
  • shmid:shmget 返回的共享内存 id
  • shmaddr:指定虚拟地址,填 NULL 让内核自动分配(最常用)
  • shmflg:SHM_RDONLY 只读挂载;0 可读可写
  • 返回:成功返回共享内存起始虚拟地址;失败(void *)-1
3. shmdt 卸载(脱离进程地址空间)
int shmdt(const void *shmaddr);
  • shmaddr:shmat 返回的指针
  • 返回:成功 0;失败 - 1
4. shmctl 控制共享内存(获取信息 / 修改属性 / 删除)
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
  • shmid:共享内存 id
  • cmd:
    • IPC_STAT:读取共享内存信息,存入 buf(shmid_ds)
    • IPC_SET:修改权限、uid 等属性
    • IPC_RMID:标记删除共享内存段
  • buf:struct shmid_ds 结构体指针,IPC_RMID 时传 NULL 即可
  • 返回:成功 0;失败 - 1

来个例子:

client.c

#include <stdio.h>
#include <unistd.h>
#include <sys/shm.h>
#include <sys/ipc.h>
#include <sys/types.h>
#include <string.h>
int main()
{
    key_t key=ftok("shm_key",'x');
    int shmid=shmget(key,4096,0666);
    char* shmaddr=shmat(shmid,NULL,0);
    strcpy(shmaddr, "hello shared memory!");
    printf("client写入完成\n");
    shmdt(shmaddr);
    return 0;
}

server.c

#include <stdio.h>
#include <unistd.h>
#include <sys/shm.h>
#include <sys/ipc.h>
#include <sys/types.h>
#include <string.h>
int main()
{
    key_t key=ftok("./shm_key",'x');
    int shmid=shmget(key,4096,IPC_CREAT | 0666);
    char* shmaddr=shmat(shmid,NULL,0);
    printf("server等待client写入数据:\n");
    while(1)
    {
        printf("server读到了:%s\n",shmaddr);
        sleep(1);
    }
    shmdt(shmaddr);
    shmctl(shmid,IPC_RMID,NULL);
    return 0;
}

结果如下:

但是我们发现:

共享内存没有进⾏同步与互斥! 共享内存缺乏访问控制!会带来并发问题

关于共享内存,消息队列,信号量:

三者都依靠 key_t,用 ftok() 获取 key;都可以用 ipcs / ipcrm 查看、删除内核对象

1. 共享内存 shm

// 创建/获取共享内存
int shmget(key_t key, size_t size, int shmflg);
// 挂载到进程虚拟地址
void *shmat(int shmid, const void *shmaddr, int shmflg);
// 卸载映射
int shmdt(const void *shmaddr);
// 控制(获取属性/删除)
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

2. 消息队列 msg

// 创建/获取消息队列
int msgget(key_t key, int msgflg);
// 发送消息
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
// 接收消息(无消息默认阻塞)
int msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
// 控制(获取属性/删除)
int msgctl(int msqid, int cmd, struct msqid_ds *buf);

3. 信号量 sem

// 创建/获取信号量集合
int semget(key_t key, int nsems, int semflg);
// P/V操作
int semop(int semid, struct sembuf *sops, size_t nsops);
// 控制(初始化值/删除)
int semctl(int semid, int semnum, int cmd, ...);

所以三者其实使用起来其实使用方法差不多,我们学会了共享内存,其它几个自然也是信手拈来

那么我们接下来要看的是,内核如何管理ipc资源?

内核如何管理ipc资源:

三者的数据结构:

共享内存:

struct shmid_ds {
    struct ipc_perm shm_perm;    // IPC权限
    size_t shm_segsz;            // 共享内存段字节大小
    time_t shm_atime;             // 最后挂载shmat时间
    time_t shm_dtime;             // 最后卸载shmdt时间
    time_t shm_ctime;             // 最后修改属性时间
    pid_t shm_cpid;               // 创建者pid
    pid_t shm_lpid;               // 最后shmat/shmdt的进程pid
    shmatt_t shm_nattch;          // 当前挂载的进程数量(shm_nattch就是shm_nattch,对应shm_nattch,就是之前shm_nattch,也就是shm_nattch,shm_nattch,shm_nattch,shm_nattch)
};

消息队列:

struct msqid_ds {
    struct ipc_perm msg_perm;    // IPC权限
    struct msg *msg_first;        // 队列第一条消息(内核指针)
    struct msg *msg_last;         // 队列最后一条消息
    time_t msg_stime;             // 最后msgsnd发送时间
    time_t msg_rtime;             // 最后msgrcv接收时间
    time_t msg_ctime;             // 最后修改属性时间
    unsigned long msg_cbytes;     // 队列当前全部消息字节数
    msgqnum_t msg_qnum;           // 当前消息个数
    msglen_t msg_qbytes;          // 队列最大允许总字节
    pid_t msg_lspid;              // 最后msgsnd的pid
    pid_t msg_lrpid;              // 最后msgrcv的pid
};

信号量集合:

struct semid_ds {
    struct ipc_perm sem_perm;    // IPC权限
    struct sem *sem_base;         // 信号量数组起始地址
    time_t sem_otime;             // 最后一次semop操作时间
    time_t sem_ctime;             // 最后修改属性时间
    unsigned short sem_nsems;     // 信号量集合里面信号量的个数
};

我们发现,三者都有一个

struct  ipc_perm类型的东西:

struct ipc_perm {
    key_t __key;                  // ftok生成的key
    uid_t uid;                    // 所有者用户ID
    gid_t gid;                    // 所有者组ID
    uid_t cuid;                   // 创建者用户ID
    gid_t cgid;                   // 创建者组ID
    unsigned short mode;          // 权限rwx
    unsigned short __seq;         // IPC序列号
};

那么,其实以上我们给出的共享内存,消息队列,信号量,它们都是用户态数据结构,但是它们在内核里,其实和我们看到的结构是不一样的,因为内核里很多属性,我们用户在使用的时候,并不需要考虑,所以它就只把一些我需要考虑的拷贝出来

而真正内核级数据结构(消息队列):

struct msg_queue {
	struct kern_ipc_perm q_perm;
	int q_id;
	time_t q_stime;         /* last msgsnd time */
	time_t q_rtime;         /* last msgrcv time */
	time_t q_ctime;         /* last change time */
	unsigned long q_cbytes; /* current number of bytes on queue */
	unsigned long q_qnum;   /* number of messages in queue */
	unsigned long q_qbytes; /* max number of bytes on queue */
	pid_t q_lspid;          /* pid of last msgsnd */
	pid_t q_lrpid;          /* last receive pid */

	struct list_head q_messages;
	struct list_head q_receivers;
	struct list_head q_senders;
};

我们发现,我们看到的ipc_perm,它其实在内核里叫kern_ipc_perm。

那么,我们内核里,总不可能只有一个ipc资源吧?肯定不是,一定是有很多ipc资源的,那么我们就要对它们进行管理,怎么管理?先描述再组织;

所以我们是怎么组织的呢?

我们内核里有一个数据结构是:

struct ipc_id_ary {
	int size;
	struct kern_ipc_perm *p[0];
};

下面的是一个柔性数组,它里面存放的是kern_ipc_perm指针,而kern_ipc_perm正好是我们ipc资源的第一个成员,它的地址在数值上等于我们ipc结构体的地址,所以我们通过这个kern_ipc_perm结构体,把一个个共享内存,消息队列,信号量组织起来,我们需要的时候,再进行强转,从而可以实现对ipc资源的管理(多态!)

可是,我们怎么知道到时候具体强转成共享内存,消息队列,还是信号量呢?

所以啊:

struct ipc_ids {
	int in_use;
	int max_id;
	unsigned short seq;
	unsigned short seq_max;
	struct mutex mutex;
	struct ipc_id_ary nullentry;
	struct ipc_id_ary* entries;
};

这个数据结构它的最后一个成员,正是指向ipc_id_ary,而这个数据结构,它会创建三个对象:

shm_ids,msg_ids,sem_ids;

这不就可以实现知道强转成哪一个数据结构了么,所以我们最后来画一张图:

尾:

从早上九点开始忙到下午五点,恍若隔世啊......下次更新就是写信号了,不知道什么时候能写出来,不过可以期待一下?

感谢观看,祝国庆快乐

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2601_96394870/article/details/167167787

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--