深入理解 Linux 進程間通信

作者簡介:

程磊,某手機大廠系統開發工程師,閱碼場榮譽總編輯,最大的愛好是鑽研 Linux 內核基本原理。

一、進程間通信的本質

什麼是進程間通信?

爲什麼要有進程間通信?

爲什麼能進程間通信?

1.1 爲什麼要通信

我們先拿人來做個類比,人與人之間爲什麼要通信,有兩個原因。首先是因爲你有和對方溝通的需求,如果你都不想搭理對方,那就肯定不用通信了。其次是因爲有空間隔離,如果你倆在一起,對方就站在你面前,你有話直說就行了,不需要通信。此時你非要給對方打個電話或者發個微信,是不是顯得非常奇怪、莫名其妙。如果你倆不在一塊,還有事需要溝通,此時就需要通信了。通信的方式有點烽火、送信鴿、寫信、發電報、打電話、發微信等。採取什麼樣的通信方式跟你的需求、通信量的大小、以及客觀上能否實現有關。

同樣的,軟件體系中爲什麼會有進程間通信呢?首先是因爲軟件中有這個需求,比如有些任務是由多個進程一起協同來完成的,或者一個進程對另一個進程有服務請求,或者有消息要向另一方提供。其次是因爲進程間有隔離,每個進程都有自己獨立的用戶空間,互相看不到對方,所以才需要通信。

1.2 爲什麼能通信

爲什麼能通信呢?那是因爲內核空間是共享的,雖然 N 個進程都有 N 個用戶空間,但是內核空間只有一個,雖然用戶空間之間是完全隔離的,但是用戶空間與內核空間並不是完全隔離的,他們之間有系統調用這個通道可以溝通。所以兩個用戶空間就可以通過內核空間這個橋樑進行溝通了。

我們再借助一副圖來講解一下。

雖然這個圖是講進程調度的,但是大家從這個圖裏面也能看出來進程之間爲什麼要通信,因爲進程之間都是有空間隔離的,它們之間要想交流信息是沒有辦法的。但是也不是完全沒有辦法,好在它們都和內核是連着的,雖然它們不能隨意訪問內核,但是還有系統調用這個大門,進程之間可以通過一些特殊的系統調用和內核溝通從而達到和其它進程通信的目的。

**二、進程間通信的框架 **

通過上一章的描述,我們明白了進程間爲什麼要通信、爲什麼能通信,現在我們來看看進程間通信機制該如何實現。

2.1 進程間通信機制的結構

進程間通信機制都要有兩部分組成,一是存在於內核空間的通信中樞,二是存在於用戶空間的通信接口,這兩者的關係就好比是郵局與信紙的關係、基站與手機的關係。通信中樞提供通信機制,通信接口提供使用方法。我們使用通信接口來讓通信中樞幫我們建立通信信道或者傳遞通信信息。

下面我們畫個圖看一下進程間通信機制的基本結構。

2.2 進程間通信機制的類型

進程間通信機制的類型有兩種,一種是媒婆式,給你倆牽線搭橋,然後就不管了,你倆自己聊吧,另一種是保姆式,一直在中間傳話。這兩種模式用計算機的術語來說分別叫做共享內存式和消息傳遞式。共享內存式進程間通信,通信中樞建立好通信信道之後,就不再管了,通信雙方之後的通信不需要通信中樞的協助。消息傳遞式進程間通信,通信中樞建立好通信信道之後,每次通信還都需要通信中樞的協助。共享內存式進程間通信,由於通信信息的傳遞不需要通信中樞的協助,所以通信雙方還需要進程間同步,來保證數據讀寫的一致性,以避免踩踏數據或者讀到垃圾數據。消息傳遞式進程間通信,由於通信信息是通過通信中樞傳遞的,所以不需要進程間同步。消息傳遞式進程間通信又可以分爲兩類,有邊界消息和無邊界消息。無邊界消息就是字節流,發過來是一個一個的字節,要靠進程自己設計如何區分消息的邊界。有邊界消息的進程間通信的發送和接收都是以消息爲基本單位的。

