高頻考點,六大進程通訊機制總結

 

🎓 盡人事,聽天命。部落客東南大學碩士在讀,熱愛健身和籃球,樂於分享技術相關的所見所得,關注公眾號 @ 飛天小牛肉,第一時間獲取文章更新,成長的路上我們一起進步

🎁 本文已收錄於 「CS-Wiki」Gitee 官方推薦項目,現已累計 1.4k+ star,致力打造完善的後端知識體系,在技術的路上少走彎路,歡迎各位小夥伴前來交流學習

🍉 如果各位小夥伴春招秋招沒有拿得出手的項目的話,可以參考我寫的一個項目「開源社區系統 Echo」Gitee 官方推薦項目,目前已累計 250+ star,基於 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + … 並提供詳細的開發文檔和配套教程。公眾號後台回復 Echo 可以獲取配套教程,目前尚在更新中

 

初學作業系統的時候,我就一直懵逼,為啥進程同步與互斥機制里有訊號量機制,進程通訊里又有訊號量機制,然後你再看網路上的各種面試題匯總或者部落格,你會發現很多都是千篇一律的進程通訊機制有哪些?進程同步與互斥機制鮮有人問津。看多了我都想把 CSDN 屏了…..,最後知道真相的我只想說為啥不能一篇部落格把東西寫清楚,沒頭沒尾真的浪費時間。

希望這篇文章能夠拯救某段時間和我一樣被繞暈的小夥伴。上篇文章我已經講過進程間的同步與互斥機制,各位小夥伴看完這個再來看進程通訊比較好。

全文脈絡思維導圖如下:

1. 什麼是進程通訊

顧名思義,進程通訊( InterProcess Communication,IPC)就是指進程之間的資訊交換。實際上,進程的同步與互斥本質上也是一種進程通訊(這也就是待會我們會在進程通訊機制中看見訊號量和 PV 操作的原因了),只不過它傳輸的僅僅是訊號量,通過修改訊號量,使得進程之間建立聯繫,相互協調和協同工作,但是它缺乏傳遞數據的能力

雖然存在某些情況,進程之間交換的資訊量很少,比如僅僅交換某個狀態資訊,這樣進程的同步與互斥機制完全可以勝任這項工作。但是大多數情況下,進程之間需要交換大批數據,比如傳送一批資訊或整個文件,這就需要通過一種新的通訊機制來完成,也就是所謂的進程通訊。

再來從作業系統層面直觀的看一些進程通訊:我們知道,為了保證安全,每個進程的用戶地址空間都是獨立的,一般而言一個進程不能直接訪問另一個進程的地址空間,不過內核空間是每個進程都共享的,所以進程之間想要進行資訊交換就必須通過內核

下面就來我們來列舉一下 Linux 內核提供的常見的進程通訊機制:

  • 管道(也稱作共享文件)

  • 消息隊列(也稱作消息傳遞)

  • 共享記憶體(也稱作共享存儲)

  • 訊號量和 PV 操作

  • 訊號

  • 套接字(Socket)

2. 管道

匿名管道

各位如果學過 Linux 命令,那對管道肯定不陌生,Linux 管道使用豎線 | 連接多個命令,這被稱為管道符。

$ command1 | command2

以上這行程式碼就組成了一個管道,它的功能是將前一個命令(command1)的輸出,作為後一個命令(command2)的輸入,從這個功能描述中,我們可以看出管道中的數據只能單向流動,也就是半雙工通訊,如果想實現相互通訊(全雙工通訊),我們需要創建兩個管道才行。

另外,通過管道符 | 創建的管道是匿名管道,用完了就會被自動銷毀。並且,匿名管道只能在具有親緣關係(父子進程)的進程間使用,。也就是說,匿名管道只能用於父子進程之間的通訊

在 Linux 的實際編碼中,是通過 pipe 函數來創建匿名管道的,若創建成功則返回 0,創建失敗就返回 -1:

int pipe (int fd[2]);

該函數擁有一個存儲空間為 2 的文件描述符數組:

  • fd[0] 指向管道的讀端,fd[1] 指向管道的寫端

  • fd[1] 的輸出是 fd[0] 的輸入

