作者:@小萌新
专栏:@Linux
作者简介:大二学生 希望能和大家一起进步!
本篇博客简介:介绍Linux进程间通信
进程间通信(InterProcess Communication,IPC)是指在不同进程之间传播或交换信息。
我们都知道 进程是由程序员创建的 所以说进程之间通信本质就是程序员之间的通信
程序员在合作完成一个项目的时候需要同步数据
有时候在完成一个小demo之后需要共享这个demo的资源加快后序的开发进度
在一个小组的模块做完之后需要通知另外的一个小组进行后序的测试工作
如果测试的小组测出了bug要停止当前的开发修改此bug
所以说进程通信的目的有下面四个
进程间通信的本质就是让不同的进程看到同一份资源
我们都知道进程之间是具有独立性的 就拿父子进程对于同一个全局变量来说 如果子进程修改它的话会发生写时拷贝 从而达到一个保持进程独立性的效果
所以说我们如果想要两个进程看到同一份数据 这个数据肯定不能是属于某一个进程的
这个数据一定要属于操作系统 让操作系统来居中调度
由于这块资源可以由操作系统的不同模块来分配(内存 文件内核缓冲等)所以说这里就出现了很多种的进程通信方式
管道
System V IPC
POSIX IPC
在Linux中,管道是一种进程间通信的方式,它把一个程序的输出直接连接到另一个程序的输入
我们使用ls指令能够查看目录下的文件
我们使用grep指令可以搜索关键字
那么如果我们想要查看目录下所有带有14这个关键字的文件呢?是不是可以输入下面的指令
ls | grep 14
与此同时 结合我们之前的进程部分的学习 我们知道使用指令本质上也是在创建一个进程
所以说这里我们是不是就是在使用两个进程互相合作 既然两个进程在合作那么是不是它们之间一定发生了通信?
而这里实际上就是使用管道来进行进程间的通信
匿名管道是一种用于父子进程之间通信的方式 它不占用外部存储空间 只存在于内存中
我们在前面的讲解中说过 进程间通信的本质就是让不同的进程看到同一份资源 当然匿名管道也不例外
它的原理是让父子进程看到同一份文件资源 之后父子进程就可以对该文件进行写入或者是读取操作 从而实现进程间通信
这里有两个注意点
系统中给我们提供了一个函数来创建匿名管道 函数原型如下
int pipe(int pipefd[2]);
它的返回值是一个整型 如果我们调用成功返回0 失败返回-1
它的参数是一个输出型参数 返回的是管道读端和写端的文件描述符(匿名管道只能单向读写 即只能通过一端写入 一端输出)
数组元素 | 含义 |
---|---|
pipefd[0] | 管道读端的文件描述符 |
pipefd[1] | 管道写端的文件描述符 |
我们可以这样子来形象的记忆
0代表嘴巴 用嘴巴来吃饭 所以应该是读端
1代表铅笔 用铅笔来写字 所以应该是写端
我们在前面的原理部分说过 匿名管道的管理其实就是让父子进程看到同一份资源
这里的先决条件是父子进程
所以说我们在使用匿名管道的时候一定会使用到fork函数和pipe函数
其具体步骤如下
这里有两点需要注意:
如果站在文件的角度我们可以这么理解
下面是代码示例
1 // 这是一个测试管道的c语言程序2 #include 3 #include 4 #include 5 #include 6 #include 7 #include 8 9 10 11 int main()12 {13 // 1. 父进程创建管道14 int fd[2] = {0};15 16 if (pipe(fd) == -1)17 {18 // -1表示创建失败19 perror("pipe error!\n");20 exit(-1);21 }22 23 // 2. 父进程创建子进程24 pid_t pid = fork();25 if (pid == 0)26 {27 // child28 // 3. child write29 close(fd[0]); // 关闭读端30 31 // 4. send message32 const char* msg = "hello! im child!\n";33 int count = 5; 34 while(count--)35 {36 write(fd[1] , msg , strlen(msg));37 // 这里我们不需要把/0 拷贝到文件中因为这只是c语言字符串的规则38 sleep(2);39 }40 close(fd[1]);41 exit(0);42 }43 44 45 close(fd[1]);46 // father47 // 3. father read 48 // 关闭写端49 // 4. read message50 char buff[64] = {0}; 51 while (1)52 {53 ssize_t s = read(fd[0] , buff , sizeof(buff));54 if (s == 0)55 {56 // 写文件关闭了57 printf("write file close\n");58 break;59 }60 else if (s > 0)61 {62 printf("child say: %s",buff);63 }64 else65 {66 printf("error!\n");67 break;68 }69 }70 close(fd[0]);71 waitpid(-1 , NULL , 0);72 printf("wait child process success!\n");73 return 0;74 }
在这个程序中 我们使用父进程打开了一个管道并且创建了一个子进程
父进程关闭写端即只读 子进程关闭读端即只写
接着子进程向文件中写入五段消息 父进程读取这五段消息
子进程退出后 父进程回收子进程资源 结束
他的演示效果如下
- 管道内部自带同步与互斥机制
在了解这个特点之前我们需要了解下面的几个概念
从临界资源的概念上来讲 我们很容易的推断出临界资源是需要被保护的
不然我们没法保证在同一时刻只有同一进程访问这一共享资源
为了形成对于临界资源的保护 操作系统会对管道进行同步和互斥
同步其实是一种更加复杂的互斥 而互斥是一种特殊的同步
- 管道的生命周期
管道本质上是通过文件进行通信的 也就是说管道依赖于文件系统
那么当所有打开该文件的进程都退出后 该文件也就会被释放掉 所以说管道的生命周期随进程
- 管道提供的是流式服务
我们首先要理解下面的两个概念
流式服务: 数据没有明确的分割 不分一定的报文段
数据报服务: 数据有明确的分割 拿数据按报文段拿
也就是说 对于子进程输入的数据父进程读取多少是任意的 这就是流式服务
- 管道是半双工通信的
在数据的通信中 数据的传输方式大致可以分为下面的三种
单工通信 指数据只能在一个方向上传输。例如,广播电台只能向外发送信号,而不能接收来自外部的信号。
半双工通信 允许数据在两个方向上传输,但是在某一时刻,只允许数据在一个方向上传输。它实际上是一种切换方向的单工通信。例如,对讲机就是一种半双工通信设备。
全双工通信 允许数据同时在两个方向上传输。因此,全双工通信是两个单工通信方式的结合,它要求发送设备和接收设备都有独立的接收和发送能力。例如,在打电话时,我们可以同时听到对方说话并且说话。
我们的管道就是一种典型的半双工通信
如果我们想要父子进程之间可以相互交流通信我们可以再创建一根管道
我们在使用管道的时候会遇到下面四种特殊情况
- 写端不写 读端一直读
遇到这种情况的时候 读端会挂起 直到管道里面有数据时 读端才会被唤醒
代码表示如下 (其实我们前面写的演示代码就是这种情况 我们这里直接复用)
1 // 这是一个测试管道的c语言程序2 #include 3 #include 4 #include 5 #include 6 #include 7 #include 8 9 10 11 int main()12 {13 // 1. 父进程创建管道14 int fd[2] = {0};15 16 if (pipe(fd) == -1)17 {18 // -1表示创建失败19 perror("pipe error!\n");20 exit(-1);21 }22 23 // 2. 父进程创建子进程24 pid_t pid = fork();25 if (pid == 0)26 {27 // child28 // 3. child write29 close(fd[0]); // 关闭读端30 31 // 4. send message32 const char* msg = "hello! im child!\n";33 int count = 5; 34 while(count--)35 {36 write(fd[1] , msg , strlen(msg));37 // 这里我们不需要把/0 拷贝到文件中因为这只是c语言字符串的规则38 sleep(2);39 }40 close(fd[1]);41 exit(0);42 }43 44 45 close(fd[1]);46 // father47 // 3. father read 48 // 关闭写端49 // 4. read message50 char buff[64] = {0}; 51 while (1)52 {53 ssize_t s = read(fd[0] , buff , sizeof(buff));54 if (s == 0)55 {56 // 写文件关闭了57 printf("write file close\n");58 break;59 }60 else if (s > 0)61 {62 printf("child say: %s",buff);63 }64 else65 {66 printf("error!\n");67 break;68 }69 }70 close(fd[0]);71 waitpid(-1 , NULL , 0);72 printf("wait child process success!\n");73 return 0;74 }
我们可以看到 子进程其实是隔两秒才开始写数据的 而父进程一直在读数据
要是按照我们的理解 其实第二次父进程读数据的时候就会跳出while循环了
可是并没有 这就是管道的第一种特殊情况造成的影响 如果写端不写 读端会挂起
- 读端不读 写端一直写
遇到这种情况的时候 当管道里面的数据写满后 写端会被挂起 当读取了一定的数据后 写端才会被唤醒
代码表示如下
1 #include 2 #include 3 #include 4 #include 5 #include 6 #include 7 8 int main()9 {10 // 1. ´ò¿ª¹ÜµÀ11 int fd[2] = {0};12 if (pipe(fd) == -1)13 {14 perror("pipe error!\n");15 exit(-1);16 }17 18 // 2. ´´½¨×Ó½ø³Ì19 pid_t pid = fork();20 if (pid == 0)21 {22 // child 1 23 close(fd[0]);24 int count = 0;25 while(1)26 {27 write(fd[1] , "a" , 1);28 count++;29 printf("child say success! :%d\n",count);30 }31 }32 // father33 close(fd[1]);34 // 4. read message35 waitpid(-1 , NULL , 0);36 return 0;37 }
我们可以看到 子进程一直在写数据 在写满了管道之后就挂起了
此时需要父进程读取一定的数据子进程才能够继续写
- 写端关闭
遇到这种情况的时候 读端会在读取完全部的数据之后继续执行下面的流程
代码表示如下
1 #include 2 #include 3 #include 4 #include 5 #include 6 #include 7 8 9 int main()10 {11 int fd[2] = {0};12 if (pipe(fd) == -1)13 {14 perror("pipe error!\n");15 exit(-1);16 }17 18 pid_t id = fork();19 20 if (id == 0)21 {22 // child 23 close(fd[0]);24 int count = 5;25 const char* msg = "hello world\n";26 while(count--)27 {28 write(fd[1] , msg , strlen(msg));29 sleep(1);30 }31 close(fd[1]);32 exit(0);33 } 34 35 char buff[64];36 close(fd[1]);37 // father38 while(1)39 {40 ssize_t s = read(fd[0] ,buff ,sizeof(buff));41 if (s == 0)42 {43 printf("write close\n");44 break;45 }46 printf("child say :%s",buff);47 }48 49 while(1)50 {51 printf("father still exist\n");52 sleep(1);53 }54 return 0;55 }
我们可以看到 子进程停止写数据退出后 父进程在读完数据之后执行其他内容去了
读端关闭
遇到这种情况的时候 写端进程会直接退出
代码表示如下
1 #include 2 #include 3 #include 4 #include 5 #include 6 #include 7 8 9 10 int main()11 {12 int fd[2] = {0};13 if (pipe(fd) < 0) // -1 error14 {15 perror("pipe error!\n");16 return -1;17 }18 19 pid_t pid = fork();20 if (pid == 0) // child21 {22 close(fd[0]);23 const char* msg = "hello world!\n";24 while (1)25 {26 write(fd[1] ,msg , strlen(msg));27 printf("send success!\n");28 sleep(1);29 }30 31 // we do not set _exit func there32 }33 34 // father 35 close(fd[1]);36 sleep(6);37 close(fd[0]);38 waitpid(pid , NULL ,0);39 printf("wait child process success!\n");40 return 0;41 }
我们在这段代码中 在创建管道六秒后 将读端全部关闭 父进程等待子进程
但是我们并没有对子进程做任何事
我们来看看效果
我们可以发现 在六秒后读端关闭的瞬间 子进程退出了
这是为什么呢? 我们这里可以用到之前进程控制部分的相关知识 获取进程的退出信号 (因为这里的进程肯定不是正常退出的 所以查看退出码没有意义)
我们在原先的代码最后加上这段代码
int status = 0; waitpid(pid , &status ,0); printf("wait child process success!\n"); printf("the singal is : %d\n", status & 0x7f);
我们可以发现 这里的进程退出信号是13
接着我们使用kill -l
指令查看所有的退出信号
那么现在我们就能知道了 当匿名管道发生情况四的时候 操作系统会向进程发送13号命令来终止进程
我们可以复用上面的代码来测试出管道的大小
1 #include 2 #include 3 #include 4 #include 5 #include 6 #include 7 8 int main()9 {10 // 1. 创建管道11 int fd[2] = {0};12 if (pipe(fd) == -1)13 {14 perror("pipe error!\n");15 exit(-1);16 }17 18 // 2. 父子进程19 pid_t pid = fork();20 if (pid == 0)21 {22 // child 1 23 close(fd[0]);24 int count = 0;25 while(1)26 {27 write(fd[1] , "a" , 1);28 count++;29 printf("child say success! :%d\n",count);30 }31 }32 // father33 close(fd[1]);34 // 4. read message35 waitpid(-1 , NULL , 0);36 return 0;37 }
我们可以看到 子进程一共向管道中写入了65536字节的数据 也就是512kb
命名管道是一种特殊的管道,存在于文件系统中。它也被称为先进先出队列 (FIFO) 。它可以用于任何两个进程间的通信 而不限于同源的两个进程
我们可以狭义上的理解匿名管道就是没有名字的管道而命名管道就是有名字的管道
但是实际上它们还是有很多的不同点 比如说匿名管道必须要同源的两个进程才能通信而命名管道则不需要
命名管道就是一种特殊类型的文件,两个进程通过命名管道的文件名打开同一个管道文件,此时这两个进程也就看到了同一份资源,进而就可以进行通信了。
这里我们有两点需要注意:
我们在系统中一般使用mkfifo
命令来创建一个命名管道
使用效果如下
此时我们就可以使用该管道来实现进程间的通信了
我们使用一个进程不停的往该管道文件内写入数据再使用一个进程不停的读取数据 效果如下:
在左边 我们使用了这样的一行shell脚本
while true; do echo "hello fifo" ; sleep 1 ; done > fifo
它的意思是每隔一秒不停的循环输出hello fifo 并将输出的hello fifo重定向到fifo文件中
在右边我们不停的在从fifo管道中读取数据
我们可以发现这样一个神奇的现象:当我们退出右边的进程的时候 左边的bash进程也退出了
这是为什么呢?
我们前面讲管道的四种特殊情况下讲过这一点 当管道的读端关闭的时候 写端进程会被操作系统使用13信号杀掉
而我们的shell脚本就是由bash进程执行的 所以说当我们关闭读端的左边的bash进程就终止了
我们在程序中创建命名管道要使用mkfifo
函数 它的函数原型如下
int mkfifo(const char *pathname, mode_t mode);
返回值
参数
const char *pathname
第一个参数是一个字符串
这里关于当前路径的概念 如果还有不理解的同学可以参考我的这篇博客
基础IO
mode_t mode
这里设置是管道文件的权限 我们一般使用八进制的数字设置
当然权限的设置还和umask有关
有关权限的概念我之前的一篇博客以及详细介绍了 这里就不再赘述
Linux中权限
下面是该函数的使用
1 #include 2 #include 3 #include 4 5 6 int main()7 {8 if (mkfifo("myfifo" , 0666) < 0)9 {10 perror("mkfifo fail!\n");11 return -1;12 } 13 return 0;14 }
上面这段代码的意思是 在当前路径下创建一个叫做mkfifo
的命名管道
执行代码后查看当前目录下的文件 我们发现管道文件真的被创建了
我们可以创建两个程序分别代表客户端 (client) 和服务端(server)
服务器创建一个命名管道 之后客户端和服务端全部打开该命名管道
服务端读数据 客户端写数据
服务端代码
1 #include 2 #include 3 #include 4 #include 5 #include 6 #include 7 #include 8 #define FILENAME "myfifo"9 10 // 服务端要求创建一个命名管道 11 // 并且接受客户端发来的消息12 int main()13 {14 // open the fifo15 if (mkfifo(FILENAME , 0666) < 0)16 {17 perror("mkfifo error!\n");18 return -1;19 }20 21 // open the file 22 int fd = open(FILENAME , O_RDONLY);23 if (fd < 0)24 {25 perror("open fail!\n"); 26 return 1;27 }28 29 char msg[128];30 // read msg from fifo 31 while (1)32 {33 msg[0] = 0;34 ssize_t s = read(fd , msg , sizeof(msg)-1); 35 if (s > 0)36 {37 msg[s] = 0;38 printf("client say: %s\n" , msg);39 }40 else if (s == 0)41 {42 printf("client end!\n");43 break;44 }45 else 46 {47 perror("read error!\n");48 exit(-1);49 }50 }51 close(fd);52 return 0;53 }
而对于客户端来说 服务端已经将命名管道创建好了
所以说客户端只需要往管道里面写入数据就好了
服务端代码
1 #include 2 #include 3 #include 4 #include 5 #include 6 #include 7 #include 8 #define FILENAME "myfifo"9 10 11 12 int main() 13 {14 int fd = open(FILENAME , O_RDWR);15 if (fd < 0)16 {17 perror("open error\n");18 exit(-1);19 }20 21 char msg[128];22 while(1)23 {24 msg[0] = 0;25 printf("Please write :");26 fflush(stdout);27 // the screen is line fflush but there is no \n28 ssize_t s = read (0 , msg , sizeof(msg) - 1);29 if (s > 0)30 {31 msg[s-1] = 0; // because the end of msg is :xxxx\n\032 write(fd , msg , strlen(msg));33 }34 }35 close(fd);36 return 0;37 }
接下来我们只需要将服务端和客户端都运行起来 就能够实现两个进程之间的通信了
服务端和客户端之间的退出关系
我们这里首先要明白客户端是写端
服务端是读端
当写端退出后 读端会继续执行后面的程序
所以说客户端退出后 服务端会继续执行后面的代码
而当读端退出后 写端会被操作系统杀死
所以说服务端退出后 客户端会被操作系统杀死
通信是在内存中进行的
我们可以尝试让客户端一直写数据 但是服务端一直不读数据
之后查看fifo文件的大小
我们可以发现fifo文件的大小还是0 这说明我们的命名管道通信还是在内存当中进行通信的
我们通过命名管道可以实现一个进程对于另一个进程遥控
当然这里要利用到子进程和进程替换的一些知识(因为如果使用父进程进行进程替换的话替换一次服务端就停止服务了)
我们这里只需要对于服务端的代码进行一些修改
1 #include 2 #include 3 #include 4 #include 5 #include 6 #include 7 #include 8 #include 9 #define FILENAME "myfifo"10 11 // 服务端要求创建一个命名管道 12 // 并且接受客户端发来的消息13 int main()14 {15 // open the fifo16 if (mkfifo(FILENAME , 0666) < 0)17 {18 perror("mkfifo error!\n");19 return -1;20 }21 22 // open the file 23 int fd = open(FILENAME , O_RDONLY);24 if (fd < 0)25 {26 perror("open fail!\n");27 return 1; 28 }29 30 char msg[128];31 // read msg from fifo 32 while (1)33 {34 msg[0] = 0; 35 ssize_t s = read(fd , msg , sizeof(msg)-1); 36 if (s > 0) 37 {38 msg[s] = 0;39 printf("client say: %s\n" , msg);40 if (fork() == 0)41 {42 execlp(msg , msg , NULL);43 exit(1);44 }45 waitpid(-1 , NULL , 0);46 }47 else if (s == 0)48 {49 printf("client end!\n");50 break;51 }52 else 53 {54 perror("read error!\n");55 exit(-1);56 }57 }58 close(fd);59 return 0;60 }
下面是实机效果
虽然它们有这些不同点 但是它们工作的时候都是在内存中传输数据的
我们在命令行中也可以使用管道来通信
比如说我们下面的指令
这就是我们在使用命令行中的管道进行通信
那么命令行中的管道是匿名管道还是命名管道呢?
答案是匿名管道 因为实际上我们在使用这个管道的时候磁盘上并没有创建文件