2.3 進程間通信機制的接口設計

按照通信雙方的關係,可以把通信類型分爲對稱型通信和非對稱型通信。對稱型通信的雙方關係是對等的,非對稱型通信的雙方關係是不對等的,可能是命令執行關係、客戶服務關係、生產消費關係等。這種關係是通信雙方邏輯上的關係,並不是進程間通信機制本身的特徵。消息傳遞式進程間通信一般用於非對稱型通信,共享內存式進程間通信一般用於對稱型通信,也可以用於非對稱型通信。

進程間通信機制一般要實現下面三類接口,但是有些機制不一定要這三類接口都實現。

  1. 如何建立通信信道,誰去建立通信信道。

  2. 後者如何找到並加入這個通信信道。

  3. 如何使用通信信道。

對於對稱型通信來說,誰去建立通信信道無所謂,有一個人去建立就可以了,後者直接加入通信信道。對於非對稱型通信,一般是由服務端、消費者建立通信信道,客戶端、生產者則加入這個通信信道。如何建立信道呢,不同的進程間通信機制,有不同的接口來創建信道,這個在下一章講。後者如何找到並加入前者建立的通信信道呢?一般情況是,雙方通過提前約定好的信道名稱找到信道句柄,通過信道句柄加入通信信道。但是有的是通過繼承把信道句柄傳遞給對方,有的是通過其它進程間通信機制傳遞信道句柄,有的則是通過信道名稱直接找到信道,不需要信道句柄。如何使用信道呢?對於消息傳遞式進程間通信來說,一般都要提供特殊的接口。對於共享內存式進程間通信來說,則不需要提供這個接口,因爲就和訪問普通內存一樣訪問共享內存就行。

三、進程間通信機制簡介

前面我們對進程間通信的本質和框架有了基本的瞭解,下面我們來簡單介紹一下 Linux 中的所有進程間通信機制。我們先來看一下總圖。

我們先把這張圖簡介瀏覽一下。首先從大類上分,進程間通信方法可以分爲 3 類,消息傳遞式、共享內存式、進程間同步。爲啥這裏會有進程間同步呢?進程間同步是爲了同步兩個進程對共享內存的讀寫,進程間同步也算是在兩個進程間傳遞了信息,所以把進程間同步也放在了進程間通信中。

可以看到共享內存式機制比消息傳遞式機制要少,我們就先介紹共享內存式。共享內存式進程間通信的原理很簡單,就是通過修改頁表,使得兩個虛擬進程空間的一部分虛擬內存對應到相同的物理內存上。雖然原理是一樣的,但是具體怎麼實現,接口怎麼設計,又產生了許多不同的共享內存式進程間通信機制。

3.1 SysV 共享內存

SysV 共享內存是一種非常古老的共享內存方法,是在 UNIX 誕生早期就有的方法。SysV 共享內存創建共享內存的方法是使用接口 shmget,它有三個參數,分別是 key、size、flag。其中 key 是一個整數,是表示通信信道的名稱,兩個進程要提前約定好 key。Size 代表共享內存的大小。Flag 用來表示創建的行爲,flag IPC_CREAT 表示如果通信信道存在就直接獲取它,如果還不存在就創建它,沒有 IPC_CREAT 的話表示只獲取不創建。如果再加上 IPC_EXCL 的話,表示只創建,如果已經被別人創建了則返回失敗。shmget 返回的是共享內存的 id,代表通信信道的句柄。然後拿着通信信道的句柄通過 shmat 接口就可以把底層的物理內存映射到本進程空間了。函數返回值就是映射到本進程虛擬內存空間的一個指針,然後就可以像訪問普通內存一樣讀寫這段內存了。任務完成之後就可以通過 shmdt 接口釋放信道。注意這只是釋放了本進程的通信信道,沒有釋放底層的物理內存,要釋放底層物理內存的話,需要使用接口 shmctl() 並選擇 IPC_RMID 操作。

3.2 POSIX 共享內存