粗略的解釋一下通過匿名管道實現進程間通訊的步驟:

1)父進程創建兩個匿名管道,管道 1(fd1[0]fd1[1])和管道 2(fd2[0]fd2[1]);

因為管道的數據是單向流動的,所以要想實現數據雙向通訊,就需要兩個管道,每個方向一個。

2)父進程 fork 出子進程,於是對於這兩個匿名管道,子進程也分別有兩個文件描述符指向匿名管道的讀寫兩端;

3)父進程關閉管道 1 的讀端 fd1[0] 和 管道 2 的寫端 fd2[1],子進程關閉管道 1 的寫端 fd1[1] 和 管道 2 的讀端 fd2[0],這樣,管道 1 只能用於父進程寫、子進程讀;管道 2 只能用於父進程讀、子進程寫。管道是用環形隊列實現的,數據從寫端流入從讀端流出,這就實現了父子進程之間的雙向通訊。

看完上面這些講述,我們來理解下管道的本質是什麼:對於管道兩端的進程而言,管道就是一個文件(這也就是為啥管道也被稱為共享文件機制的原因了),但它不是普通的文件,它不屬於某種文件系統,而是自立門戶,單獨構成一種文件系統,並且只存在於記憶體中。

簡單來說,管道的本質就是內核在記憶體中開闢了一個緩衝區,這個緩衝區與管道文件相關聯,對管道文件的操作,被內核轉換成對這塊緩衝區的操作

有名管道

匿名管道由於沒有名字,只能用於父子進程間的通訊。為了克服這個缺點,提出了有名管道,也稱做 FIFO,因為數據是先進先出的傳輸方式。

所謂有名管道也就是提供一個路徑名與之關聯,這樣,即使與創建有名管道的進程不存在親緣關係的進程,只要可以訪問該路徑,就能夠通過這個有名管道進行相互通訊。

使用 Linux 命令 mkfifo 來創建有名管道:

$ mkfifo myPipe

myPipe 就是這個管道的名稱,接下來,我們往 myPipe 這個有名管道中寫入數據:

$ echo "hello" > myPipe

執行這行命令後,你會發現它就停在這了,這是因為管道里的內容沒有被讀取,只有當管道里的數據被讀完後,命令才可以正常退出。於是,我們執行另外一個命令來讀取這個有名管道里的數據:

$ cat < myPipe
hello

3. 消息隊列

可以看出,管道這種進程通訊方式雖然使用簡單,但是效率比較低,不適合進程間頻繁地交換數據,並且管道只能傳輸無格式的位元組流。為此,消息傳遞機制(Linux 中稱消息隊列)應用而生。比如,A 進程要給 B 進程發送消息,A 進程把數據放在對應的消息隊列後就可以正常返回了,B 進程在需要的時候自行去消息隊列中讀取數據就可以了。同樣的,B 進程要給 A 進程發送消息也是如此。

消息隊列的本質就是存放在記憶體中的消息的鏈表,而消息本質上是用戶自定義的數據結構。如果進程從消息隊列中讀取了某個消息,這個消息就會被從消息隊列中刪除。對比一下管道機制:

  • 消息隊列允許一個或多個進程向它寫入或讀取消息。

  • 消息隊列可以實現消息的隨機查詢,不一定非要以先進先出的次序讀取消息,也可以按消息的類型讀取。比有名管道的先進先出原則更有優勢。

  • 對於消息隊列來說,在某個進程往一個隊列寫入消息之前,並不需要另一個進程在該消息隊列上等待消息的到達。而對於管道來說,除非讀進程已存在,否則先有寫進程進行寫入操作是沒有意義的。

  • 消息隊列的生命周期隨內核,如果沒有釋放消息隊列或者沒有關閉作業系統,消息隊列就會一直存在。而匿名管道隨進程的創建而建立,隨進程的結束而銷毀。

需要注意的是,消息隊列對於交換較少數量的數據很有用,因為無需避免衝突。但是,由於用戶進程寫入數據到記憶體中的消息隊列時,會發生從用戶態拷貝數據到內核態的過程;同樣的,另一個用戶進程讀取記憶體中的消息數據時,會發生從內核態拷貝數據到用戶態的過程。因此,如果數據量較大,使用消息隊列就會造成頻繁的系統調用,也就是需要消耗更多的時間以便內核介入

