目录
一、进程间通信的介绍
✍️进程间通信的目的
- 数据传输:一个进程需要将它的数据发送给另一个进程
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
✍️进程间通信的本质
进程间通信的本质就是让不同的进程看到同一份资源
这里要解释一下了,由于进程具有独立性,所以想要直接实现不同进程间的相互通信还是非常困难的。
这里我们就引入了第三方资源来作为不同进程之间通信的桥梁,也就是说这些进程可以通过向这么个第三方资源进行写入和读取来进行通信,示意图如下:
通过上面的方式,我们就实现的不同进程看到了同一份资源,所以说进程间通信的本质就是让不同的进程看到了同一份资源。
进程间通信的分类
✍️管道
- 匿名管道
- 命名管道
✍️System V IPC
- System V 消息队列
- System V 共享内存
- System V 信号量
✍️POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
二、管道
🧠什么是管道
管道是Unix中最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”。
我们之前在谈Linux的命令的时候也提及了管道这一概念:
例如,这个查看当前服务器有多少用户登录的命令
我们发现这个命令是由who命令和wc命令组成的,这个命令运行起来之后就变成了两个进程了,who进程把执行得到的结果通过标准输出写入到了管道之中,wc进程通过标准输入从管道中读取数据,处理完之后再把结果通过标准输出给到用户。
注意:who显示的是相关信息,wc -l命令是用来统计行数的。
✍️匿名管道
📝匿名管道的原理
匿名管道用于进程间的通信,且仅限于父子进程之间的通信。
我们之前也说了,进程间通信的本质就是让不同的进程看到了同一份资源,使用匿名管道实现通信的原理就是让父子进程看了同一份被打开的文件资源,然后父子进程就可以对同一份资源进行读写操作,从而实现了进程间的通信。
敲黑板:
这里需要注意的是这里打开的文件是由操作系统来进行管理的,父子进程进行读写时并不会进行写时拷贝。
📝pipe函数
我们可以使用pipe函数来创建匿名管道
int pipe(int pipefd[2]);
参数说明:输入的参数实际上是输出型的参数,数组中的两个元素分别指向读端和写端。‘
- pipefd[0]:管道读端的文件描述符
- pipefd[1]:管道写端的文件描述符
如果创建失败则返回-1,成功创建则返回0。
📝匿名管道的使用步骤
我们在使用管道通信的时候,既要使用fork函数来创建子进程,也要使用pipe函数来创建管道。
第一步:父进程创建管道
第二步:父进程创建子进程
第三步:父端关闭写,子端关闭读
敲黑板:
这里的通信只能是单向的,也就是我们说的半双工,在代码中的反应就是管道只开一个读端和写端。
我们这里可以将上面的三个步骤再细化一下:
第一步:父进程创建管道
第二步:父进程创建子进程
第三步:父进程关闭写端,子进程关闭读端
我们来写个代码演示一下:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main() {
int fd[2] = {0}; // 作为pipe函数的参数来进行传递
if(pipe(fd) < 0) {
perror("pipe");
exit(1);
}
pid_t id = fork();
if(id == 0) // 子进程
{
close(fd[0]); // 关闭了读端
const char* message = "Hello my father, I am child...";
int count = 10;
while(count--) {
write(fd[1], message, strlen(message)); // 字节流的写入数据
sleep(1);
}
close(fd[1]);
exit(0);
}
// 父进程
close(fd[1]); // 关闭了写端
char buffer[64];
while(1) {
ssize_t s = read(fd[0], buffer, sizeof(buffer)); // 由于休眠时间的存在,就会读完整
if(s > 0) {
buffer[s] = '\0';
printf("child send to father: %s\n", buffer);
}else if (s == 0) {
printf("the end\n");
break;
}else {
printf("read error\n");
break;
}
}
close(fd[0]);
waitpid(id, NULL, 0);
return 0;
}
📝管道读写的规则
pipe2函数的操作和pipe函数类似,具体的函数原型如下:
int pipe2(int pipefd[2], int flags);
pipe函数的第二个参数用来设置选项可以设置为O_NONBLOCK ,也就是非阻塞:
- 默认不传参数时,read在没有数据会一直等待数据的到来,write在数据数据写满的时候会等待数据被读走。
- 在加了O_NONBLOCK 参数的时候,read在没有数据的时候会返回-1,并把erron的值设置为EAGIN,write在数据写满的时候也会返回-1,并把erron的值设置为EAGIN。
- 如果所有管道写端对应的文件描述符被关闭,则read返回0
- 如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出
- 当要写入的数据量不大于PIPE_BUF时,Linux将保证写入的原子性。
- 当要写入的数据量大于PIPE_BUF时,Linux将不再保证写入的原子性。
📝管道的特点
第一点:管道内自带了同步和互斥机制
首先我们要知道什么是同步和互斥:
同步:两个或者是两个以上的进程在运行时要按照之前约定好的顺序依次执行,可以类比到做菜的场景,我们要先切菜洗菜才能去炒,要有一个先后的顺序才行。
互斥:防止多个进程同时操作了同一个资源,可以类比到打印的场景,A和B都要使用打印机打印,但是我们不能同时发送内容到打印机,否则就是打印出来的结果出错。
我们仔细思考会发现两者其实是类似的,同步是一种更加复杂的互斥,而互斥则是一种特殊的同步,对于我们的管道而言,互斥就是两个进程不可以同时对管道进行操作,他们会相互排斥,而同步也说的是不能对管道进行操作,但这里的要求是必须要是按照一定的次序来对管道进行操作的。
第二点:管道的生命周期随进程
管道的本质就是通过文件来进行通信的,也就是说管道是依赖于文件系统的,那么当所有的文件都退出了,文件也就被释放掉了,所一说管道的生命周期是随进程的。
第三点:管道提供的是流式服务
对于进程A写入管道的数据,进程B每次都可以从管道中任意读取数据,这种方式被称之为流式服务,与之对应的就是数据报服务,也就是后面我们会讲的字节流和数据包。
- 流式服务:数据没有明确的分割,也就是会粘连在一起。
- 数据报服务:数据有了明确的分割了,数据是一份一份的。
第四点:管道是半双工通信
在数据通信中,数据在线路上的传送方式有以下这几种:
1、单工通信:数据的传输是单向的。通信双方中,一方是发送端,一方是接受数据端。
2、半双工通信:半双工是指既可以从从两个反向上面通信,但是不可以同时通信。
3、全双工通信:全双工就是两个反向上可以同时通信,也就是加强版的半双工,双倍的单双工。
📝管道的四种特殊情况
在我们使用管道进行通信的时候会有如下四种情况:
- 读正常&&写满了,读端进程还在正常的工作,写端继续写,写端会被阻塞挂起,直到读端读取数据、释放空间。
- 写正常&&读空了,写端进程在正常运行,读端尝试读发现管道内没有数据了,读操作会被阻塞挂起,直到有数据被写入。
- 写关闭&&读正常,写端已经被关闭了,读端继续尝试从管道内读取数据,如果有数据就读,读到空就会read返回0。
- 读关闭&&写正常,读端关闭之后写端会继续写入数据,写操作会收到一个SIGPIPE的信号,将进程终止掉。
前面的这两个情况是很好说明的,因为管道是自带了互斥和同步的机制的,也就是说读端进程和写端进程会有一个协调的过程,不会读已经空,也不会写已经满;第三种情况也是很好理解的,也就是说,读端会继续去读数据,读完了就回去去执行后面的逻辑;我们这里主要还是关注一下情况四, 其实也很好理解,就是读端已经是出于关闭状态了,这个时候再去写已经没有意义了,所以操作系统会发出信号将写端进程直接杀死。
我们也可以写个代码来看看情况四:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{
int fd[2] = {0};
if(pipe(fd) < 0) {
perror("pipe error");
exit(1);
}
pid_t id = fork();
if(id == 0) // 子进程
{
close(fd[0]);
const char* message = "hello my father, I am child...";
int count = 10;
while(count--)
{
write(fd[1], message, strlen(message));
sleep(1);
}
close(fd[1]);
exit(0);
}
// 父进程
close(fd[1]);
close(fd[0]); // 模拟情况四
int status = 0;
waitpid(id, &status, 0);
printf("child get signal: %d\n", status & 0x7f);
printf("状态码:%d, 信号:%d\n", WEXITSTATUS(status), WTERMSIG(status));
return 0;
}
运行结果显示,子进程收到了13号信号:
我们可以使用kill -l命令来查看具体是哪个信号:
kill -l
📝管道的大小
我们可以使用三种方法来查看管道的大小:
方法一:查看man手册
我们可以是用命令man 7 pipe来查看:
这上面显示的是:
-
Linux 2.6.11 之前,管道容量等于系统页面大小(例如 i386 是 4096 字节)
-
Linux 2.6.11 及以后版本,管道容量是 65536 字节(64KB)
我们也可以使用uname -r命令来查看我们的系统版本:
所以我们的管道大小是64KB。
方法二:使用ulimit命令
我们还可以使用ulimlt -a命令来查看当前资源的限制设定。
根据现实我们发现,管道的最大容量是4096字节,这个结果其实是过时的。
方法三:自行测试
为了更好的验证我们的说法,我们这里还是来写个代码来测试一下,具体的逻辑是:我们可以一直让写进程写入数据,而读端不去读去,那么就可以测试出具体的管道的容量。
测试代码:
#include <stdio.h>
#include <stdbool.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
int fd[2] = {0};
if(pipe(fd) < 0)
{
perror("pipe error");
exit(1);
}
pid_t id = fork();
if(id == 0)
{
close(fd[0]);
const char c = 'a';
int count = 0;
while(true)
{
write(fd[1], &c, sizeof(c));
count++;
printf("count: %d\n", count);
}
close(fd[1]);
exit(0);
}
// 父进程
close(fd[1]);
waitpid(id, NULL, 0);
close(fd[0]);
return 0;
}
我们运行之后可以发现,我们的最大容量是65536个字节也就是64KB,这也就是说明了我们方法一的正确性。
✍️命名管道
📝命名管道的原理
匿名管道只能用于具有共同祖先的进程之间的通
信,通常就是我们共用了一个进程创建的管道,然后改进程再fork了一个子进程实现父子之间的通信机制。
我们要想实现两个没有任何关系的进程之间的通信就需要实现命名管道了,命名管道说白了就是一个存在于文件系统的特殊文件,两个进程通过命名管道的文件明打开同一个管道文件,此时这两个进程就可以看到同一份资源了,就具备了通信的前提了。
📝使用命令创建出命名管道
我们可以使用mkfifo命令来创建一个命名管道
mkfifo fifo
我们还可以看到我们创建的这个管道文件其实是p类型的一个文件,代表它是一个管道文件。
我们可以使用这一管道文件,实现两个进程之间的通信了,下面是我模拟的两个进程间的通信,也就是一个进程在向管道文件中每隔一秒写入一条消息,然后另一个进程在从这个文件中来读取我们写入的数据,我们观察到的结果就是右边的窗口每隔一秒打印出一条消息,这样我们便可以实现通信了。
这里还可以验证我们之前说的管道的四种情况的情况四,我们直接把读端退了,那么我们的写端也会被操作系统杀掉,下面是我们验证的效果:
📝创建一个命名管道
在程序中创建命名管道使用mkfifo函数,mkfifo函数的原型是:
int mkfifo(const char *pathname, mode_t mode);
参数说明:
mkfifo函数的第一个参数是pathname,表示要创建的命名管道文件。
- 如果pathname是以路径的方式给出来的,那么命名管道会创建在pathname路径下。
- 如果pathname是以文件名的方式给出来的,那么命名管道文件默认会创建在当前路径下。
mkfifo函数的第二个参数是mode,表示的是创建命名管道文件的默认权限。
需要注意的是这里的权限默认会受到文件掩码的影响,实际的权限会变成:mode&~(umask)。我们为了使创建出来的命名管道的文件权限不会收受到文件掩码的影响,我们需要将umask设置为0。
返回值说明:
命名管道创建成功就会返回0。
命名管道创建失败就会返回-1。
我们也是可以来写个代码来测试一下的:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#define FILE_NAME "myfifo"
int main()
{
umask(0);
if(mkfifo(FILE_NAME, 0666))
{
perror("mkfifo");
return 1;
}
return 0;
}
运行完代码之后我们就可以创建出来一个管道文件了。
📝 命名管道打开的规则
✅ 命名管道的打开规则总结:
-
读打开 FIFO:
-
阻塞模式(默认):会阻塞,直到有写端打开。
-
非阻塞模式(
O_NONBLOCK
):立即成功返回。
-
-
写打开 FIFO:
-
阻塞模式(默认):会阻塞,直到有读端打开。
-
非阻塞模式(
O_NONBLOCK
):立即失败,返回ENXIO
错误。
-
📝使用命名管道来实现serve和client通信
实现的逻辑:先让服务端运行起来,在服务端实现创建一个命名管道文件,然后再以读的方式来打开命名的管道文件,之后服务端就可以从这个创建出来的命名管道中读取客户端发来的通信信息了。
服务端的代码:
#include "comm.h"
int main()
{
umask(0);
if(mkfifo(FILE_NAME, 0666) < 0)
{
perror("mkfifo");
exit(1);
}
int fd = open(FILE_NAME, O_RDONLY);
if(fd < 0)
{
perror("open error");
exit(2);
}
char message[256];
while(true) {
message[0] = '\0';// C语言是以\0结束,变向的清空了字符数组
ssize_t s = read(fd, message, sizeof(message) - 1); // 接受字节数
if(s > 0) {
message[s] = '\0';
printf("client: %s\n", message);
}else if (s == 0) {
printf("client quit\n");
break;
}else {
printf("read error!\n");
break;
}
}
close(fd);
return 0;
}
客户端代码:
#include "comm.h"
int main() {
int fd = open(FILE_NAME, O_WRONLY);
if(fd < 0) {
perror("open error");
exit(1);
}
char message[256];
while(true) {
message[0]= '\0';
printf("please enter: ");
fflush(stdout);
ssize_t s = read(0, message, sizeof(message) - 1);
if(s > 0) {
message[s - 1] = '\0';
write(fd, message, strlen(message));
}
}
close(fd);
return 0;
}
共用头文件:
#pragma once
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <stdlib.h>
#include <fcntl.h>
#include <stdbool.h>
#define FILE_NAME "myfifo"
下面是我们运行的效果:
我们也是可以通过下面的这个命令看一下是不是两个无关的进程:
这两个进程确实是两个无关的进程。
这里我们还可以验证一下我们之前的说法:
当客户端退出之后, 服务端应为都不到数据,而去执行之后的代码了,也就是直接也退出了:
当我们的服务端直接退出之后,我们的客户端也就没有了存在的意义了,就会在下一次写入数据的时候,收到操作系统发来的13号信号而被强制杀掉。
这里我们也可以验证我们的通信是在内存中的
我们只让客户端写数据,而服务端不去读数据,那么这个管道文件的大小会怎么变化。
我们改写一下我们的客户端代码:
#include "comm.h"
int main()
{
umask(0);
if(mkfifo(FILE_NAME, 0666) < 0)
{
perror("mkfifo");
exit(1);
}
int fd = open(FILE_NAME, O_RDONLY);
if(fd < 0)
{
perror("open error");
exit(2);
}
char message[256];
while(true) {
// message[0] = '\0';// C语言是以\0结束,变向的清空了字符数组
// ssize_t s = read(fd, message, sizeof(message) - 1); // 接受字节数
// if(s > 0) {
// message[s] = '\0';
// printf("client: %s\n", message);
// }else if (s == 0) {
// printf("client quit\n");
// break;
// }else {
// printf("read error!\n");
// break;
// }
}
close(fd);
return 0;
}
这里我们可以看到即使是我们不读数据,我们的文件也是不会刷新到磁盘上的,ll命令查看发现文件的大小一直是0,也就是说我们的通信实现内存进行的。
📝用命名管道实现派发任务
实际上我的管道不仅仅是可以实现计算机之间传递几个字符,也是可以实现任务相关的派发的,未来我们学了网络协议之后,也会实现一个简易版的网络版本计算器,今天我们在这里可以实现一个管道版本的计算器。
这里我们只需要修改一下之前的服务端代码即可:
#include "comm.h"
int main() {
umask(0);
if(mkfifo(FILE_NAME, 0666) < 0) {
perror("mkfifo error");
exit(1);
}
int fd = open(FILE_NAME, O_RDONLY);
if(fd < 0) {
perror("open error");
exit(1);
}
char message[256];
while(true) {
message[0] = '\0';
ssize_t s = read(fd, message, sizeof(message) - 1);
if(s > 0) {
message[s] = '\0';
printf("client: %s\n", message);
char* oper = "+-*/%";
char* p = message;
int flag = 0;
while(*p) {
switch(*p) {
case '+':
flag = 0;
break;
case '-':
flag = 1;
break;
case '*':
flag = 2;
break;
case '/':
flag = 3;
break;
case '%':
flag = 4;
break;
}
p++;
}
char* data1 = strtok(message, "+-*/%");
char* data2 = strtok(NULL, "+-*/%");
int num1 = atoi(data1);
int num2 = atoi(data2);
int ret = 0;
switch(flag) {
case 0:
ret = num1 + num2;
break;
case 1:
ret = num1 - num2;
break;
case 2:
ret = num1 * num2;
break;
case 3:
ret = num1 / num2;
break;
case 4:
ret = num1 % num2;
break;
}
printf("%d %c %d = %d\n", num1, oper[flag], num2, ret);
}
else if (s == 0) {
printf("client quit!\n");
break;
}else {
printf("read error!\n");
break;
}
}
close(fd);
return 0;
}
实际执行效果如下:
📝使用命名管道实现遥控操作
我们这里可以实现通过一个进程来控制另一个进程的行为,也就是说我们的客户端可以通过输入命令到管道里面来让服务端读取并执行该命令。
这里我们就实现一个简单的不带选项的命令,如果想实现带选项的就要加上我们之前写过的命令行解析。这里我们也是只需要修改服务端代码:
#include "comm.h"
int main() {
umask(0);
if(mkfifo(FILE_NAME, 0666) < 0) {
perror("mkfifo error");
exit(1);
}
int fd = open(FILE_NAME, O_RDONLY);
if(fd < 0) {
perror("open error");
exit(2);
}
char message[256];
while(true) {
message[0] = '\0';
ssize_t s = read(fd, message, sizeof(message) - 1);
if(s > 0) {
message[s] = '\0';
printf("client: %s\n", message);
pid_t id = fork();
if(id == 0) {
execlp(message, message, NULL);
exit(1);
}
waitpid(-1, NULL, 0);
}else if(s == 0) {
printf("client quit!\n");
break;
}else {
printf("read error!\n");
break;
}
}
close(fd);
return 0;
}
运行结果如下:
📝用命名管道实现文件的拷贝操作
这里我们实现的是本地文件之间的互传工作,未来我们学习了网络就可以实现线上互传了。
这里我们要实现的逻辑是:让客服端将文件test.txt通过管道发送到服务端来,在服务端创建一个file-copy.txt文件,并把从管道获取的数据写入到文件file-copy.txt中,从而实现拷贝操作。
客户端的逻辑是:以写的方式打开已经存在的管道文件,再以读的方式打开test.txt文件,之后我们需要做的就是将test.txt文件中的数据读出来再写入管道文件。
客户端代码如下:
#include "comm.h"
int main() {
int fd = open(FILE_NAME, O_WRONLY);
if(fd < 0) {
perror("open error");
exit(1);
}
int fdin = open("test.txt", O_RDONLY);
if(fdin < 0) {
perror("open error");
exit(2);
}
char message[256];
while(true) {
ssize_t s = read(fdin, message, sizeof(message));
if(s > 0) {
write(fd, message, s);
}else if(s == 0) {
printf("read end of test.txt");
break;
}else {
printf("read error");
break;
}
}
close(fd);
close(fdin);
return 0;
}
服务端的逻辑是: 创建一个管道文件并以读方式打开它,再创建一个名为test_copy.txt的文件,之后就是将从管道中读取上来的数据写入到创建的test_copy.txt文件中即可。
服务端代码:
#include "comm.h"
int main() {
umask(0);
if(mkfifo(FILE_NAME, 0666) < 0) {
perror("mkfifo error");
exit(1);
}
int fd = open(FILE_NAME, O_RDONLY);
if(fd < 0) {
perror("open error");
exit(2);
}
int fdout = open("test_copy.txt", O_CREAT | O_WRONLY, 0666);
if(fdout < 0) {
perror("open error");
exit(3);
}
char message[256];
while(true) {
message[0] = '\0';
ssize_t s = read(fd, message, sizeof(message) -1);
if(s > 0) {
write(fdout, message, s);
}else if(s == 0) {
printf("client quit!\n");
break;
}else {
printf("read error!\n");
break;
}
}
close(fd);
close(fdout);
return 0;
}
公共头文件:
#pragma once
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <stdlib.h>
#include <fcntl.h>
#include <stdbool.h>
#define FILE_NAME "myfifo"
实现效果如下:
📝命名管道和匿名管道的区别
特性 | 匿名管道(pipe) | 命名管道(FIFO) |
---|---|---|
是否有文件名 | ❌ 没有 | ✅ 有(在文件系统中) |
创建方式 | pipe(fd) | mkfifo(path, mode) |
通信进程关系 | 仅限亲缘关系进程(如父子) | 可用于任意无血缘关系进程 |
总结起来就是匿名管道和命名管道的唯一区别在于创建和打开的方式不一样,一旦创建并打开之后是具有相同的语义的。
📝命令行上面的管道的类别
我们先来看看下面这个命令:
我们先创建一个文件test.txt,内容如下:
我们可以使用过滤命令来对文本进行过滤:
cat test.txt | grep world
那么我们这里的管道“|”是匿名管道还是命名管道呢?
我们可以先来看看它们是不是具有亲缘关系,下面我们可以写三个休眠命令并用管道来查看他们的进程PPID,我们发现它们是一样的,这也就是说它们是由同一个父进程创建的子进程。
他们的父进程其实就是bash。
也就说管道连接的各个进程实际上是有亲缘关系的,而且我们在使用这个管道的时候也并没有生成一个命名管道文件,所以实际上命名行上的管道是匿名管道。