相信大家對前面的敘述都有個疑惑,用一個整數當做通信信道名稱,那豈不是很容易就選重了,那不就錯亂了嘛!而且如果有人惡意猜測使用你的 key,你也沒有辦法。針對這個問題,POSIX 設計出了一個新的共享內存方案,叫做 POSIX 共享內存,很好地解決了這個問題。POSIX 共享內存使用接口 shm_open 來創建共享內存通信信道句柄,它的參數和 open 是一樣的,但是它不創建磁盤文件。這樣以來,我們使用的是一個路徑名作爲通信信道的名稱,這就比一個整數 key 好多了,容易起名字還不容易重複。並且它的參數是和 open 一樣的,所以它的第三個參數 mode 可以指定權限,這樣就更安全了。shm_open 的第二個參數和 open 的第二個參數是一樣的,可以指定 flag O_CREAT O_EXCL,這兩個 flag 和前面的 shmget 可以達到相同的效果,你可以選擇是僅加入已經信道,還是非要自己親自創建信道,或者已有就加入沒有就創建。shm_open 返回的是一個 fd,這個 fd 就是通信信道的句柄。有了這個 fd,我們可以通過接口 ftruncate 來設置共享內存的大小。得到了信道句柄之後,我們加入信道的方式不是用的專用的方法,而是使用系統已有的接口,用的是 shared mmap,這點和 SysV 共享內存有很大的不同。mmap 之後我們就加入了信道,其返回值是本進程虛擬內存空間的指針,我們就可以像操作普通內存一樣操作它了。

3.3 共享內存映射

系統調用 mmap 並不是專門用來做進程間通信的,它是用來做內存映射的。它的映射來源可以用文件也可以是匿名 (也就是沒有來源,直接分配內存並初始化爲 0)。它的映射方式可以是私有的,也可以是共享的。映射來源和映射方式兩者一組合是四種方式。當我們使用共享映射方式的時候,正好可以用來做進程間通信。對於共享文件映射,兩個進程映射相同的文件就可以達到共享內存的目的,文件名就是通信信道的名稱,由名稱直接加入信道,沒有信道句柄。對於共享匿名映射,是通過 fork 之後在父子進程之間共享內存的。fork 之後父子進程之間的內存本來是 COW(寫時複製) 的,也就是說父子進程之間不會共享內存,但是被共享匿名映射的部分不會 COW,而是在父子進程之間共享物理內存,這就達到了共享內存的效果。這種方法既沒有信道名稱也沒有信道句柄,是通過繼承方式直接就獲得了信道。這兩種共享內存的解除方法都是使用 munmap 函數。

3.4 Android ION

很多博客上都會介紹說 ION 是一個內存分配管理器,這麼說既對也不對,單看 ION 它確實是內存分配管理器,但是我們不能單看 ION,我們要把和 dma-buf 一起看。Dma-buf 既不是 dma 也不是 buffer,它是一個 buffer sharing 框架,重點是 sharing。Dma-buf 框架實現了進程與進程之間、進程與內核之間的內存共享方案。但是它僅僅是一個框架,本身並沒有分配內存的能力。ION 則在 dma-buf 框架的基礎之上實現了內存分配管理功能,所以應該把 ION 與 dma-buf 當做是一個整體,看成是共享內存機制。ION 與普通共享內存機制不同的是,它不僅僅可以在進程間共享內存,還能在進程與內核之間共享內存。ION 在進程之間共享內存時,是一方通過 / dev/ion 的 ioctl ALLOC 命令創建一個 fd,這個 fd 就是信道句柄,通過對這個 fd 進行 mmap 就可以通信了。這和 POSIX 共享內存的模式有點像,不同的是對方是如何得到這個 fd 的。POSIX 共享內存是通過大家都 shm_open 打開相同的文件名得到了同一個信道的句柄 (句柄值不一定相同,但是底層對應的信道是相同的)。ION 是通過 Binder 向另一個進程傳遞 fd 的,Binder 對 fd 做了特殊處理,對方收到的 fd 和自己的 fd,數值不一定相同,但是底層對應的東西是相同的。如果直接給一個進程傳遞 fd 的值,那是沒有意義的。ION 和內核驅動之間共享內存有兩種情況,一種是內核驅動創建了底層的物理內存然後把它包裝成一個 fd,通過一些系統調用傳遞給進程,進程對這個 fd 進行 mmap 就可以進行進程間通信了。另一種情況是進程創建了通信信道的 fd,然後通過一些系統調用傳遞給內核驅動,內核驅動就根據這個 fd 找到其對應的物理內存。