4. 共享記憶體

為了避免像消息隊列那樣頻繁的拷貝消息、進行系統調用,共享記憶體機制出現了。

顧名思義,共享記憶體就是允許不相干的進程將同一段物理記憶體連接到它們各自的地址空間中,使得這些進程可以訪問同一個物理記憶體,這個物理記憶體就成為共享記憶體。如果某個進程向共享記憶體寫入數據,所做的改動將立即影響到可以訪問同一段共享記憶體的任何其他進程。

集合記憶體管理的內容,我們來深入理解下共享記憶體的原理。首先,每個進程都有屬於自己的進程式控制制塊(PCB)和邏輯地址空間(Addr Space),並且都有一個與之對應的頁表,負責將進程的邏輯地址(虛擬地址)與物理地址進行映射,通過記憶體管理單元(MMU)進行管理。兩個不同進程的邏輯地址通過頁表映射到物理空間的同一區域,它們所共同指向的這塊區域就是共享記憶體

不同於消息隊列頻繁的系統調用,對於共享記憶體機制來說,僅在建立共享記憶體區域時需要系統調用,一旦建立共享記憶體,所有的訪問都可作為常規記憶體訪問,無需藉助內核。這樣,數據就不需要在進程之間來回拷貝,所以這是最快的一種進程通訊方式。

5. 訊號量和 PV 操作

實際上,對具有多 CPU 系統的最新研究表明,在這類系統上,消息傳遞的性能其實是要優於共享記憶體的,因為消息隊列無需避免衝突,而共享記憶體機制可能會發生衝突。也就是說如果多個進程同時修改同一個共享記憶體,先來的那個進程寫的內容就會被後來的覆蓋。

並且,在多道批處理系統中,多個進程是可以並發執行的,但由於系統的資源有限,進程的執行不是一貫到底的, 而是走走停停,以不可預知的速度向前推進(非同步性)。但有時候我們又希望多個進程能密切合作,按照某個特定的順序依次執行,以實現一個共同的任務。

舉個例子,如果有 A、B 兩個進程分別負責讀和寫數據的操作,這兩個執行緒是相互合作、相互依賴的。那麼寫數據應該發生在讀數據之前。而實際上,由於非同步性的存在,可能會發生先讀後寫的情況,而此時由於緩衝區還沒有被寫入數據,讀進程 A 沒有數據可讀,因此讀進程 A 被阻塞。

因此,為了解決上述這兩個問題,保證共享記憶體在任何時刻只有一個進程在訪問(互斥),並且使得進程們能夠按照某個特定順序訪問共享記憶體(同步),我們就可以使用進程的同步與互斥機制,常見的比如訊號量與 PV 操作。

進程的同步與互斥其實是一種對進程通訊的保護機制,並不是用來傳輸進程之間真正通訊的內容的,但是由於它們會傳輸訊號量,所以也被納入進程通訊的範疇,稱為低級通訊

下面的內容和上篇文章【看完了進程同步與互斥機制,我終於徹底理解了 PV 操作】中所講的差不多,看過的小夥伴可直接跳到下一標題。

訊號量其實就是一個變數 ,我們可以用一個訊號量來表示系統中某種資源的數量,比如:系統中只有一台印表機,就可以設置一個初值為 1 的訊號量。

用戶進程可以通過使用作業系統提供的一對原語來對訊號量進行操作,從而很方便的實現進程互斥或同步。這一對原語就是 PV 操作:

1)P 操作:將訊號量值減 1,表示申請佔用一個資源。如果結果小於 0,表示已經沒有可用資源,則執行 P 操作的進程被阻塞。如果結果大於等於 0,表示現有的資源足夠你使用,則執行 P 操作的進程繼續執行。

可以這麼理解,當訊號量的值為 2 的時候,表示有 2 個資源可以使用,當訊號量的值為 -2 的時候,表示有兩個進程正在等待使用這個資源。不看這句話真的無法理解 V 操作,看完頓時如夢初醒。

