☰
linux(10)进程间通信
2026/10/7 19:50:52 网站建设 项目流程

前言:

依旧月更博主这一块,今天早上八点多爬起来打算要写来着,配一个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;

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

尾:

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

感谢观看,祝国庆快乐

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询