ION 裏面有許多不同的堆,每個堆分配的物理內存區域和方式並不相同,可以在使用 ION 接口的時候通過指定 flag 來選擇不同的堆。

3.5 dma-buf heaps

dma-buf heaps 是 ION 的替代品。因爲 ION 裏面所有的堆都對應同一個設備文件 / dev/ion,不同的堆是通過在接口中指定 flag 來選擇的。那麼這就存在一個問題,就是 ION 所有的堆對所有進程都是開放的,沒法或者不太容易對不同的進程做權限限制。dma-buf heaps 正好解決了這個問題,它把不同的堆分拆成了不同的設備,都在目錄 /dev/dma_heap/ 下,比如 /dev/dma_heap/system 是默認的堆。這樣不同的堆就可以設置不同的文件權限,還可以通過 selinux 進行限制,這樣就大大提高了安全性。它的用法和底層邏輯與 ION 是一樣了,這裏就不再過多介紹了。值得一提的是 dma-buf heaps 已經合入了標準內核,而且 Android 也正在逐步替換 ION。

3.6 匿名管道

前面說的都是共享內存式進程間通信,下面我們來說一說消息傳遞式進程間通信。我們先來說說無邊界的消息傳遞式進程間通信。

匿名管道是 UNIX 上最早的進程間通信機制了。它的出現來源於早期的操作系統都是命令行式的,我們經常需要多個命令來協同完成一個任務。比如 ls -ef | grep process-name , 這個命令中前面命令的輸出要作爲後面命令的輸入,中間的 | 豎線叫做管道符,代表像管道一樣從前往後傳遞數據。那麼這個管道符的邏輯在程序中是怎麼實現的呢,就是通過匿名管道實現的。Shell 在執行命令時先 fork 出一個子進程 A,然後在子進程 A 中解析命令,發現命令需要執行兩個程序,並通過管道連接。於是就使用匿名管道的創建接口 int pipe(int fd[2]),此接口接收一個雙 int 元素的數組作爲參數。接口執行完成後返回兩個 fd, fd[0] 是讀端 fd,fd[1] 是寫端接口。然後 fork A,生成進程 B,這樣進程 B 也繼承了兩個 fd。A 和 B 都有兩個 fd 是沒啥意義的,於是進程 A close(fd[0]),進程 B close(fd[1])。然後進程 A 執行 exec(“ls -l”), 然後進程 B 執行 exec(“grep process-name”),這樣進程 A 就可以通過 fd[1] 輸出數據,進程 B 通過 fd[0] 讀取數據。這樣就實現了進程間通信的目的。匿名管道通過通信雙方的父進程創建通信句柄,然後通過 fork 傳遞給子進程。父子進程都通過 file IO 的方式來進行消息傳遞。由於是使用的 file IO,所以讀寫的都是字節流,並沒有消息邊界。如果進程想要確定消息邊界,需要自己想辦法確定每個消息的邊界,比如每個換行符代表一個消息,或者每次遇到字符串 AAAAAA,代表一個新消息。

3.7 命名管道

