
文章目录前言一、更改后的代码二、代码回顾三、原因分析文件描述符的继承四、解决方案1.倒着关闭2. 子进程关闭所有写端3. 先全关再全等方法对比前言在上一期《Linux 进程池实战匿名管道、轮询调度与隐藏的阻塞陷阱》中我们实现了一个基于匿名管道的进程池并故意留下了一个“隐藏的堵塞问题”。现在是时候解释一下了。这一期我会对代码做出略微修改以此来暴露这个问题。一、更改后的代码因为仅更改了ProcessPool.hpp里面的STOP()、StopSubprocess()和WaitSubprocess()函数故此篇只贴出ProcessPool.hpp的代码。注释掉的代码为上一期的代码#ifndef__PROCESS_POOL_HPP__#define__PROCESS_POOL_HPP__#includeTask.hpp#includeunistd.h#includestdlib.h#includeerrno.h#includestring.h#includesys/types.h#includesys/wait.husingnamespacestd;classChannel{public:Channel(intfd,pid_t id):_wfd(fd),_subid(id){// 切记要to_string转换一下格式_namechannel- to_string(_wfd)-to_string(_subid);}intFd(){return_wfd;}pid_tSubid(){return_subid;}stringName(){return_name;}voidSend(intcode){// 发送本质上就是把任务编码通过写端写到管道文件write(_wfd,code,sizeof(code));}voidClose(){// 关闭写端close(_wfd);cout关闭_nameendl;}voidWait(){// 等待子进程退出waitpid(_subid,nullptr,0);cout等待子进程[_name]回收endl;}private:// 每个信道里都包含文件描述符 _wfd// 子进程id _subid// 信道名字 _nameint_wfd;pid_t _subid;string _name;};classChannelManager{public:ChannelManager():_next(0){}ChannelSelect(){// 轮询选择信道并返回autoc_channels[_next];_next;_next%_channels.size();returnc;}// void StopSubprocess()// {// // 通过挨个关闭信道的方式关闭这次通信// for (auto channel : _channels)// {// channel.Close();// }// }voidInsert(intfd,pid_t id){// 向_channels里插入Channel_channels.emplace_back(fd,id);}// void WaitSubprocess()// {// // 挨个等待每个子进程// for (auto channel : _channels)// {// channel.Wait();// }// }voidStopAndWaitSubprocess(){for(autochannel:_channels){channel.Close();channel.Wait();}}private:vectorChannel_channels;//_next用于轮询选择_channels里的Channelint_next;};constintgg10;classProcessPool{public:ProcessPool(intnum):_process_num(num){// 注册任务_tm.Register(Printlog);_tm.Register(Download);_tm.Register(Upload);}voidRun(){// 该函数的作用就是选择一个信道生成任务码并将任务码发送至管道里inttaskcode_tm.code();autoc_cm.Select();cout选择一个信道c.Name()endl;c.Send(taskcode);cout发送了一个任务码taskcodeendl;}boolStart(){for(inti0;i_process_num;i){// 创建匿名管道intpipefd[2]{0};if(pipe(pipefd)0){perror(pipe);returnfalse;}// 创建子进程intidfork();if(id0){perror(fork);returnfalse;}// 子进程只读不写elseif(id0){close(pipefd[1]);Work(pipefd[0]);close(pipefd[0]);exit(0);}// 父进程只写不读else{close(pipefd[0]);_cm.Insert(pipefd[1],id);}}returntrue;}// void Stop()// {// //关闭所有父进程的写端即可子进程读端读取不到内容自己就结束了// _cm.StopSubprocess();// // 回收所有子进程// _cm.WaitSubprocess();// }voidStop(){_cm.StopAndWaitSubprocess();}private:// Work的主要工作就是在管道里读取任务码并执行相应任务voidWork(intrfd){while(true){intcode;intnumread(rfd,code,sizeof(code));if(num0){perror(read);break;}elseif(num0){if(num!sizeof(code)){continue;}cout子进程[getpid()]收到了一个任务码codeendl;_tm.Execute(code);}else{cout子进程结束endl;break;}}}private://_process_num是要创建的信道数量ChannelManager _cm;TaskManager _tm;int_process_num;};#endif二、代码回顾这次把 StopSubprocess 和WaitSubprocess 合并成了 StopAndWaitSubprocessvoidChannelManager::StopAndWaitSubprocess(){for(autochannel:_channels){channel.Close();// 关闭父进程的写端channel.Wait();// 等待对应的子进程退出}}运行程序会发现程序会在Stop()卡住终端输出类似于关闭channel-4-973765三、原因分析文件描述符的继承当父进程调用fork()创建子进程时子进程会获得父进程文件描述符表的完整拷贝。我们知道父进程持有管道的写端父进程在创建新子进程时仍然持有之前所有管道的写端。fork() 会把这些写端全部复制给新子进程导致新的子进程就会持有前面所有管道的写端。子进程只在Work()之前关闭了当前管道的写端而忽略了从父进程继承来的其他管道写端。导致在关闭子进程的时候因为当前管道的写端被后来生成的子进程所持有所以当前管道的写端清除不干净子进程的read()返回值不为0子进程就无法退出在read()处阻塞父进程在Wait()处一直等待最终卡死四、解决方案1.倒着关闭最后一个管道的写端只有父进程持有该管道后面不会再出现新的管道不会再出现有新的子进程持有该管道写端的情况。所以只需要关掉父进程持有的最后一个管道的写端即可。read返回值为0子进程不再在read阻塞父进程也不会一直等待。以此类推所以子进程就会完全关闭代码演示voidStopAndWaitSubprocess(){// 方法一for(inti_channels.size()-1;i0;i--){_channels[i].Close();_channels[i].Wait();}}2. 子进程关闭所有写端// 方法二voidCloseAll(){for(autochannel:_channels){channel.Close();}}boolStart(){for(inti0;i_process_num;i){// 创建匿名管道intpipefd[2]{0};if(pipe(pipefd)0){perror(pipe);returnfalse;}// 创建子进程intidfork();if(id0){perror(fork);returnfalse;}// 子进程只读不写elseif(id0){// 方法二每次生成新的子进程后关闭所有写端_cm.CloseAll();close(pipefd[1]);Work(pipefd[0]);close(pipefd[0]);exit(0);}// 父进程只写不读else{close(pipefd[0]);_cm.Insert(pipefd[1],id);}}returntrue;}在子进程 fork 成功后除了关闭当前管道的写端还要关闭它从父进程继承来的所有旧管道写端。这样之后再关闭信道的时候只需要关闭父进程持有的管道写端就可以使read返回0从而使得子进程自动退出父进程成功等到子进程信道关闭成功。3. 先全关再全等voidStop(){// 方法三最后关闭所有写端再结束、等待子进程_cm.CloseAll();_cm.StopAndWaitSubprocess();}先关闭父进程持有的全部写端跟方法一类似此时最后一个管道的写端已经全部关闭故最后一个信道的read()返回值为0子进程退出父进程成功等待最后一个信道删除1。相同的逻辑依次往前遍历即可关闭所有信道。方法对比方案做法是否工作说明正序关一个等一个Close(i); Wait(i)❌ 卡死旧写端没清干净倒序关一个等一个for in-1…0: Close(i); Wait(i)✅每次关的都是最后一个持有者先全关再全等CloseAll(); WaitAll()✅依赖级联退出Wait 会阻塞较久子进程主动关旧写端 先全关再全等CloseAll(); WaitAll()✅最优 子进程独立退出顺序无关代码里并没有真正“删除”信道。_channels 里的 Channel 对象一直存在。故可以把信道删除理解为信道对应的子进程退出该信道生命周期结束↩︎