作者簡介:
程磊,某手機大廠系統開發工程師,閱碼場榮譽總編輯,最大的愛好是鉆研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庫。
3.12 信號機制
信號機制是在UNIX里面很早就存在的機制,它是內核用來處理程序運行時發生錯誤的一種方法,也是給進程發送一些簡單特定的消息的方法,所以也可以看做是一種進程間通信機制。但是它又比較特殊,它和一般的進程間通信機制的結構都不太相同。它是不需要建立通信信道的,因為它不是典型的進程間通信,或者說它的通信信道是天然建立好的,因為它用的是pid來指定消息傳遞給誰。它的發送是內核發送或者進程通過kill等接口發送,指定pid就能發送給對方。對方可以設置信號處理函數來接收處理信號,也可以不設置,內核會進行默認處理。信號機制的具體細節請參看《深入理解Linux信號機制》。
3.13 偽終端
大家可能聽說過終端、虛擬終端、控制臺、終端模擬器、偽終端等這些詞。估計大家和我一樣也是對這些詞一頭霧水,理不清它們到底是什么意思,相互之間是什么關系。其實我對虛擬終端和控制臺也不太理解,但是對終端、終端模擬器、偽終端還是比較了解的,在這里給大家講解一下。最早的時候,一臺電腦還是一臺幾間房子那么大的大型機,普通人根本買不起,有些大學或者科研單位或者政府機關也只能買得起一臺。然后是大家每人買一個終端連接到這臺電腦就可以使用了。終端就是一臺顯示器加一個鍵盤,只不過這個顯示器并不是像素顯示器,而是字符顯示器,一屏只能顯示80x25的字符。當時的程序也都是命令行程序,從終端接收輸入,再把結果輸出到終端。具體到程序內部來說,fd 0 對應的就是終端輸入,fd 1就是終端輸出。終端并不是說鍵盤輸入的是什么它就原封不動地傳給程序,而是會做一定的預處理。
后來隨著技術的不斷發展,計算機就變成了我們今天使用的計算機。每個人都可以買一臺獨立的電腦了,而且顯示器也變成了像素顯示器了,可以顯示豐富的畫面。而且很多程序的模式也從命令行模式轉變成了GUI模式。但是仍然有很多程序比較適合在命令行執行,仍然保留了命令行模式。為此系統開發了一個GUI程序,叫做終端模擬器,也是我們平常說的命令行界面或者終端程序。它利用圖形界面模擬了之前的終端界面,讓我們看起來像是在使用終端,但是它本身是一個GUI程序。終端模擬器是怎么運行命令行程序的呢?它會使用系統的接口創建一個偽終端,偽終端分為主端和從端兩部分,模擬器自己拿主端,命令行程序拿從端,這樣命令行程序仿佛就像運行在終端環境里一樣。我們從鍵盤輸入的字符其實是先按照GUI程序的邏輯傳遞給了終端模擬器,終端模擬器再把輸入傳遞給偽終端的主端,然后偽終端在內核里按照終端本身的邏輯進行處理,再發給偽終端從端,這樣我們的命令行程序才會收到輸入。命令行程序的輸出先發給偽終端從端,然后再進入內核里的偽終端,然后再發給偽終端主端,然后終端模擬器才收到我們的輸出,然后它再按照GUI程序的方法把輸出繪制到它的窗口上,我們就看到了程序的輸出。所以說偽終端可以看做是終端模擬器和命令行程序之間的進程間通信機制。
?四、總結回顧 ? ? 本文中我們先分析了進程間通信的本質,然后講解了進程間通信的基本框架,最后簡單介紹了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
編輯:配合費
?
評論
查看更多