我們可以看到匿名管道雖然很好用,但是卻有一個很大的缺陷,就是隻能父子進程或者親屬進程之間使用,因爲要傳遞信道句柄 fd。有沒有辦法擴大匿名管道的使用範圍呢,有,創建命名管道。管道有了名稱之後,其它進程就可以通過名稱找到信道句柄從而加入信道了。命名管道的用法是,首先要使用 mkfifo 命令在文件系統創建一個文件,這個文件是真實的文件,但不是常規文件,而是 fifo 類型的文件。有個這個文件之後,通信雙方的寫者就可以用正常的 open 接口以 O_WRONLY 模式打開文件,讀者就可以用 open 接口以 O_RDONLY 方式打開文件。然後讀寫雙方就可以通過各自的 fd 讀寫管道了。命名管道的創建方式和匿名管道不同,但是消息傳遞方式是相同的。匿名管道也是無邊界消息,原理同匿名管道一樣。

3.8 SysV 消息隊列

SysV 消息隊列是一個有邊界的消息傳遞式進程間通信。它的信道創建邏輯和 SysV 共享內存差不多。創建接口是 msgget,有兩個參數 key 和 flag。Key 是一個整數,是信道名稱。Flag 有兩個,flag IPC_CREAT 表示如果通信信道存在就直接獲取它,如果還不存在就創建它,沒有 IPC_CREAT 的話表示只獲取不創建。如果再加上 flag IPC_EXCL 的話,表示只創建,如果已經被別人創建了則返回失敗。msgget 返回的是消息隊列的 id,也就是信道的句柄。然後可以通過接口 msgsnd 和 msgrcv 來發送和接收消息,一個只能發送或者接收一個消息。當通信完成之後,可以通過接口 msgctl 的 IPC_RMID 操作來銷燬消息隊列。

3.9 POSIX 消息隊列

SysV 消息隊列和 SysV 共享內存存在的問題是一樣的,於是又設計了 POSIX 消息隊列。POSIX 消息隊列的創建接口是 mq_open,它的參數和 open 是類似的。用一個字符串類型的 name 作爲信道名稱。還有一個 flag 參數和前面講的 flag 參數是一樣的,可以指定是創建信道還是加入已經的信道。返回值叫做消息隊列描述符,是信道句柄。然後可以通過接口 mq_send、 mq_receive 來發送接收消息。當通信完成後可以通過接口 mq_close 來關閉信道。如果所有的進程都關閉信道了,底層信道纔會被刪除。

3.10 套接字

套接字是分爲網絡套接字和 UNIX local 套接字。網絡套接字不僅可以在本機進行進程間通信,還能在不同的機器間進行通信。UNIX local 套接字只能在本機的進程間進行通信。兩者都分爲流式套接字和數據報套接字,前者是無邊界消息傳遞式進程間通信,後者是有邊界消息傳遞式進程間通信。套接字是區分服務端和客戶端的,服務端創建通信信道,客戶端加入通信信道。套接字的接口這裏就不介紹了,大家可以找一些網絡編程相關的書籍或者博客來學習。

3.11 Android Binder

Android Binder 是谷歌爲 Android 開發的 RPC,RPC 是遠程過程調用的意思。RPC 也是一種進程間通信,但又不僅僅是進程間通信,它的使用接口表現爲可以透明調用其它進程的函數。Binder 的通信中樞是內核裏的 Binder 驅動,它的用戶空間接口是對虛擬設備 / dev/binder 的一系列 ioctl 命令。但是進程並不是直接使用這些 ioctl 命令的,而是使用谷歌封裝好的 libbinder 庫。Binder 的具體細節這裏就不講了,給大家推薦兩個學習博客:

http://gityuan.com/2015/10/31/binder-prepare/

這個系列博客大概有十幾篇,全面詳細地講解了 Binder 的各個方面。

https://www.jianshu.com/p/adaa1a39a274

這是 Binder 的進階學習,有 3 篇,通過一些提問與解答,讓你對 binder 有更深入的理解。

3.12 信號機制

