Linux 进程间通信
1. 为什么需要通信?
进程之间需要协同工作完成某个任务,好比在学校中,校长想要拿到同学的基本信息,叫每个院的管理人去统计,每个院的管理人叫班主任去统计,班主任叫班长统计,班长叫每个人填好表,再依次上交
这个过程中,需要很多人参与完成一件事,一个人叫另一个人完成某件事的前提是:他得先告诉别人,也就是说,协同的前提条件是通信
通信就意味着数据的传递,而数据是有类别的,通知就绪的、单纯传递数据的、控制信息的…
2. 如何通信?
进程具有独立性,A,B两个进程,彼此之间一定不能访问对方的数据,但它们之间又需要通信,该怎么办呢?这种时候,就必须有第三者出现,也就是 OS,必须由 OS 给两个进程开辟一个共享的资源,同时它还要提供访问这片共享资源的系统调用,而共享资源的不同,提供的系统调用就不同,说明进程通信是有很多种类的
因此,进程通信的前提是:让不同的进程看到同一块"资源"(操作系统开辟的内存)
- 某个进程需要通信,让 OS 开辟一块内存
- OS 要提供系统调用,两个进程通过系统调用进行通信
3. 通信的方式
3.1 管道
管道的设计并没有采用新的技术,而是复用 Linux 内核的代码,完成进程间通信
它的通信是单向的,只能一端读,另一端写,因为它的这种特性,于是称它为管道
匿名管道
根据我们之前的知识,一个文件被打开,OS 就会创建一系列的内核数据结构
进程以 w 和 r 的方式分别打开同一个文件,在 OS 内部,该进程一定会有两个 struct file,因为打开文件的方式不一样,结构体内容也就不同,但是,文件的内容是相同的,因此,两个 struct file 都指向同一块内核缓冲区
然后,父进程创建子进程,子进程会继承父进程的数据,包括文件描述符,相当于将父进程的内核结构体拷贝了一份给子进程,但 struct file 等一系列文件相关的数据需要拷贝一份吗?答案是不需要,因为这些东西是文件的,跟进程没有关系,因此,子进程的文件描述符表与父进程指向相同的 struct file
此时进程间通信的前提就具备了,父子进程看到了同一份内核缓冲区,也就是同一份资源
这次,写入到内核缓冲区的数据未来不是刷新到磁盘,而是交给另一个进程,因此,系统调用就不要加上将内核缓冲区的数据刷新到磁盘这一步了,我们把上述的一系列结构叫做匿名管道
之后,我们规定父进程读,子进程写,或者父进程写,子进程读,然后将各自不需要使用的 fd 关闭,就能基于管道完成单向通信了
理解了上述内容,我们也能解释这样的现象:
-
Linux 命令行中,我们启动的进程默认打开了0,1,2文件描述符,是谁打开的?
bash 默认打开了0,1,2,所有的子进程继承了 bash 的文件描述符,也就默认打开了
-
为什么父子进程会向同一个显示器打印数据?
子进程继承父进程的文件描述符表
-
为什么子进程 close(0/1/2) 不影响父进程向显示器打印数据
子进程关闭,父进程没有关闭对应的 fd
将匿名管理的原理进行简化:
-
父子进程既然要关闭不需要的fd,为什么要打开不需要的fd?
这是为了让子进程继承与父进程不同功能的 fd,如果父进程只打开w/r,子进程就只能继承w/r,也就不能完成一个进程写,另一个进程读的要求了
-
打开后能不关闭不需要的fd吗?
答案是可以的,但是建议关闭,因为如果你是读端,但仍能对管道写,有可能会误写
-
为什么管道只能单向通信?不能双向吗?
如果想要双向通信,可以使用两个管道,管道在设计时,就尽量避免复杂的情况,追求简单的模式,如果实现成双向,管道的内部必然要区分数据分别是谁写的等复杂情况,因此,就只让它进行单向通信,这也就是它为什么叫管道
-
子进程能继承父进程的数据,这算是通信吗?
这是不算的,虽然子进程能看到父进程的数据,但如果要写时,会发生写时拷贝,双方是看不到彼此写入的数据的
了解了匿名管道的原理,接下来介绍匿名管道的使用
#include <unistd.h>int pipe(int pipefd[2]);
#include <cstdlib>
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>void ChildWrite(int fd)
{while(true){static int cnt = 0;std::string msg = "Hello from child process-" + std::to_string(++ cnt);write(fd, msg.c_str(), msg.size());sleep(1);}
}void ParentRead(int fd)
{while(true){char buf[1024] = { 0 };ssize_t n = read(fd, buf, sizeof(buf) - 1);if(n > 0){buf[n] = '\0';std::cout << "Parent read: " << buf << std::endl;}}
}int main()
{int pipefd[2];int n = pipe(pipefd);if(n < 0){std::cerr << "pipe error" << std::endl;return -1;}std::cout << "pipefd[0] = " << pipefd[0] << std::endl;std::cout << "pipefd[1] = " << pipefd[1] << std::endl;pid_t id = fork();if(id == 0){// childclose(pipefd[0]); // 子进程关闭读端ChildWrite(pipefd[1]);close(pipefd[1]);exit(1);}// parentclose(pipefd[1]); // 父进程关闭写端ParentRead(pipefd[0]);close(pipefd[0]);pid_t wid = waitpid(id, nullptr, 0);if(wid > 0){std::cout << "child process " << id << " exit" << std::endl;}return 0;
}
管道的 4 种情况:
- 如果管道为空,写端一直不写且没有关闭,则读端进入阻塞状态(等待读取条件具备)
- 如果管道满了,读端一直不读且没有关闭,则写端进入阻塞状态(等待写入条件具备)
- 读端在读时,写端关闭了,此时读端 read 会读到 0,表示读到文件末尾
- 写端在写时,读端关闭了,写端会被 OS 使用 13 号信号 SIGPIPE 杀掉,相当于进程出现了异常(OS不会浪费时间和空间做没有意义的事)
匿名管道的 5 种特征:
- 匿名管道只适用于具有血缘关系的进程之间通信,常用于父子进程
- 管道内部,自带进程之间同步(多执行流执行代码时,具有明显的顺序性)的机制,管道内部有数据,读端会读取数据,管道内部没有数据,读端会阻塞等待
- 管道文件的生命周期是随进程的(当进程被释放,它指向所有的文件也会被释放)
- 管道文件在通信时,是面向字节流的,读的次数和写的次数不是一一对应的
- 管道的通信模式,是一种特殊的半双工模式
可以使用匿名管道实现进程池
代码:processpool
命名管道
匿名管道只适用于具有血缘关系的进程之间进行通信,那如果两个毫不相干的进程也想通信呢?
通信的前提是看到同一份资源,如果两个进程打开同一个文件,由于不同进程对同一份文件可能进行不同的操作,因此它们都要有一份 struct file 结构,但文件的内容肯定一样,因此共享同一份内核缓冲区
此时,两个进程就看到了同一份资源,只不过未来刷新到内核缓冲区里的数据不需要刷新到磁盘,我们把这个特殊的文件叫做管道文件
如何保证两个进程看到了同一份资源,也就是打开了同一个文件?文件路径能标识文件的唯一性
代码:NamedPipe
3.2 System V IPC
管道技术复用 Linux 内核代码,也有基于 System V 标准专门为进程间通信单独设计出来的方案,只不过它们只适用于本地通信
共享内存
当进程 A 需要和进程 B 通信时,让 OS 创建共享内存,挂接到自己的地址空间,进程 B 也要找到相同的共享内存,但进程 A 和进程 B 是两个毫不相干的进程,进程 B 如何得知进程 A 创建的共享内存呢?共享内存一定要有唯一性标识,如果让系统自动生成唯一性标识,进程 B 也就无法得知,因此共享内存的唯一性标识需要用户自己设置,是两个进程都可知的
#include <sys/ipc.h>
#include <sys/shm.h>int shmget(key_t key, size_t size, int shmflg);
/*
key: 用户设置的共享内存唯一性标识,使用ftok()函数生成
size: 共享内存的大小,OS会根据4096的倍数申请
shmflg:
1. IPC_CREAT: 共享内存不存在则创建,存在则获取
2. IPC_EXCL: 共享内存不存在则创建,存在则报错,一定会获取一个新的共享内存,配合IPC_CREAT使用
shmget()函数调用成功则返回shmid
*/int shmctl(int shmid, int cmd, struct shmid_ds *buf);
#include <sys/types.h>
#include <sys/ipc.h>key_t ftok(const char *pathname, int proj_id)
#include <sys/types.h>
#include <sys/shm.h>void *shmat(int shmid, const void *shmaddr, int shmflg);
int shmdt(const void *shmaddr);
key 和 shmid:
- key:用户生成,内核使用,内核用来区分共享内存的唯一性的,用户不能通过 key 管理共享内存
- shmid:内核返回给用户,用户通过 shmid 管理共享内存
共享内存的缺点:
- 共享内存不提供保护机制,意味着可能会导致读写数据不一致的问题,可以利用管道解决
共享内存的优点:
- 共享内存是所有 IPC 方案中效率最高的,因为减少了数据的拷贝次数
代码:Shm