前言:
依旧月更博主这一块,今天早上八点多爬起来打算要写来着,配一个Ubuntu环境花了一个小时,不过现在至少可以在vscode上面写代码了,不用再看那个雷霆vim了,写的时候真的能气死我,废话少说,依旧前言大纲环节
今天我们来学一下进程间通信:
首先我们先来聊一下进程间通信这个东西,(它诞生的目的?)
之后我们来看一看,早期我们使用的进程间通信方法(匿名管道)
在了解了匿名管道之后,我们来聊一聊命名管道
之后呢,我们再来学习一下system V标准(共享内存,消息队列,信号量)
其中,我们主要来聊一聊共享内存,因为以上三个东西,都是一个标准,所以使用起来大同小异,所以学会一个另外两个也就会了
最后我们来对进程间通信做一个总结,内核是如何组织ipc资源的?(ipc就是进程间通信)
正文:
进程间通信:
目的:
发展历程:
① 管道
② system V 进程间通信
③ POSIX 进程间通信
匿名管道:
管道是什么?
我们把一个进程连接到另一个进程的数据流称为一个管道,这是unix中最古老的进程间通信的方式(linux不就是在unix之上延伸过来的吗)
匿名管道如何使用?
我们使用的是pipe函数:
函数原型:
int pipe(int fd[2]);
fd[0]:管道读端,只能 readfd[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,写端则看它是第几个子进程咯
匿名管道读端写端的四种情况:
- 读正常 && 写满缓冲区写满,
write阻塞,等读端读走一部分数据腾出空间,写才继续。 - 写正常 && 读空缓冲区没有数据,
read阻塞,等待写端写入数据。 - 写关闭 && 读正常所有写端 fd 全部 close。
read读到数据正常返回;读完剩余数据后,再次 read 返回0(代表读到 EOF)。 - 读关闭 && 写正常所有读端 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;
这不就可以实现知道强转成哪一个数据结构了么,所以我们最后来画一张图:
尾:
从早上九点开始忙到下午五点,恍若隔世啊......下次更新就是写信号了,不知道什么时候能写出来,不过可以期待一下?
感谢观看,祝国庆快乐