2)V 操作:將訊號量值加 1,表示釋放一個資源,即使用完資源後歸還資源。若加完後訊號量的值小於等於 0,表示有某些進程正在等待該資源,由於我們已經釋放出一個資源了,因此需要喚醒一個等待使用該資源(就緒態)的進程,使之運行下去。

我覺得已經講的足夠通俗了,不過對於 V 操作大家可能仍然有困惑,下面再來看兩個關於 V 操作的問答:

問:訊號量的值 大於 0 表示有共享資源可供使用,這個時候為什麼不需要喚醒進程

答:所謂喚醒進程是從就緒隊列(阻塞隊列)中喚醒進程,而訊號量的值大於 0 表示有共享資源可供使用,也就是說這個時候沒有進程被阻塞在這個資源上,所以不需要喚醒,正常運行即可。

問:訊號量的值 等於 0 的時候表示沒有共享資源可供使用,為什麼還要喚醒進程

答:V 操作是先執行訊號量值加 1 的,也就是說,把訊號量的值加 1 後才變成了 0,在此之前,訊號量的值是 -1,即有一個進程正在等待這個共享資源,我們需要喚醒它。

訊號量和 PV 操作具體的定義如下:

互斥訪問共享記憶體

兩步走即可實現不同進程對共享記憶體的互斥訪問:

  • 定義一個互斥訊號量,並初始化為 1

  • 把對共享記憶體的訪問置於 P 操作和 V 操作之間

P 操作和 V 操作必須成對出現。缺少 P 操作就不能保證對共享記憶體的互斥訪問,缺少 V 操作就會導致共享記憶體永遠得不到釋放、處於等待態的進程永遠得不到喚醒。

實現進程同步

回顧一下進程同步,就是要各並發進程按要求有序地運行。

舉個例子,以下兩個進程 P1、P2 並發執行,由於存在非同步性,因此二者交替推進的次序是不確定的。假設 P2 的 「程式碼4」 要基於 P1 的 「程式碼1」 和 「程式碼2」 的運行結果才能執行,那麼我們就必須保證 「程式碼4」 一定是在 「程式碼2」 之後才會執行。

如果 P2 的 「程式碼4」 要基於 P1 的 「程式碼1」 和 「程式碼2」 的運行結果才能執行,那麼我們就必須保證 「程式碼4」 一定是在 「程式碼2」 之後才會執行。

使用訊號量和 PV 操作實現進程的同步也非常方便,三步走:

  • 定義一個同步訊號量,並初始化為當前可用資源的數量

  • 在優先順序較的操作的面執行 V 操作,釋放資源

  • 在優先順序較的操作的面執行 P 操作,申請佔用資源

配合下面這張圖直觀理解下:

6. 訊號

注意!訊號和訊號量是完全不同的兩個概念

訊號是進程通訊機制中唯一的非同步通訊機制,它可以在任何時候發送訊號給某個進程。通過發送指定訊號來通知進程某個非同步事件的發送,以迫使進程執行訊號處理程式。訊號處理完畢後,被中斷進程將恢復執行。用戶、內核和進程都能生成和發送訊號。

訊號事件的來源主要有硬體來源和軟體來源。所謂硬體來源就是說我們可以通過鍵盤輸入某些組合鍵給進程發送訊號,比如常見的組合鍵 Ctrl+C 產生 SIGINT 訊號,表示終止該進程;而軟體來源就是通過 kill 系列的命令給進程發送訊號,比如 kill -9 1111 ,表示給 PID 為 1111 的進程發送 SIGKILL 訊號,讓其立即結束。我們來查看一下 Linux 中有哪些訊號:

7. Socket

至此,上面介紹的 5 種方法都是用於同一台主機上的進程之間進行通訊的,如果想要跨網路與不同主機上的進程進行通訊,那該怎麼做呢?這就是 Socket 通訊做的事情了(當然,Socket 也能完成同主機上的進程通訊)。

Socket 起源於 Unix,原意是插座,在電腦通訊領域,Socket 被翻譯為套接字,它是電腦之間進行通訊的一種約定或一種方式。通過 Socket 這種約定,一台電腦可以接收其他電腦的數據,也可以向其他電腦發送數據。