信號機制是在 UNIX 裏面很早就存在的機制,它是內核用來處理程序運行時發生錯誤的一種方法,也是給進程發送一些簡單特定的消息的方法,所以也可以看做是一種進程間通信機制。但是它又比較特殊,它和一般的進程間通信機制的結構都不太相同。它是不需要建立通信信道的,因爲它不是典型的進程間通信,或者說它的通信信道是天然建立好的,因爲它用的是 pid 來指定消息傳遞給誰。它的發送是內核發送或者進程通過 kill 等接口發送,指定 pid 就能發送給對方。對方可以設置信號處理函數來接收處理信號,也可以不設置,內核會進行默認處理。信號機制的具體細節請參看《深入理解 Linux 信號機制》。

3.13 僞終端

大家可能聽說過終端、虛擬終端、控制檯、終端模擬器、僞終端等這些詞。估計大家和我一樣也是對這些詞一頭霧水,理不清它們到底是什麼意思,相互之間是什麼關係。其實我對虛擬終端和控制檯也不太理解,但是對終端、終端模擬器、僞終端還是比較瞭解的,在這裏給大家講解一下。最早的時候,一臺電腦還是一臺幾間房子那麼大的大型機,普通人根本買不起,有些大學或者科研單位或者政府機關也只能買得起一臺。然後是大家每人買一個終端連接到這臺電腦就可以使用了。終端就是一臺顯示器加一個鍵盤,只不過這個顯示器並不是像素顯示器,而是字符顯示器,一屏只能顯示 80x25 的字符。當時的程序也都是命令行程序,從終端接收輸入,再把結果輸出到終端。具體到程序內部來說,fd 0 對應的就是終端輸入,fd 1 就是終端輸出。終端並不是說鍵盤輸入的是什麼它就原封不動地傳給程序,而是會做一定的預處理。

後來隨着技術的不斷髮展,計算機就變成了我們今天使用的計算機。每個人都可以買一臺獨立的電腦了,而且顯示器也變成了像素顯示器了,可以顯示豐富的畫面。而且很多程序的模式也從命令行模式轉變成了 GUI 模式。但是仍然有很多程序比較適合在命令行執行,仍然保留了命令行模式。爲此係統開發了一個 GUI 程序,叫做終端模擬器,也是我們平常說的命令行界面或者終端程序。它利用圖形界面模擬了之前的終端界面,讓我們看起來像是在使用終端,但是它本身是一個 GUI 程序。終端模擬器是怎麼運行命令行程序的呢?它會使用系統的接口創建一個僞終端,僞終端分爲主端和從端兩部分,模擬器自己拿主端,命令行程序拿從端,這樣命令行程序彷彿就像運行在終端環境裏一樣。我們從鍵盤輸入的字符其實是先按照 GUI 程序的邏輯傳遞給了終端模擬器,終端模擬器再把輸入傳遞給僞終端的主端,然後僞終端在內核裏按照終端本身的邏輯進行處理,再發給僞終端從端,這樣我們的命令行程序纔會收到輸入。命令行程序的輸出先發給僞終端從端,然後再進入內核裏的僞終端,然後再發給僞終端主端,然後終端模擬器才收到我們的輸出,然後它再按照 GUI 程序的方法把輸出繪製到它的窗口上,我們就看到了程序的輸出。所以說僞終端可以看做是終端模擬器和命令行程序之間的進程間通信機制。

僞終端的具體實現細節還是很複雜的,想詳細瞭解的朋友可以去看《The Linux Programming Interface》的第 64 章內容。

四、總結回顧

本文中我們先分析了進程間通信的本質,然後講解了進程間通信的基本框架,最後簡單介紹了 Linux 系統中存在的各種進程間通信機制。大家在實際的工作過程中可以根據自己的需求來選擇使用哪種進程間通信機制。

參考文獻:

《Understanding the Linux Kernel》
《Professional Linux Kernel Architecture》
《The Linux Programming Interface》

https://www.kernel.org/doc/html/latest/driver-api/dma-buf.html

https://lwn.net/Articles/473668/
https://lwn.net/Articles/454389/
https://lwn.net/Articles/474819/
https://elinux.org/images/a/a8/DMA_Buffer_Sharing-_An_Introduction.pdf
https://blog.csdn.net/hexiaolong2009/article/details/102596744

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/fwenDxVOjW7eYO8hFBHICg