目录
1.进程间通信(IPC)
(1)为什么要有进程间通信?
(2)通信的前提
(3)通信分类
2.匿名管道
(1)管道使用及现象
(2)匿名管道原理
①struct file和inode的权限区别
②pipe的创建
③引用计数在pipe上的应用
④数据通信
a.数据不一致
b.同步互斥保护机制
c.临界资源
(3)管道的特性
(4)原子性
(5)数据不一致、匿名管道缺陷
3.进程池
(1)进程池原理
(2)不必要写端继承问题
(3)过程实现
①主程序逻辑
②进程池成员变量
③初始化进程池
④子进程处理任务
⑤分发任务
⑥销毁进程池
(4)总结
1.进程间通信(IPC)
(1)为什么要有进程间通信?
我们都知道进程具有独立性,但我们在很多时候都面临数据传输、资源共享、通知事件、进程控制等需要多个进程协同工作的场景。
(2)通信的前提
要实现通信的前提,我们先得让不同的进程看到同一份资源。同一份资源指的是某种形式的内存空间;提供资源的只能是操作系统!也就是说我们需要让操作系统提供一份公共的内存空间,这份空间就是文件内核缓冲区。还记得struct file吗?我们在这里需要复习并补充一点关于struct file的知识:
①从磁盘读取的文件会以struct file的形式展示给OS,OS的所有对文件的操作都是对file进行操作,file中有两个很重要的成员:inode和文件内核缓冲区,每个被打开的文件都只有唯一一份inode和文件内核缓冲区。也就是说,如果父子打开同一个文件,父子进程的内核缓冲区和inode都指向同一个,这就是通信的核心——让两个进程看到同一块内存空间。
②父子进程的struct file都是独立的!为什么?当不同进程访问文件内核缓冲区,它们访问缓冲区的位置是独立的,比如A进程访问缓冲区第一个字节,B进程访问缓冲区最后一个字节,A进程和B进程都需要保存当前“指针”的位置,这个位置就是保存在struct file中的。所以不同进程之间要保证部分属性的独立,这也是为了保证①不会发生读写冲突,使其逻辑严密。只是在很多情况下,我们可以简单地说一个文件对应一个struct file,但一旦涉及读写位置独立,struct file的独立性就不得不考虑了。
③文件内核缓冲区是操作系统提供的,这就是说这个缓冲区不属于某个具体的进程。在这里我们需要先接收一个观点,即OS是所有进程共享的,所有进程都会看到同一个OS。这其实也是两个进程能看到同一块空间的另一层原因。如果是属于某个进程的空间,如父子进程自己new的空间、用户空间,其它进程自然无法看到,也就无法用作公共内存空间。在这里我们可以下一个结论:凡是用作IPC的公共空间,它一定属于OS而不是某个进程,凡是属于某一个具体进程的空间一定不能用作不同进程的公共空间,在摸棱两可的情况下,这是个很好的判断方法。
④OS中几乎所有数据结构都存在引用计数,所以我们无需担心内存泄漏的情况。
(3)通信分类
通信分为本地通信和网络通信。我们当前主要介绍本地通信。
本地通信:同一台主机,同一个OS下,在不同进程之间通信。主流的有三种通信标准:system V、管道、posix。标准是指一种规范、解决方案,标准一般都是由大厂带领建立的。
2.匿名管道
(1)管道使用及现象
管道是Unix(Linux是从Unix模仿的)古老的通信方式。我们已经会使用管道了,大致上可以认为是前者的执行结果能够作为后者的已知信息。
如who | wc -l,who查看登陆当前系统有几个人登录,wc -l计算行数
在后端执行sleep 20000 | sleep 20000 &,我们可以发现管道前后的文件PPID都是一致的,这说明它们有着共同的父进程
(2)匿名管道原理
我们利用文件内存缓冲区实现匿名管道,但这是一种纯内存的文件,不需要刷新到磁盘,我们只需要它的缓冲区,没必要和磁盘挂钩。这个纯内存文件很特殊,它需要我们在具体进程中用特殊的函数代码创建,但它并不属于某个进程,这也验证了我们前面的说法。
①struct file和inode的权限区别
struct file* fd_array是每个进程都有的,它是一个结构体指针数组,其下标就是fd,当我们打开文件时,就会创建file并修改fd_array。
注意struct file中会保存当前file的读写权限,inode里面也会保存权限,这两种权限是不一样的。file具有独立性,因此不同file指向同一文件可以只读、只写、读写(动态的权限),而inode里面保存的是拥有组、所属组和other的读写情况(静态的权限)。
②pipe的创建
我们使用int pipe(int pipefd[2])创建匿名管道。pipefd是输出型参数,接收读写端的fd。返回值表示是否创建成功,创建成功返回0,否则返回-1
注意这里4和6其实是创建了两个file,分别对应读权限和写权限(不是同时打开读写权限,而是用两个fd来打开文件,一个读一个写),但它们都指向同一块文件内核缓冲区。
当我们这个时候fork,就会将fd_array完整的交给子进程
这个时候我们就实现了父子进程看到同一块资源。之后我们需要对父子进程进行处理,让它们close不必要的读端和写端(如果不关闭不必要的读写端,这会导致fd泄露,也可能会误操作,就像指针一样,这个操作不必须,但这是良好的编程习惯),这就形成了一条父子进程间的管道,子写父读或者父写子读。
管道采用创建两个file的方法来实现读写的区分,这样保证每个fd对应的是纯粹的读或者写,close不必要fd后传输数据更加安全。同时我们也发现通信是单向的,这是因为管道就是为解决单向通信诞生的,这种性质也导致它叫管道。同时这个管道没有磁盘路径,没文件名,就是个特殊的内存文件,所以叫匿名管道。
易错点:不能先创建子进程再打开pipe文件,因为要利用的进程创建的继承特性来实现拷贝文件描述符,同时打开读写权限是为了方便父子进程选择通信方式,一边读一边写。
③引用计数在pipe上的应用
当我们调用pipe函数后,父进程就有两个不同权限的file被创建,它们都指向同一块文件内核缓冲区;当fork之后就有四个file,2读2写指向该内核缓冲区。pipe采用引用计数的方法来保证不会出现内存泄露的情况,即根据指向文件内核缓冲区的指针数量来判断是否释放文件。
对于所有文件而言,当指向文件内核缓冲区的指针数量为0之后,就会主动释放该文件唯一的一份inode、文件内核缓冲区。也就是说,当我们杀掉父子进程之后,文件会自动关闭,这体现出文件的生命周期随进程这一特点,管道文件也是如此,只要指向它的父子进程关闭,文件自动释放!也就是说,就算我们的进程因为意外闪退或是我们主动exit,只要保证进程被完全杀掉,文件就不会泄露。
除了以上特点,管道文件还会检测file的权限类型,默认情况下一个正常的匿名管道是1个读端配上1个写端,当写段正常 && 读端关闭时,OS会直接用信号13(SIGPIPE)杀掉写入的进程。
对于管道来说,没有读端的一瞬间就会触发OS的13信号的发送,因为当最后一个读端被关闭时,在OS看来无论写端怎么写,这都是没有意义的,写给谁呢?可能有的人会说可以先写,再打开pipe读,但这并不符合规定,我们要避免出现这种情况,如果要实现上面的功能,请至少保持一个读端的打开。
反之,如果写端关闭,只剩下读端,OS并不会发送信号,因为此时缓冲区还有数据。至于如何判断缓冲区有没有数据,我后面会介绍的。
④数据通信
接下来我们需要具体聊聊通信的细节。两个进程的读写都使用read、write系统调用 + fd进行访问。假设子写父读,子进程write了100B的数据,若父进程一次性read 1KB数据,将子进程写的数据全部读过去,对于pipe而言,只要父进程读了的数据都会被缓冲区清理,缓冲区里只会存储没有被父进程读的数据,也就是说缓冲区的数据是一次性的。
a.数据不一致
子进程写100B父进程读1KB,这是为了父进程能够一次性读完子进程的数据。并且子进程写完100B后需要sleep一段时间,因为父子进程的数据读写竞争,子进程虽然每次写100B,但有可能在某一时间子进程写了3次而父进程就读了1次,这就会导致本来3份数据,在父进程看来只是1份更大的数据,数据内容虽不会丢失,但它的份数由3 -> 1,也就是说父进程收到的数据和子进程原本想表达的数据发生了数据不一致问题。
这个问题需要重视,最好的解决办法是子进程每次写数据要间隔一段时间,给父进程留足时间读数据,并且保证父进程每次读数据的量足够大,一次能读完子进程发送的一份数据。
b.同步互斥保护机制
子进程每次写完要sleep一段时间,给父进程留足时间读数据。那么问题来了,父进程要sleep吗?不需要,当父进程在执行read函数时,如果子进程还没有发送数据(pipe文件缓冲区为空),父进程会被阻塞在这个函数里(类似wait),读到数据后就会跳出阻塞。这是pipe管道的特殊处理(管道的同步互斥保护机制),正常文件缓冲区为空会直接返回0!
当写端关闭只剩读端时,父进程依然可以读取数据,当数据读完后,再read就不会被阻塞,这个时候就会和普通文件一样返回0,这也是判断写端有没有关闭的好办法。
总结:处于正常通信时如果文件缓冲区为空,read会一直阻塞;如果写端都被关闭,此时如果文件缓冲区为空,read就会返回0。
c.临界资源
IPC是让不同的进程看到同一块资源(共享资源),但这会面临数据不一致问题,即一个还没写完另一个就来读了,还会出现read到空的缓冲区的问题,因此我们要保护共享资源,这部分资源就叫临界资源。
(3)管道的特性
①面向字节流。父子进程可以直接读写,不管对方进程读或者写了多少次,当然这会导致数据不一致问题
②匿名管道可以用于有血缘关系的进程,一般是父子进程
③管道自带同步互斥等保护机制,管道文件同样有大小,其内核缓冲区满了就不能写了,空了就不能读。
④单次写入数据小于PIPE_BUF(如4096字节)大小就叫做单次写入具有原子性。
(4)原子性
当我们第一次写数据时,缓冲区为空。那么这里就会面临一个问题,这个时候read正在阻塞,会不会出现write写着写着就被read读走造成数据不一致问题呢?
当单次写入大小 <= 一个原子的大小的话,此次写入具有原子性,即无法被打断,只有完全写入和完全不写入两种状态,在进行原子化写入时,OS的read会被阻塞,不会出现进行原子化写入时被打断的情况。比如当写入4095个字节时,就会写入一个原子,当这个原子写入后,read才会解除阻塞;当写入4097个字节时,就会写入两个原子,这个时候注意了,当写完一个原子后,第二个原子开始写之前第一个原子就会被读走,造成数据不一致问题。所以说我们单次write大小尽量不超过一个原子的大小。
这里强调一下,原子性写入贯穿OS所有的IO流程。OS读写数据看似最小单位是1B,实际上是以一个原子的大小为单位进行的,只不过它不会让我们看到多余IO的空间。它显示只拷贝了10B,实际上硬件侧拷贝的是4096B。就好比我需要给一个人1块钱,我从银行取一百,会往自己兜里放回99,那99是不会让别人知道的,我只会告诉别人我这里有1块。
(5)数据不一致、匿名管道缺陷
上面讲述了数据不一致的两种场景,这里需要总结一下。当进行写入时,应保证单次写入不超过一个原子,这样会使得在连续写多个原子期间前面的数据被read读走,我们尽量保证缓冲区的数据在一个原子以内。但是,如果子进程连续写了两次,而父进程一次性把两个原子都读走了,也会造成数据不一致问题,所以如果要严格保证数据一致,写端写完一次要sleep,给父进程时间读走数据,父进程读数据的时候子进程可以继续向缓冲区写,两者不冲突,子进程正在写的原子父进程读不走;如果要一次写入多个原子,就要保证每次写的时候父进程不会进来读,也就是说每次读完之后读端要sleep,给子进程一点时间去写数据。
总结:由于面向字节流的特性,管道文件缓冲区没有消息边界,有可能写两次读一次,因此写后要sleep;管道文件也可能一次写两个原子,结果写一个就被读走了,所以需要读完后sleep。管道很难控制绝对的数据一致,一是父子进程至少其一要sleep,但调用没办法很好契合;二是sleep效率折损太大。所以对于这种需要多次通信的情况需要选择有消息边界的通信方案,匿名管道适合单次通信。
3.进程池
(1)进程池原理
创建进程池本质上是一种任务派发。我们预先创建进程,每个进程在需要时各自执行任务。以匿名管道为例,父进程先创建pipe,再创建子进程,父进程保存pipe和子进程一一对应的关系,循环往复创建多组pipe和子进程。父进程写,子进程读,以此实现父进程给子进程派发任务的功能,进而实现多个子进程协同操作,加快任务处理。
只要掌握了匿名管道,将读写细节搞清楚的话,进程池的实现就会轻松许多。
(2)不必要写端继承问题
对于子进程来说,它们的读端都是固定的,因为fd的占用是从前到后,先从没有使用的下标开始,且pipe占用fd都是先读后写的顺序。例如,父进程创建pipe1时占用3为读端,4为写端,其子进程的3为读端,父进程关闭3;当创建pipe2时,父进程使用3为读端,5为写端,其子进程的3为读端,以此类推。
在创建匿名管道时我们忽略了一个细节,那就是父子进程的fd数组在关闭不必要接口后出现了不必要写端继承的问题。
下面是pipe1 -> pipe2父子进程读写端的情况。
简单来说就是每一次创建pipe,对于子进程来说都默认只会处理当前pipe的写端,但对于第二个pipe来说,它还会继承父进程第一个pipe的写端,也就是说pipe2、pipe3...都会含有pipe1的写端,pipe1写端引用计数为n,pipe3、pipe4...含有pipe2的写端(引用计数为n - 1),以此类推,仅有最后一个pipe n只有1个写端。这增加了不安全性,pipe1的读写端本就应该只由父子进程决定,却现在让pipe2、pipe3...的子进程看到了。因此,每一次创建pipe,应该让子进程先把父进程存下来的写端全部关一遍,这样每个子进程才不会得到前面pipe遗留的写端。
如果不这样做,让我们清理进程池时,使用关闭写端,让子进程read得到0进而退出进程的方法就失效了,因为对于pipe1而言,其写端有n个,关一个依然会触发同步互斥保护机制(写端引用计数不为0),让子进程read阻塞。面对这种问题,我们的解决方法最好是向上面说的那样每次清理子进程继承的多余的写端。除此之外,还有一种思路,即从pipe n对应的进程开始逆序关闭,pipe n对应子进程关闭了,其上所有pipe的写端引用计数都会 - 1,以此类推。
(3)过程实现
下面按照代码逻辑大致梳理一遍匿名管道实现进程池的过程,以加深印象
①主程序逻辑
抓住主要任务:创建进程池、处理任务、销毁进程池
②进程池成员变量
一个进程池要保存的是任务数量和子进程与其对应的pipe写端的信息,后者采用Channel类进行封装。
Channel类,就是子进程pid和父进程写端对应fd的值
③初始化进程池
父进程采用循环方式,在每一次循环中创建pipe及其子进程,当子进程得到pipe之后关闭父子进程不必要的读写端,父进程用Channel对象记录子进程的pid及其写端。
对于子进程而言,当它的准备工作做好之后就应该进入read函数,阻塞着等待父进程发放命令。
下面是初始化代码,先大致看一下,验证上面的流程,后面会详细讲解。
enum ProcessPool_state
{ExecuteComplete,ExecuteError,PipeError,NumError,
};class ProcessPool
{
public:ProcessPool(int count, int num_of_tasks): _num_of_tasks(num_of_tasks){if (count <= 0){cout << "创建的子进程数量必须 >= 1" << endl;exit(NumError); // 直接退出,exit不会调用析构函数}for (int i = 0; i < count; i++) // 创建count个子进程{int pipefd[2] = {0, 0};// 创建匿名管道int ret_creat_pipe = pipe(pipefd);if (ret_creat_pipe == 0)printf("第%d个匿名管道创建成功,读端fd为%d,写端fd为%d\n", i + 1, pipefd[0], pipefd[1]);else{printf("第%d匿名管道创建失败\n", i + 1);exit(PipeError); // 直接退出程序,先前创建的匿名管道会因为引用计数为0自动销毁}// 创建进程使用匿名管道pid_t ret_fork = fork();if (ret_fork == 0) // 子进程{// 父进程写,子进程读,父进程给子进程派发任务::close(pipefd[1]); // 子进程关闭写端dup2(pipefd[0], 0); // 将读端覆盖标准输入流,后续只需要从0读数据就能知道父进程的安排::close(pipefd[0]); // 相当于将fd移位到0for (auto &e : _pipe_array){::close(e.Getwritefd()); // 将父进程遗留下来的写端都关闭,子进程只会调用读端}printf("第%d个子进程准备就绪,开始等待处理任务\n", i + 1);ProcessTask();}// 只有父进程能走到这里::close(pipefd[0]); // 父进程关闭读端// 父进程要向多个pipe写文件,而子进程只从一个pipe读文件,所以父进程不能直接dup2// 将子进程的pid和父进程写端的fd记录下来,方便后续管理子进程以及分配任务_pipe_array.emplace_back(ret_fork, pipefd[1]);printf("第%d个子进程和及其匿名管道数据已被父进程记录\n", i + 1);}}
④子进程处理任务
其中ProcessTask()函数就是让子进程进入处理任务的状态,时刻read等待父进程下达命令。
这里使用了死循环使得一个子进程时刻接收任务、处理任务。如果写端关闭,子进程读完数据后就不再触发同步互斥保护机制,返回0,我们利用这种特性来关闭读端,进而退出进程。
⑤分发任务
初始化完成后就是要分发任务了,我们采用轮询的方式让每个进程都公平的得到任务,获取任务编号写到对应的管道里,子进程的read就会读到数据并开始处理,当处理完之后又能得到新的任务并继续处理。注意这里我没有使用sleep来强制保证数据一致,这也是我们代码的弱点之一。
其中我们将任务类单独封装了,用于任务的获取和调用。
using task_t = function<void()>;void taskA()
{cout << "正在执行任务A" << endl;
}
void taskB()
{cout << "正在执行任务B" << endl;
}
void taskC()
{cout << "正在执行任务C" << endl;
}
void taskD()
{cout << "正在执行任务D" << endl;
}class Task
{
public:int size(){return (int)_task_array.size();}Task()//加入四个任务{_task_array.reserve(sizeof(task_t) * 4);srand((unsigned int)time(nullptr));_task_array.push_back(taskA);_task_array.push_back(taskB);_task_array.push_back(taskC);_task_array.push_back(taskD);}int SelectTask(){return rand() % _task_array.size();}void ExecuteTask(int num){if(num < 0 || num >= 4){printf("当前任务码为%d,异常!\n", num);exit(1);}printf("开始处理任务,任务码为%d\n", num);fflush(stdout);_task_array[num]();//执行对应任务}private:vector<task_t> _task_array;}task;
⑥销毁进程池
分完任务并处理完之后就需要销毁进程池了。由于在初始化进程池时我们就解决了不必要写端继承的问题,关闭写端后,子进程就会依序退出,我们的父进程阻塞式等待子进程即可。
(4)总结
通过进程池的代码,我们能够进一步深入了解进程间通信的原理,其中最难理解的就是数据不一致问题,匿名管道的面向字节流就决定了它不适合多次数据传输,不过我们依然使用它走完了进程池的流程,了解即可。在整个过程中,读写端都将对方当作了文件,都是对pipe文件进行IO,这再次体现出Linux一切皆文件的思想,值得我们体会。