從電腦網路層面來說,Socket 套接字是網路通訊的基石,是支援 TCP/IP 協議的網路通訊的基本操作單元。它是網路通訊過程中端點的抽象表示,包含進行網路通訊必須的五種資訊:連接使用的協議,本地主機的 IP 地址,本地進程的協議埠,遠地主機的 IP 地址,遠地進程的協議埠

Socket 的本質其實是一個編程介面(API),是應用層與 TCP/IP 協議族通訊的中間軟體抽象層,它對 TCP/IP 進行了封裝。它把複雜的 TCP/IP 協議族隱藏在 Socket 介面後面。對用戶來說,只要通過一組簡單的 API 就可以實現網路的連接。

8. 總結

簡單總結一下上面六種 Linux 內核提供的進程通訊機制:

1)首先,最簡單的方式就是管道,管道的本質是存放在記憶體中的特殊的文件。也就是說,內核在記憶體中開闢了一個緩衝區,這個緩衝區與管道文件相關聯,對管道文件的操作,被內核轉換成對這塊緩衝區的操作。管道分為匿名管道和有名管道,匿名管道只能在父子進程之間進行通訊,而有名管道沒有限制。

2)雖然管道使用簡單,但是效率比較低,不適合進程間頻繁地交換數據,並且管道只能傳輸無格式的位元組流。為此消息隊列應用而生。消息隊列的本質就是存放在記憶體中的消息的鏈表,而消息本質上是用戶自定義的數據結構。如果進程從消息隊列中讀取了某個消息,這個消息就會被從消息隊列中刪除。

3)消息隊列的速度比較慢,因為每次數據的寫入和讀取都需要經過用戶態與內核態之間數據的拷貝過程,共享記憶體可以解決這個問題。所謂共享記憶體就是:兩個不同進程的邏輯地址通過頁表映射到物理空間的同一區域,它們所共同指向的這塊區域就是共享記憶體。如果某個進程向共享記憶體寫入數據,所做的改動將立即影響到可以訪問同一段共享記憶體的任何其他進程。

對於共享記憶體機制來說,僅在建立共享記憶體區域時需要系統調用,一旦建立共享記憶體,所有的訪問都可作為常規記憶體訪問,無需藉助內核。這樣,數據就不需要在進程之間來回拷貝,所以這是最快的一種進程通訊方式。

4)共享記憶體速度雖然非常快,但是存在衝突問題,為此,我們可以使用訊號量和 PV 操作來實現對共享記憶體的互斥訪問,並且還可以實現進程同步。

5)訊號和訊號量是完全不同的兩個概念!訊號是進程通訊機制中唯一的非同步通訊機制,它可以在任何時候發送訊號給某個進程。通過發送指定訊號來通知進程某個非同步事件的發送,以迫使進程執行訊號處理程式。訊號處理完畢後,被中斷進程將恢復執行。用戶、內核和進程都能生成和發送訊號。

6)上面介紹的 5 種方法都是用於同一台主機上的進程之間進行通訊的,如果想要跨網路與不同主機上的進程進行通訊,就需要使用 Socket 通訊。另外,Socket 也能完成同主機上的進程通訊。

總結完畢!

 

🎉 關注公眾號 | 飛天小牛肉,即時獲取更新

  • 部落客東南大學碩士在讀,利用課餘時間運營一個公眾號『 飛天小牛肉 』,2020/12/29 日開通,專註分享電腦基礎(數據結構 + 演算法 + 電腦網路 + 資料庫 + 作業系統 + Linux)、Java 基礎和面試指南的相關原創技術好文。本公眾號的目的就是讓大家可以快速掌握重點知識,有的放矢。希望大家多多支援哦,和小牛肉一起成長 😃

  • 並推薦個人維護的開源教程類項目: CS-Wiki(Gitee 推薦項目,現已累計 1.4k+ star), 致力打造完善的後端知識體系,在技術的路上少走彎路,歡迎各位小夥伴前來交流學習 ~ 😊

  • 如果各位小夥伴春招秋招沒有拿得出手的項目的話,可以參考我寫的一個項目「開源社區系統 Echo」Gitee 官方推薦項目,目前已累計 250+ star,基於 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + … 並提供詳細的開發文檔和配套教程。公眾號後台回復 Echo 可以獲取配套教程,目前尚在更新中。