目錄
1、進程間通信基礎概念
2、管道的工作原理
?2.1 什么是管道文件
3、匿名管道的創建與使用
3.1、pipe 系統調用
3.2??父進程調用 fork() 創建子進程
3.3. 父子進程的文件描述符共享
3.4. 關閉不必要的文件描述符
3.5?父子進程通過管道進行通信
父子進程通信的具體例子
?4.管道的四種場景?
4.1 場景一:父進程不寫,子進程嘗試讀取
4.2 場景二:父進程不斷寫入,直到管道寫滿,子進程不讀取
4.3 場景三:關閉寫端,子進程讀取數據
4.4 場景四:關閉讀端,父進程寫入數據
5、匿名管道實操-進程控制
5.1 邏輯設計
🌇前言
在操作系統中,進程間通信(Interprocess Communication,簡稱IPC)是兩個不同進程之間進行協同工作和信息交換的基礎。IPC 允許不同的進程相互協調,協作完成任務。進程間通信的方式有很多種,而管道則是一種非常經典且常用的方式。本文將詳細探討 匿名管道,它在進程間通信中扮演著重要的角色。
🏙?正文
1、進程間通信基礎概念
在深入探討匿名管道之前,我們先來了解一些基本概念。進程間通信的目的是為了使多個獨立的進程能夠協同工作,進行信息交換。主要有四個目的:
-
數據傳輸:不同進程之間需要傳輸數據。例如,將數據從客戶端傳送到服務器。
-
資源共享:多個進程共享系統資源,保證高效使用。
-
事件通知:某個進程需要通知其他進程某個事件的發生,例如進程的終止。
-
進程控制:用于進程管理,協調進程的執行和資源的分配。
這些目的的核心是打破進程的獨立性,讓它們能夠共享資源和信息,協同完成任務。?
2、管道的工作原理
管道是一種用于進程間通信的方式,它本質上是一個文件。無論是匿名管道還是命名管道,它們的原理都是通過文件描述符來共享數據。每個管道都有兩個端口:一個是寫端,另一個是讀端。
管道最初是由 Unix 系統引入的,它允許具有“血緣關系”的進程(如父子進程)通過管道進行通信。管道的實現通常會涉及到內核為進程分配文件描述符。父進程在創建管道后,會為其子進程繼承文件描述符,并通過關閉不需要的端口,確保通信的流向。
?2.1 什么是管道文件
管道文件是操作系統中用于實現進程間通信的特殊文件,具有以下幾個顯著特點:
-
單向通信:管道是 單向 的通信方式,意味著數據只能從一個端流向另一個端。通常情況下,一個進程寫數據到管道,而另一個進程從管道中讀取數據。這種方式被稱為“半雙工通信”,如果需要實現雙向通信,需要兩個管道。
-
基于文件的設計,管道本質上是內存中的文件; 管道文件并不是磁盤級別的文件,而是內存級別文件,管道文件沒有自己的inode,也沒有名字。過內存中的緩沖區進行存儲,操作系統會將管道作為文件來處理。
-
管道分為 匿名管道 和 命名管道。匿名管道沒有名字,是由操作系統在內存中創建的,僅限于有血緣關系(如父子進程或兄弟進程)的進程間通信。由于沒有名字,匿名管道無法在進程間直接共享。
與此不同,命名管道(FIFO)則擁有一個系統中的路徑名,因此它可以被不具備血緣關系的進程之間共享。這使得命名管道的通信更加靈活。
-
生命周期與進程綁定,管道的生命周期與創建它的進程生命周期緊密相關。當進程結束時,管道也會被操作系統回收。管道文件的生命周期由打開它的進程的生命周期決定,在進程終止時,管道的資源會被釋放。
-
內存緩沖區,管道的一個重要特點是,它在內存中創建一個緩沖區,用于存儲待傳輸的數據。由于是內存中的緩沖區,管道中的數據并不會被持久化到磁盤中。這使得管道比磁盤文件更為高效,但數據在管道中的存儲是臨時的,不會在系統重啟后保留
-
阻塞行為與同步機制,
管道的通信遵循 阻塞 和 同步 機制。當讀端嘗試讀取數據時,如果管道為空,進程會阻塞,直到寫端寫入數據。同樣,寫端如果嘗試寫入數據時,如果管道已滿,進程也會阻塞,直到讀端讀取部分數據。
這種阻塞行為本身提供了一定的同步機制。管道會保證寫入數據的順序,并且數據在被讀取之前不會丟失。這使得進程間的通信是同步的,確保數據完整傳輸。
-
管道大小限制,管道的大小在不同的操作系統和系統配置中可能有所不同。通常,管道大小會受到系統配置的限制。在 Linux 中,管道大小的默認值通常為 64KB(從 Linux 2.6.11 版本開始),不過在不同的系統或不同的內核版本中,管道的大小也可能有所變化
- 在管道中,寫入 與 讀取 的次數并不是嚴格匹配的,此時讀寫次數沒有強相關關系,管道是面向字節流讀寫的面向字節流讀寫又稱為 流式服務:數據沒有明確的分割,不分一定的報文段;與之相對應的是 數據報服務:數據有明確的分割,拿數據按報文段拿不論寫端寫入了多少數據,只要寫端停止寫入,讀端都可以將數據讀取。
- 具有一定的協同能力,讓 讀端 和 寫端 能夠按照一定的步驟進行通信(自帶同步機制)當讀端進行從管道中讀取數據時,如果沒有數據,則會阻塞,等待寫端寫入數據;如果讀端正在讀取,那么寫端將會阻塞等待讀端,因此 管道自帶 同步與互斥 機制。
3、匿名管道的創建與使用
具體流程:
父進程創建匿名管道,同時以讀、寫的方式打開匿名管道,此時會分配兩個 fd
fork 創建子進程,子進程擁有自己的進程系統信息,同時會繼承原父進程中的文件系統信息,此時子進程和父進程可以看到同一份資源:匿名管道 pipe
因為子進程繼承了原有關系,因此此時父子進程對于 pipe 都有讀寫權限,需要確定數據流向,關閉不必要的 fd,比如父進程寫、子進程讀,或者父進程讀、子進程寫都可以。
3.1、pipe
系統調用
匿名管道的創建通過 pipe()
系統調用來實現。該函數會創建一個管道,并返回兩個文件描述符:一個用于讀,另一個用于寫。函數原型如下:
#include <unistd.h>int pipe(int pipefd[2]);
?傳入一個大小為2的整型數組作為輸出型參數,操作系統就會生成一個管道文件,并且讓進程以讀寫的方式分別打開進程,并且將進程的讀管道文件的文件標識符寫道pipe[1],寫管到文件描述符寫道pipe[1]之中。
int pipefd[2];
pipe(pipefd); // 創建管道
3.2??父進程調用 fork()
創建子進程
當父進程調用 fork()
時,操作系統會創建一個新的子進程。子進程會繼承父進程的文件描述符表,因此,父子進程可以共享父進程所創建的管道文件描述符。也就是說,父進程和子進程都會擁有相同的管道讀端和寫端。
pid_t pid = fork();
3.3. 父子進程的文件描述符共享
父進程和子進程共享管道的讀寫端口意味著:
-
父進程 和 子進程 都可以操作
pipefd[0]
和pipefd[1]
,但它們之間的角色(讀或寫)通常是根據進程的需求來確定的。 -
父進程和子進程在創建時各自擁有自己的 進程資源,但文件描述符表會被子進程繼承,指向相同的管道內存資源。
3.4. 關閉不必要的文件描述符
由于管道是單向通信的,所以為了避免數據混亂,父進程和子進程通常會關閉不必要的文件描述符。例如,如果父進程要寫數據到管道而子進程讀取數據,父進程應該關閉管道的讀端,子進程應該關閉管道的寫端。
// 父進程關閉管道的讀端,子進程關閉管道的寫端
close(pipefd[0]); // 父進程關閉讀端
close(pipefd[1]); // 子進程關閉寫端
3.5?父子進程通過管道進行通信
-
父進程寫數據:父進程通過管道的寫端
pipefd[1]
向管道中寫入數據。write(pipefd[1], "Hello from parent", 17);
子進程讀數據:子進程通過管道的讀端
pipefd[0]
從管道中讀取數據。char buf[128]; read(pipefd[0], buf, sizeof(buf));
-
父進程寫入的數據會通過管道傳遞給子進程。
-
子進程從管道中讀取數據,通常會按順序接收父進程寫入的數據。
父子進程通信的具體例子
下面是一個完整的示例代碼,展示了父子進程如何通過管道進行通信:
#include <iostream>
#include <cassert>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>using namespace std;int main()
{// 1、創建匿名管道int pipefd[2]; // 數組int ret = pipe(pipefd);assert(ret == 0);(void)ret; // 防止 release 模式中報警告// 2、創建子進程pid_t id = fork();if (id == 0){// 子進程內close(pipefd[1]); // 3、子進程關閉寫端// 4、開始通信char buff[64]; // 緩沖區while (true){int n = read(pipefd[0], buff, sizeof(buff) - 1); //注意預留一個位置存儲 '\0'buff[n] = '\0';if (n >= 5 && n < 64){// 讀取到了信息cout << "子進程成功讀取到信息:" << buff << endl;}else{// 未讀取到信息if (n == 0)cout << "子進程沒有讀取到信息,通信結束!" << endl;// 讀取異常(消息過短)elsecout << "子進程讀取數據量為:" << n << " 消息過短,通信結束!" << endl;break;}}close(pipefd[0]); // 關閉剩下的讀端exit(0); // 子進程退出}// 父進程內close(pipefd[0]); // 3、父進程關閉讀端char buff[64];// 4、開始通信srand((size_t)time(NULL)); // 隨機數種子while (true){int n = rand() % 26;for (int i = 0; i < n; i++)buff[i] = (rand() % 26) + 'A'; // 形成隨機消息buff[n] = '\0'; // 結束標志cout << "=============================" << endl;cout << "父進程想對子進程說: " << buff << endl;write(pipefd[1], buff, strlen(buff)); // 寫入數據if (n < 5)break; // 消息過短時,不寫入sleep(1);}close(pipefd[1]); // 關閉剩下的寫端// 父進程等待子進程結束int status = 0;waitpid(id, &status, 0);// 通過 status 判斷子進程運行情況if ((status & 0x7F)){printf("子進程異常退出,core dump: %d 退出信號:%d\n", (status >> 7) & 1, (status & 0x7F));}else{printf("子進程正常退出,退出碼:%d\n", (status >> 8) & 0xFF);}return 0;
}
?
站在?文件描述符?的角度理解上述代碼:
?
?所以,看待?管道
?,就如同看待?文件
?一樣!管道
?的使用和?文件
?一致,迎合?Linux一切皆文件思想。
?4.管道的四種場景?
4.1 場景一:父進程不寫,子進程嘗試讀取
情況描述:
-
父進程沒有寫入數據到管道。
-
子進程嘗試從管道中讀取數據。
結果:
-
由于管道為空,子進程在嘗試讀取時會進入阻塞狀態。
-
只有當父進程開始向管道中寫入數據后,子進程才會成功讀取數據。
形象化理解:
-
這就像一個垃圾桶,子進程是倒垃圾的工作人員,而父進程是往垃圾桶里扔垃圾。如果垃圾桶為空,子進程(倒垃圾的人)就無法工作,必須等待父進程(扔垃圾的人)開始丟垃圾,才能開始工作。
4.2 場景二:父進程不斷寫入,直到管道寫滿,子進程不讀取
情況描述:
-
父進程持續向管道寫入數據,直到管道被寫滿。
-
子進程不進行讀取操作。
結果:
-
當管道的緩沖區滿了,父進程會被阻塞,無法繼續寫入數據,直到子進程讀取數據。
-
這是因為管道有大小限制,管道滿時,寫端無法繼續寫入,必須等待管道中有空間才能繼續寫入。
形象化理解:
-
就像垃圾桶滿了后,不能繼續往里面丟垃圾,必須等到垃圾桶被清空(子進程讀取數據)之后,才能繼續丟垃圾。
4.3 場景三:關閉寫端,子進程讀取數據
情況描述:
-
父進程寫入數據到管道,并關閉寫端。
-
子進程從管道中讀取數據,并在讀取到末尾時判斷寫端是否關閉。
結果:
-
當父進程關閉寫端后,子進程可以繼續讀取管道中的數據,直到數據讀取完。
-
子進程在讀取到數據末尾時會收到 read的,表示已經沒有更多數據可讀取,且寫端已關閉。
形象化理解:
-
這類似于垃圾桶的垃圾已經被倒空,子進程(倒垃圾的人)會看到垃圾桶已經沒有垃圾了。即使它繼續嘗試“倒垃圾”,也不會有新的垃圾,顯示讀取到了文件末尾。
4.4 場景四:關閉讀端,父進程寫入數據
情況描述:
-
父進程是寫端,子進程是讀端。父進程寫入數據。
-
父進程在讀取五次后關閉讀端。
結果:
-
當關閉讀端后,寫端(父進程)會收到
SIGPIPE
信號,通常導致進程終止。 -
因為操作系統會發現,寫端已沒有可用的讀取端(讀端關閉了),它會強制終止寫端進程以防止資源浪費。
形象化理解:
這就像垃圾桶的“倒垃圾的人”(寫端)發現沒有“垃圾桶”(讀端)可以丟垃圾,因此操作系統會終止寫端,避免無意義的行為繼續發生。
5、匿名管道實操-進程控制
匿名管道作為 IPC 的其中一種解決方案,那么肯定有它的實戰價值
場景:父進程創建了一批子進程,并通過多條匿名管道與它們鏈接,父進程選擇某個子進程,并通過匿名管道與子進程通信,并下達指定的任務讓其執行
5.1 邏輯設計
首先創建一批子進程及匿名管道 -> 子進程(讀端)阻塞,等待寫端寫入數據 -> 選擇相應的進程,并對其寫入任務編號(數據)-> 子進程拿到數據后,執行相應任務
1.創建一批進程及管道
首先需要先創建一個包含進程信息的類,最主要的就是子進程的寫端 fd,這樣父進程才能通過此 fd 進行數據寫入
循環創建管道、子進程,進行相應的管道鏈接操作,然后子進程進入任務等待狀態,父進程將創建好的子進程信息注冊
假設子進程獲取了任務代號,那么應該根據任務代號,去執行相應的任務,否則阻塞等待
注意: 因為是創建子進程,所以存在關系重復繼承的情況,此時應該統計當前子進程的寫端 fd,在創建下一個進程時,關閉無關的 fd
具體體現為:每次都把 寫端 fd 存儲起來,在確定關系前 “清理” 干凈
關于上述操作的危害,需要在編寫完進程等待函數后,才能演示其作用?。
完整代碼如下:
Task.hpp
#pragma once
#include<iostream>
#include<vector>typedef void (*task_t)();void task1()
{std::cout << "lol 刷新日志" << std::endl;
}
void task2()
{std::cout << "lol 更新野區,刷新出來野怪" << std::endl;
}
void task3()
{std::cout << "lol 檢測軟件是否更新,如果需要,就提示用戶" << std::endl;
}
void task4()
{std::cout << "lol 用戶釋放技能,更新用的血量和藍量" << std::endl;
}
void LoadTask(std::vector<task_t>& task)
{task.push_back(task1);task.push_back(task2);task.push_back(task3);task.push_back(task4);
}
?processpool.cc
#include "Task.hpp"
#include <string>
#include <vector>
#include <cstdlib>
#include <ctime>
#include <cassert>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/wait.h>using namespace std;const int processnum =4;
vector<task_t> tasks; //把所有的任務,裝進去class channel
{public:channel(int &cmdfd ,int &mypid,string &name):_name(name),_cmdfd(cmdfd),_mypid(mypid){}public:string _name;//子進程的名字int _cmdfd;//發信號的文件描述符 int _mypid;//我的PID
};void Menu()
{std::cout << "################################################" << std::endl;std::cout << "# 1. 刷新日志 2. 刷新出來野怪 #" << std::endl;std::cout << "# 3. 檢測軟件是否更新 4. 更新用的血量和藍量 #" << std::endl;std::cout << "# 0. 退出 #" << std::endl;std::cout << "#################################################" << std::endl;
}void slaver(int pool)
{while(true){int cmdcode=0;//通過調用碼,去領任務int n=read(pool,&cmdcode,sizeof(int));cout <<"slaver say@ get a command: "<< getpid() << " : cmdcode: " << cmdcode << endl;if(cmdcode>0&&cmdcode<=tasks.size()) tasks[cmdcode-1]();if(n==0) break;}
}void InitProcesspool(vector<channel>&channels)
{vector<int> d;//把父進程的所有打開的寫管道存儲到里面for(int i=1;i<=processnum;i++){int pipeid[2];int n=pipe(pipeid);assert(!n);int pid=fork();if(pid==0){cout<<"創建的"<< i<<"號子進程"<<endl;for(auto &t:d){close(t);//關掉所有不相關的管道}close(pipeid[1]);//關閉寫管道slaver(pipeid[0]);//讀操作// close(pipeid[0]);//多此一舉cout<<"關閉的"<< i<<"號子進程"<<endl;exit(0);}close(pipeid[0]);string name="創建的子進程"+to_string(i);channels.push_back({pipeid[1],pid,name});//那個管道發數據記錄下來,d.push_back(pipeid[1]);//把一會發數據的管道號記下來sleep(1);}
}void setslaver(vector<channel>&channels)
{int which=0;int cnt=4;while(cnt--){int slect=0;Menu();cin>>slect; if(!slect) break; // rand((void)time(nullptr));// int i=srand()%5;cout<<"farher message"<<channels[which]._name<<endl;write(channels[which]._cmdfd,&slect,sizeof(int)); which++;which%=channels.size();sleep(1);}
}void Quitpool(vector<channel>&channels)
{for(auto& t:channels)//關閉所有的寫管道{close(t._cmdfd);waitpid(t._mypid,nullptr,0);}
}int main()
{vector<channel> channels;//把打開的子進程裝進來LoadTask(tasks);InitProcesspool(channels);//創建子進程setslaver(channels);//發配任務,采用輪詢Quitpool(channels);//關閉寫管道,等到read=0子進程退出,全部關閉return 0;
}
總體來說,在使用這個小程序時,以下關鍵點還是值得多注意的
注冊子進程信息時,存儲的是 寫端 fd,目的是為了通過此 fd 向對應的子進程寫數據,即使用不同的匿名管道
創建管道后,需要關閉父、子進程中不必要的 fd
需要特別注意父進程寫端 fd 被多次繼承的問題,避免因寫端沒有關干凈,而導致讀端持續阻塞關閉讀端對應的寫端后,讀端會讀到 0,可以借助此特性結束子進程的運行
在選擇進程 / 任務 時,要做好越界檢查
等待子進程退出時,需要先關閉寫端,子進程才會退出,然后才能正常等待。