进程间通信介绍
概念
进程间通信简称为IPC是一组编程接口,让程序员能够协调不同的进程,使之能在一个操作系统中同时运行,并互相传递,交换信息。这使得一个程序能够在同一时间里处理许多用户的要求。IPC方法包括管道,消息排队,旗语,共用内存以及套接字(本篇博客只介绍共享内存和管道).
目的
- 数据传输:一个进程需要将它的数据发送给另一个进程
- 资源共享:多个进程之间共享同样的资源
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它发生了某种事件,比如进程终止时需要通知其父进程
- 进程控制:有些进程希望完全控制另一个进程的执行,此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
本质
进程间通信的本质就是,让不同的进程看到同一份资源。
由于各个运行进程之间具有独立性,这个独立性主要体现在数据方面,而代码逻辑层面可以私有也可以共有,因此各个进程之间要实现通信是很困难的。
各个进程之间若想实现通信,一定要借助第三方资源,这些进程就可以通过向这个第三方资源写入或读取数据,进而实现进程之间的通信,这个第三方资源实际上就是操作系统提供的一段内存区域。
因此,进程间通信的本质就是,让不同的进程看到同一份资源,由于这份资源可以由操作系统中的不同模块提供,因此出现了不同的进程间通信方式。
分类
管道
- 匿名管道
- 命名管道
System V IPC
- System V 消息队列
- System V 共享内存
- System V 信号量
POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
管道
什么是管道
概念:管道是UNIX中最古老的进程间通信的形式。我们把从一个进程连接到另一个进程的一个数据流称为一个"管道"。它的特点就是单向传输数据的,先进先出。
例如,统计我们当前使用云服务器上的登录用户个数。
其中,who命令和wc命令是两个程序,当运行起来就变成两个进程,who进程通过便准输出将数据打到管道中,wc进程再通过标准输入从管道当中读取数据。至此便完成了数据的传输,进而完成数据的进一步加工处理。(who命令用于查看当前云服务器的登录用户(一行一个用户),wc -l用于统计当前的行数)
匿名管道
匿名管道的原理
匿名管道用于进程间通信,且仅限于本地父子进程之间的通信。
进程间通信的本质就是,让不同的进程看到同一份资源,使用匿名管道实现父子进程间通信的原理就是,让两个父子进程先看到同一份被打开的文件资源,然后父子进程就可以对该文件进行写入或是读取操作,进而实现父子进程间通信。
注意:
- 这里父子进程看到的同一份文件资源是由操作系统来维护的,所以当父子进程对该进程进行写入操作时,该文件缓冲区当中的数据是不会进行写时拷贝的。
- 管道虽然用的是文件的方案,但操作系统一定不会把进程进行通信的数据刷新到磁盘当中,因为这样做有IO参与会降低效率。也就是说,这种文件是一批不会把数据写到磁盘当中的文件,换句话说,磁盘文件和内存文件不一定是一一对应的,有些文件只会在内存当中存在,而不会在磁盘中存在。
pipe函数
pipe函数用于创建匿名管道,pip函数的函数原型如下:
int pipe(int pipefd[2]);
pipe函数的参数是一个输出型参数,数组pipefd用于返回两个指向管道读端和写端的文件描述符
数组元素 | 含义 |
pipefd[0] | 管道读端的文件描述符 |
pipefd[1] | 管道写端的文件描述符 |
pipe函数调用成功时返回0,调用失败时返回-1。
匿名管道使用步骤
在创建匿名管道实现父子进程间通信的过程中,需要pipe函数和fork函数搭配使用,具体步骤如下:
- 父进程调用pipe函数创建管道
- 2.父进程创建子进程
- 3.父进程关闭写端,子进程关闭读端
- 注意:
- 管道只能够进行单向通信,因此当父进程创建完子进程后,需要确认父子进程谁读谁写,然后关闭相应的读写端。
- 从管道写端写入的数据会被内核缓冲,直到从管道的读取被读取。
从文件描述符的角度再来看看这三个步骤:
- 父进程调用pipe函数创建管道
- 2.父进程创建子进程
- 3.父进程关闭写端,子进程关闭读端
- 在以下代码中,子进程向匿名管道当中写入10行数据,父进程向匿名管道读出数据。
#include <stdio.h> #include <unistd.h> #include <string.h> #include <stdlib.h> #include <sys/types.h> #include <sys/wait.h> int main() { //创建匿名管道 int pipefd[2]={0}; if(pipe(pipefd)<0) { perror("pipe error!\n"); exit(-1); } //创建子进程 pid_t id=fork(); if(id<0) { perror("fork error!\N"); exit(-1); } else if(id==0) { //子进程关闭读端 close(pipefd[0]); const char* msg="I am child...!\n"; int count=10; while(count--) { write(pipefd[0],msg,strlen(msg]); sleep(1); } } else { //关闭写端 close(pipefd[1]); char buf[64]; while(1) { ssize_t s=read(pipefd[0],buf,sizeof(buf)/sizeof(buf[0]); if(s>0) { buf[s]=0; printf("father get message:%s",buf); } else if(s==0) { printf("father read end of file...\n"); } sleep(1); } } return 0; }
运行结果如下:
管道读写规则
以四种情况来进行研究:
1.写端速度小于读端速度,管道大部分时间内为空,即读条件不满足 让子进程每5秒写一次,父进程一直在读,观察现象
代码如下:
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <string.h> #include <sys/types.h> #include <sys/wait.h> int main() { int pipefd[2]; int ret = pipe(pipefd); if (ret == -1){ // 管道创建失败 perror("make piep"); exit(-1); } pid_t id = fork(); if (id < 0){ perror("fork failed"); exit(-1); } else if (id == 0){ // child // 关闭读端 close(pipefd[0]); const char* msg = "I am child...!\n"; //int count = 0; // 写数据 while (1){ ssize_t s = write(pipefd[1], msg, strlen(msg)); sleep(5);// 管道大部分时间是空的,读条件不满足时,读端处于阻塞状态 printf("child is sending message...\n"); } } else{ // parent close(pipefd[1]); char buf[64]; //int count = 0; while (1){ ssize_t s = read(pipefd[0], buf, sizeof(buf)/sizeof(buf[0])-1); if (s > 0){ buf[s] = '\0';// 字符串后放一个'\0' printf("father get message:%s", buf); } else if (s == 0){ // 读到文件结尾 写端关闭文件描述符 读端会读到文件结尾 printf("father read end of file...\n "); } } } return 0; }
运行结果:读端处于阻塞
总结:当读条件不满足时,读端进程会处于阻塞,从task_struct会从运行队列调到等待队列,知道数据来才会转移到运行队列中
2.写端速度大于读端速度,管道大部分时间内是满的,即写调整不满足 让子进程一直写,父进程每3秒读一次,观察现象
代码改造:
pid_t id = fork(); if (id < 0){ perror("fork failed"); exit(-1); } else if (id == 0){ // child // 关闭读端 close(pipefd[0]); const char* msg = "I am child...!\n"; //int count = 0; // 写数据 while (1){ ssize_t s = write(pipefd[1], msg, strlen(msg)); printf("child is sending message...\n"); } } else{ // parent close(pipefd[1]); char buf[64]; //int count = 0; while (1){ ssize_t s = read(pipefd[0], buf, sizeof(buf)/sizeof(buf[0])-1); if (s > 0){ buf[s] = '\0';// 字符串后放一个'\0' printf("father get message:%s", buf); sleep(5);// 管道大部分时间都是满的,写条件不满足时,写端处于阻塞状态 } else if (s == 0){ // 读到文件结尾 写端关闭文件描述符 读端会读到文件结尾 printf("father read end of file...\n "); } } }
运行结果: 写端写了一会,管道满了,此时写端处于阻塞
3.关闭写端 让写端先写5秒,然后关闭写端,观察现象
// child // 关闭读端 close(pipefd[0]); const char* msg = "I am child...!\n"; int count = 0; // 写数据 while (1){ ssize_t s = write(pipefd[1], msg, strlen(msg)); printf("child is sending message...\n"); printf("CHILD: %d\n", count++); if (count == 5){ close(pipefd[1]); exit(-1); } sleep(1); } // parent close(pipefd[1]); char buf[64]; while (1){ ssize_t s = read(pipefd[0], buf, sizeof(buf)/sizeof(buf[0])-1); if (s > 0){ buf[s] = '\0';// 字符串后放一个'\0' printf("father get message:%s", buf); sleep(5);// 管道大部分时间都是满的,写条件不满足时,写端处于阻塞状态 } else if (s == 0){ // 读到文件结尾 写端关闭文件描述符 读端会读到文件结尾 printf("father read end of file...\n "); } }
运行结果:5秒后,关闭写端,读端会读到文件结尾
如果关闭写端,读端进程会读到文件结尾
4.关闭读端 3秒后关闭读端
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <string.h> #include <sys/types.h> #include <sys/wait.h> int main() { int pipefd[2]; int ret = pipe(pipefd); if (ret == -1){ // 管道创建失败 perror("make piep"); exit(-1); } pid_t id = fork(); if (id < 0){ perror("fork failed"); exit(-1); } else if (id == 0){ // child // 关闭读端 close(pipefd[0]); const char* msg = "I am child...!\n"; // int count = 0; // 写数据 while (1){ ssize_t s = write(pipefd[1], msg, strlen(msg)); printf("child is sending message...\n"); sleep(1); } } else{ // parent close(pipefd[1]); char buf[64]; int count = 0; while (1){ ssize_t s = read(pipefd[0], buf, sizeof(buf)/sizeof(buf[0])-1); if (s > 0){ buf[s] = '\0';// 字符串后放一个'\0' printf("father get message:%s", buf); //sleep(5);// 管道大部分时间都是满的,写条件不满足时,写端处于阻塞状态 } else if (s == 0){ // 读到文件结尾 写端关闭文件描述符 读端会读到文件结尾 printf("father read end of file...\n "); } sleep(1); if (count++ == 3){ close(pipefd[0]);// 读端关闭文件描述符,写端进程后序会被操作系统直接杀掉,没有进程读,写时没有意义的 break; } } int status; pid_t ret = waitpid(id, &status, 0); if (ret > 0){ // 等待成功 printf("child exit singal is %d\n", status&0x7f); } else{ // 等待失败 perror("wait failed"); exit(-1); } } return 0; }
运行结果:关闭读端后,子进程收到操作系统发送的13个信号杀死
读端关闭,写端进程会被操作系统发送信号杀死。
总结:管道是自带同步与互斥机制的,读端进程和写端进程是有一个步调协调的过程的,不会说当管道没有数据了读端还在读取,而当管道已经满了写端还在写入。读端进程读取数据的条件是管道里面有数据,写端进程写入数据的条件是管道当中还有空间,若是条件不满足,则相应的进程就会被挂起,直到条件满足后才会被再次唤醒。
1.当没有数据可读时:
O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。
O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来为止。
2.当管道满的时候
O_NONBLOCK disable:write调用阻塞,直到有进程读走数据
O_NONBLOCK enable:write调用返回-1,errno值为EAGAIN。
3.如果所有管道写端对应的文件描述符被关闭,则read返回0
4.如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出。
5.当要写入的数据量不大于PIPE_BUF时,Linux将保证写入的原子性。
6.当要写入的数据量大于PIPE_BUF时,Linux将不再保证写入的原子性
管道的特点
管道内部自带同步与互斥机制
我们将一次只允许一个进程使用的资源,称为临界资源。管道在同一时刻只允许一个进程对其进行写入或读取操作,因此管道也就是一种临界资源。
临界资源需要被保护的,若是我们不对管道这种临界资源进行任何保护机制,那么就可能会出现同一时刻有多个进程对同一管道进行操作的情况,进而导致同时读写,交叉读写以及读取到的数据不一致等问题。
为了避免这些问题,内核会对管道操作进行同步与互斥:
- 同步:两个或两个以上的进程在运行过程中协同步调,按预定的先后次序运行。比如:A任务运行依赖于B任务产生的数据
- 互斥:一个公共资源同一时刻只能被一个进程使用,多个进程不能同时使用公共资源。
实际上,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。对于管道的场景来说,互斥就是两个进程不可以同时对管道进行操作,它们会相互排斥,必须等一个进程操作1完毕,另一个才能操作,而同步也是指这两个不能同时对管道进行操作,但这两个进程必须要按照某种次序来对管道进行操作。
也就是说,互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序,而同步的任务之间则有明确的顺序关系。
2.管道的生命周期随进程
管道本质是通过文件进行通信的,也就是说管道依赖于文件系统,那么当所有该文件的进程都退出后,该文件也会被释放掉,所以说管道的生命周期随进程。
3.管道提供的是流式服务
对于进程A写入管道当中的数据,进程B每次从管道读取的数据多少是任意的,这种被称为流式服务,与之相对应的是数据报服务:
- 流式服务:数据没有明确的分割,不分一定的报文段。
- 数据报服务:数据有明确的分割,拿数据按报文段拿。
4.管道是半双工通信的
在数据通信中,数据在线路上的传送方式可以分为以下三种:
- 单工通信:单工模式的数据传输是单向的。通信双方中,一方固定为发送端,另一方固定为接收端。
- 半双工通信:半双工数据传输数据可以在一个信号载体的两个方向上传输,但是不能同时传输。
- 全双工通信:全双工通信允许数据在两个方向上同时传输,它的能力相当于两个单工通信方式的结合。全双工可以同时(瞬时)进行信号的双向传输。
管道是半双工的,数据只能向一个方向流动,需要双方通信时,需要建立起两个管道。
管道的大小
管道的容量是有限的,如果管道满了。那么写端将阻塞或失败,那么管道的最大容量是多少?
方法一:代码测试
若读端进程一直不读取管道中的数据,写端进程一直向管道写入数据,当管道被写满后,写端进程就会被挂起。据此,可以写入代码来测试管道的最大容量。
#include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> int main() { int fd[2] = { 0 }; if (pipe(fd) < 0){ //使用pipe创建匿名管道 perror("pipe"); return 1; } pid_t id = fork(); //使用fork创建子进程 if (id == 0){ //child close(fd[0]); //子进程关闭读端 char c = 'a'; int count = 0; //子进程一直进行写入,一次写入一个字节 while (1){ write(fd[1], &c, 1); count++; printf("%d\n", count); //打印当前写入的字节数 } close(fd[1]); exit(0); } //father close(fd[1]); //父进程关闭写端 //父进程不进行读取 waitpid(id, NULL, 0); close(fd[0]); return 0; }
可以看到,在读端进程不读取的情况下,写端进程最多写65536字节的数据就被操作系统挂起,意思就是当前Linux版本中管道的最大容量为65536字节。
方法二:使用man手册
根据man手册,在2.6.11之前的Linux版本中,管道的最大容量与系统页面大小相同,从Linux2.6.11往后,管道的最大容量是65536字节。