一、进程间通信
1.1 进程间通信的概念
进程间通信(IPC,Interprocess communication)是一组编程接口,让程序员能够协调不同的进程,使之能在一个操作系统里同时运行,并相互传递、交换信息。
1.2 进程间通信的本质
通俗的来讲,进程间通信其实就是为了让不同的进程看到同一份资源。
各个运行的进程之间都具有独立性,这个独立性主要体现在数据层面,而逻辑代码层面可以实现共有(例如子进程和父进程),因此实现各个进程之间的通信非常困难。若要想实现进程间通信,必须借助第三方资源。这些进程通过向第三方资源的写入或者读取数据,进而实现通信,第三方资源其实就是操作系统提供的一段内存区域。
1.3 进程间通信的目的
- 数据传输:一个进程将它的数据传输给另外的进程。
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知发生了某种事件,比如子进程终止时需要通知父进程。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
1.4 进程间通信的分类
- 管道
- 匿名管道
- 命名管道
- System V IPC
- System V 共享内存
- System V 消息队列
- System V 信号量
- POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
二、管道
2.1 管道的概念
管道是Unix中最古老的进程间通信的形式。我们把从一个进程连接到另一个进程的一个数据流称为一个 “管道”。管道是单向的、先进先出的,它把一个进程的输出和另一个进程的输入连接在一起。一个进程(写进程)在管道的尾部写入数据,另一个进程(读进程)从管道的头部读出数据。
例如,统计我们当前使用云服务器上的登录用户个数。
who | wc -l
其中,who命令和wc命令都是两个程序,当它们运行起来后就变成了两个进程,who进程通过标准输出将数据传输到“管道”当中,wc进程再通过标准输入从“管道”当中读取数据,至此便完成了数据的传输,进而完成数据的进一步加工处理。
2.2 匿名管道
2.2.1 匿名管道的原理
匿名管道仅限于本地父子进程之间的通信
匿名管道实现父子进程间通信的原理就是,让两个父子进程先看到同一份被打开的文件资源,然后父子进程就可以对该文件进行写入或是读取操作,进而实现父子进程间通信。
注意:
- 这里父子进程看到的同一份文件资源是由操作系统来维护的,所以当父子进程对该文件进行写入操作时,该文件缓冲区当中的数据并不会进行写时拷贝。
- 管道虽然是一种文件,但是程序对管道写入的数据不会刷新进磁盘中,因为将数据刷新进磁盘会降低进程间通信的效率。
2.2.2 pipe函数
pipe系统函数的功能就是创建一个匿名管道。
函数原型:
#include <unistd.h> int pipe(int fildes[2]);
参数:
fildes: pipe函数的参数是一个输出型参数,数组fildes用于返回两个指向管道读端和写端的文件描述符,其中fidles[0]表示读端,fildes[1]表示写端。
返回值:
创建成功返回0,失败返回错误码。
2.2.3 匿名管道的创建与使用
1、首先父进程调用pipe系统调用创造匿名管道。
2、父进程调用fork函数创建子进程。
3、 父进程关闭写端,子进程关闭读端。
此时就可以实现父子间的进程通信了,即子进程向管道中写入数据,父进程从管道中读取数据。
【注意事项】
- 管道只能够进行单向通信,因此当父进程创建完子进程后,需要确认父子进程谁读谁写,然后关闭相应的读写端。
- 从管道写端写入的数据会被内核缓冲,直到从管道的读端被读取。即只有当子进程写完了数据,父进程才能读取。
站在文件描述符角度来深度理解管道
站在内核角度看待管道
所以,看待管道,就如同看待文件一样!管道的使用和文件一致,迎合了 “Linux一切皆文件思想” 。
以下是父子进程间通信的简单代码:
#include <iostream> #include <unistd.h> #include <cstring> #include <ctime> #include <string> #include <sys/wait.h> #include <cassert> using namespace std; int main() { //创建匿名管道 int pipefd[2] = {0}; if(pipe(pipefd) != 0) { cerr << "pipe error" << endl; return 1; } //创建子进程 pid_t id = fork(); if(id < 0) { cerr << "fork error" << endl; return 2; } else if(id == 0) { //子进程进行读操作,应该关闭写 close(pipefd[1]); #define NUM 1024 char buffer[NUM]; while(true) { cout << "时间戳:" <<(uint64_t)time(nullptr) << endl; memset(buffer, 0, sizeof(buffer)); ssize_t s = read(pipefd[0], buffer, sizeof(buffer) - 1); if(s > 0) { buffer[s] = '\0'; cout << "子进程收到消息,内容是:" << buffer << endl; } else if(s == 0) { cout << "父进程写完了,子进程退出" << endl; break; } else { //do nothing } } close(pipefd[0]); exit(0); } else { //父进程进行写操作,应该关闭读 close(pipefd[0]); const char* msg = "你好啊,子进程!我是父进程。这次发送信息的编号:"; int cnt = 0; while(cnt < 5) { char sendBuffer[1024]; sprintf(sendBuffer, "%s : %d", msg, cnt); sleep(1); write(pipefd[1], sendBuffer, strlen(msg)); ++cnt; cout << "cnt:" << cnt <<endl; } close(pipefd[1]); cout << "父进程写完了" <<endl; } }
2.2.4 匿名管道读写规则
pipe2函数与pipe函数类似,也是用于创建匿名管道,其函数原型如下:
int pipe2(int pipefd[2], int flags);
pipe2函数的第二个参数用于设置选项。
- 当没有数据可读时:
- O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来到为止。
- O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。
- 当管道满时:
- O_NONBLOCK disable: write调用阻塞,直到有进程读走数据。
- O_NONBLOCK enable:调用返回-1,errno值为EAGAIN。
3.如果所有管道写端对应的文件描述符被关闭,则read返回0。
4.如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出。
5.当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。
6.当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。
2.2.5 匿名管道的特点
1、只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信
通常,一个管道由一个进程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。
2、内核会对管道操作进行同步与互斥
在一段时间内只允许一个进程访问的资源,又称独占资源。管道在同一时刻只允许一个进程对其进行写入或者读取操作,因此管道也属于临界资源。临界资源需要被保护,否则可能会出现同一个时刻有多个进程对同一个管道进行写入或读取的操作,导致同时读写,交叉读写等情况以至于最后读取的数据没能达到预期的结果。
为了避免这些问题,操作系统对管道操作进行同步与互斥:
- 同步:是指散步在不同任务之间的若干程序片断,它们的运行必须严格按照规定的某种先后次序来运行,这种先后次序依赖于要完成的特定的任务。最基本的场景就是:两个或两个以上的进程或线程在运行过程中协同步调,按预定的先后次序运行。比如 A 任务的运行依赖于 B 任务产生的数据。
- 互斥:是指散步在不同任务之间的若干程序片断,当某个任务运行其中一个程序片段时,其它任务就不能运行它们之中的任一程序片段,只能等到该任务运行完这个程序片段后才可以运行。最基本的场景就是:一个公共资源同一时刻只能被一个进程或线程使用,多个进程或线程不能同时使用公共资源。
同步是一种更为复杂的互斥,而互斥是一种特殊的同步。也就是说互斥是两个任务之间不可以同时运行,他们会相互排斥,必须等待一个线程运行完毕,另一个才能运行,而同步也是不能同时运行,但他是必须要按照某种次序来运行相应的线程(也是一种互斥)!因此互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序,即任务是无序的,而同步的任务之间则有顺序关系。
3、管道的生命周期随进程
管道本质上就是文件,它依赖于文件系统,即当所有打开管道的进程都退出后,该文件也会被释放掉,所以管道的生命周期随进程。
4、管道是半双工的,数据只能向一个方向流动
在数据通信中,数据在线路上的传送方式可以分为以下三种:
- 单工通信(Simplex Communication):单工模式的数据传输是单向的。通信双方中,一方固定为发送端,另一方固定为接收端。
- 半双工通信(Half Duplex):半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。
- 全双工通信(Full Duplex):全双工通信允许数据在两个方向上同时传输,它的能力相当于两个单工通信方式的结合。全双工可以同时(瞬时)进行信号的双向传输。
管道是半双工的,数据只能向一个方向传输,若需要双向通信,则需要创建两个管道。
5、管道提供流式服务
一个进程向管道中写入数据,另一个进程每次从管道读取的数据的多少是任意的,这种被称为流式服务,与之相对应的是数据报服务。
- 流式服务: 数据没有明确的分割,不分一定的报文段。
- 数据报服务: 数据有明确的分割,读数据按报文段读取。
2.3. 命名管道
2.3.1 命名管道的概述
匿名管道,由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了命名管道(FIFO),也叫有名管道、FIFO 文件。
命名管道(FIFO)不同于无名管道之处在于它提供了一个路径名与之关联,以 FIFO 的文件形式存在于文件系统中。这样,即使与 FIFO 的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过 FIFO 相互通信。因此,通过 FIFO 不相关的进程也能交换数据。
2.3.2 命名管道与匿名管道的区别
命名管道与匿名管道的特点部分相同,不同之处在于:
- 匿名管道由pipe函数创建并打开,而命名管道有mkfifo函数创建,使用open函数打开。
- 命名管道在文件系统中作为一个特殊的文件而存在,但命名管道中的内容却存放在内存中,向命名管道中写入的数据不会刷新到磁盘中。
- 当使用命名管道的进程退出后,命名管道文件将继续保存在文件系统中以便以后使用。
2.3.3 命名管道打开的规则
1、 如果当前打开操作是为读而打开FIFO时
- O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO
- O_NONBLOCK enable:立刻返回成功
2、如果当前打开操作是为写而打开FIFO时
- O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO
- O_NONBLOCK enable:立刻返回失败,错误码为ENXIO
2.3.4 命名管道的创建
使用mkfifo
命令创建匿名管道
mkfifo fifo
使用这个命名管道文件,就能实现两个进程之间的通信了。我们在一个进程中用shell脚本每秒向命名管道写入一个字符串,在另一个进程中用cat命令从命名管道当中进行读取。现象就是当第一个进程启动后,另一个进程会每秒从命名管道中读取一个字符串打印到显示器上。这就证明了这两个毫不相关的进程可以通过命名管道进行数据传输,即通信。
当管道的读端进程退出后,写端进程再向管道写入数据就没有意义了,此时写端进程会被操作系统杀掉,在这里就可以很好的得到验证:当我们终止掉读端进程后,因为写端执行的循环脚本是由命令行解释器bash执行的,所以此时bash就会被操作系统杀掉,我们的云服务器也就退出了。
使用mkfifo
函数创建管道
在程序中使用mkfifo
函数创建命名管道,函数原型如下:
#include <sys/types.h> #include <sys/stat.h> int mkfifo(const char *pathname, mode_t mode);
参数:
- pathname: 表示要创建的命名管道文件。
若pathname以路径的方式给出,则将命名管道文件创建在pathname路径下。若pathname以文件名的方式给出,则将命名管道文件默认创建在当前路径下。 - mode: 表示创建命名管道文件的默认权限。
返回值:
- 命名管道创建成功,返回0。
- 命名管道创建失败,返回-1。
例如,使用下面的代码创建命名管道:
#include <iostream> #include <cstdio> #include <sys/types.h> #include <sys/stat.h> #define FILE_NAME "myfifo" int main() { umask(0); //将权限掩码设置为0 if(mkfifo(FILE_NAME, 0666) < 0) { perror("mkfile"); return 1; } //创建命名管道成功... return 0; }
编译运行产生结果如下:
2.3.5 命名管道实现Server与Client进程间通信
实现服务端和客户端的通信,需要先启动服务端并让其创建一个命名管道文件,任何再以读的方式打开该命名管道,之后就能读取到来自客户端写入的消息。
服务端代码:
#include "comm.h" using namespace std; #define NUM 1024 int main() { //创建命名管道文件 umask(0); if (mkfifo(IPC_PATH, 0600) != 0) { cerr << "mkfifo error" << endl; return 1; } //打开命名管道文件 int pipeFd = open(IPC_PATH, O_RDONLY); if (pipeFd < 0) { cerr << "open error" << endl; return 2; } //向命名管道中读取数据 char buffer[NUM]; while (true) { ssize_t s = read(pipeFd, buffer, sizeof(buffer) - 1); if (s > 0) { buffer[s] = '\0'; cout << "客户端 -> 服务端# " << buffer << endl; } else if (s == 0) { cout << "客户端退出了,服务端也退出!" << endl; break; } else { //读取数据错误 cout << "read: " << strerror(errno) << endl; break; } } close(pipeFd); cout << "服务端退出" << endl; unlink(IPC_PATH); return 0; }
对于客户端来说,因为命名管道已经由服务端创建好了,所以不需要再次创建,客户端只需要以写的方式打开服务端创建的命名管道并写入数据即可,从而实现客户端与服务端的进程间通信。
客户端代码:
#include "comm.h" using namespace std; #define NUM 1024 int main() { //打开命名管道 int pipeFd = open(IPC_PATH, O_WRONLY); if(pipeFd < 0) { cerr << "open error" << endl; return 2; } //向命名管道中写入数据 char line[NUM]; while(true) { printf("请输入你的消息# "); fflush(stdout); memset(line, 0, sizeof(line)); //fgets得到C风格字符串,会在末尾自动添加 '\0' if(fgets(line, sizeof(line), stdin) != nullptr) { line[strlen(line) - 1] = '\0'; //处理回车 例如输入 abcd/n/0 write(pipeFd, line, strlen(line)); } else { break; } } close(pipeFd); cout << "客户端退出" << endl; return 0; }
为了让客户端和服务端使用同一个命名管道文件,这里让客户端和服务端都共同包含一个头文件,该头文件当中提供这个共用的命名管道文件的文件名,这样客户端和服务端就可以通过这个文件名,打开同一个命名管道文件,进而进行通信了。
【Linux学习】进程间通信的方式(匿名管道、命名管道、共享内存)2:https://developer.aliyun.com/article/1383938