涼了!張三同學沒答好「進程間通訊」,被面試官掛了….


前言

開場小故事

炎炎夏日,張三騎著單車去面試花了 1 小時,一路上汗流浹背。

結果面試過程只花了 5 分鐘就結束了,面完的時候,天還是依然是亮的,還得在烈日下奔波 1 小時回去。

面試五分鐘,騎車兩小時。

你看,張三因面試沒準備好,吹空調的時間只有 5 分鐘,來迴路上花了 2 小時曬太陽,你說慘不慘?

所以啊,炎炎夏日,為了能延長吹空調的時間,我們應該在面試前準備得更充分些,吹空調時間是要自己爭取的。

很明顯,在這一場面試中, 張三在進程間通訊這一塊沒複習好,雖然列出了進程間通訊的方式,但這只是表面功夫,應該需要進一步了解每種通訊方式的優缺點及應用場景。

說真的,我們這次一起幫張三一起複習下,加深他對進程間通訊的理解,好讓他下次吹空調的時間能長一點。


正文

每個進程的用戶地址空間都是獨立的,一般而言是不能互相訪問的,但內核空間是每個進程都共享的,所以進程之間要通訊必須通過內核。

Linux 內核提供了不少進程間通訊的機制,我們來一起瞧瞧有哪些?

管道

如果你學過 Linux 命令,那你肯定很熟悉「|」這個豎線。

$ ps auxf | grep mysql

上面命令行里的「|」豎線就是一個管道,它的功能是將前一個命令(ps auxf)的輸出,作為後一個命令(grep mysql)的輸入,從這功能描述,可以看出管道傳輸數據是單向的,如果想相互通訊,我們需要創建兩個管道才行。

同時,我們得知上面這種管道是沒有名字,所以「|」表示的管道稱為匿名管道,用完了就銷毀。

管道還有另外一個類型是命名管道,也被叫做 FIFO,因為數據是先進先出的傳輸方式。

在使用命名管道前,先需要通過 mkfifo 命令來創建,並且指定管道名字:

$ mkfifo myPipe

myPipe 就是這個管道的名稱,基於 Linux 一切皆文件的理念,所以管道也是以文件的方式存在,我們可以用 ls 看一下,這個文件的類型是 p,也就是 pipe(管道) 的意思:

$ ls -l
prw-r--r--. 1 root    root         0 Jul 17 02:45 myPipe

接下來,我們往 myPipe 這個管道寫入數據:

echo "hello" > myPipe  // 將數據寫進管道
                         // 停住了 ...

你操作了後,你會發現命令執行後就停在這了,這是因為管道里的內容沒有被讀取,只有當管道里的數據被讀完後,命令才可以正常退出。

於是,我們執行另外一個命令來讀取這個管道里的數據:

$ cat < myPipe  // 讀取管道里的數據
hello

可以看到,管道里的內容被讀取出來了,並列印在了終端上,另外一方面,echo 那個命令也正常退出了。

我們可以看出,管道這種通訊方式效率低,不適合進程間頻繁地交換數據。當然,它的好處,自然就是簡單,同時也我們很容易得知管道里的數據已經被另一個進程讀取了。

那管道如何創建呢,背後原理是什麼?

匿名管道的創建,需要通過下面這個系統調用:

int pipe(int fd[2])

這裡表示創建一個匿名管道,並返回了兩個描述符,一個是管道的讀取端描述符 fd[0],另一個是管道的寫入端描述符 fd[1]。注意,這個匿名管道是特殊的文件,只存在於記憶體,不存於文件系統中。

其實,所謂的管道,就是內核裡面的一串快取。從管道的一段寫入的數據,實際上是快取在內核中的,另一端讀取,也就是從內核中讀取這段數據。另外,管道傳輸的數據是無格式的流且大小受限。

看到這,你可能會有疑問了,這兩個描述符都是在一個進程裡面,並沒有起到進程間通訊的作用,怎麼樣才能使得管道是跨過兩個進程的呢?

我們可以使用 fork 創建子進程,創建的子進程會複製父進程的文件描述符,這樣就做到了兩個進程各有兩個「 fd[0]fd[1]」,兩個進程就可以通過各自的 fd 寫入和讀取同一個管道文件實現跨進程通訊了。

管道只能一端寫入,另一端讀出,所以上面這種模式容易造成混亂,因為父進程和子進程都可以同時寫入,也都可以讀出。那麼,為了避免這種情況,通常的做法是:

  • 父進程關閉讀取的 fd[0],只保留寫入的 fd[1];
  • 子進程關閉寫入的 fd[1],只保留讀取的 fd[0];

所以說如果需要雙向通訊,則應該創建兩個管道。

到這裡,我們僅僅解析了使用管道進行父進程與子進程之間的通訊,但是在我們 shell 裡面並不是這樣的。

在 shell 裡面執行 A | B命令的時候,A 進程和 B 進程都是 shell 創建出來的子進程,A 和 B 之間不存在父子關係,它倆的父進程都是 shell。

所以說,在 shell 里通過「|」匿名管道將多個命令連接在一起,實際上也就是創建了多個子進程,那麼在我們編寫 shell 腳本時,能使用一個管道搞定的事情,就不要多用一個管道,這樣可以減少創建子進程的系統開銷。

我們可以得知,對於匿名管道,它的通訊範圍是存在父子關係的進程。因為管道沒有實體,也就是沒有管道文件,只能通過 fork 來複制父進程 fd 文件描述符,來達到通訊的目的。

另外,對於命名管道,它可以在不相關的進程間也能相互通訊。因為命令管道,提前創建了一個類型為管道的設備文件,在進程里只要使用這個設備文件,就可以相互通訊。

不管是匿名管道還是命名管道,進程寫入的數據都是快取在內核中,另一個進程讀取數據時候自然也是從內核中獲取,同時通訊數據都遵循先進先出原則,不支援 lseek 之類的文件定位操作。


消息隊列

前面說到管道的通訊方式是效率低的,因此管道不適合進程間頻繁地交換數據。

對於這個問題,消息隊列的通訊模式就可以解決。比如,A 進程要給 B 進程發送消息,A 進程把數據放在對應的消息隊列後就可以正常返回了,B 進程需要的時候再去讀取數據就可以了。同理,B 進程要給 A 進程發送消息也是如此。

再來,消息隊列是保存在內核中的消息鏈表,在發送數據時,會分成一個一個獨立的數據單元,也就是消息體(數據塊),消息體是用戶自定義的數據類型,消息的發送方和接收方要約定好消息體的數據類型,所以每個消息體都是固定大小的存儲塊,不像管道是無格式的位元組流數據。如果進程從消息隊列中讀取了消息體,內核就會把這個消息體刪除。

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

消息這種模型,兩個進程之間的通訊就像平時發郵件一樣,你來一封,我回一封,可以頻繁溝通了。

但郵件的通訊方式存在不足的地方有兩點,一是通訊不及時,二是附件也有大小限制,這同樣也是消息隊列通訊不足的點。

消息隊列不適合比較大數據的傳輸,因為在內核中每個消息體都有一個最大長度的限制,同時所有隊列所包含的全部消息體的總長度也是有上限。在 Linux 內核中,會有兩個宏定義 MSGMAXMSGMNB,它們以位元組為單位,分別定義了一條消息的最大長度和一個隊列的最大長度。

消息隊列通訊過程中,存在用戶態與內核態之間的數據拷貝開銷,因為進程寫入數據到內核中的消息隊列時,會發生從用戶態拷貝數據到內核態的過程,同理另一進程讀取內核中的消息數據時,會發生從內核態拷貝數據到用戶態的過程。


共享記憶體

消息隊列的讀取和寫入的過程,都會有發生用戶態與內核態之間的消息拷貝過程。那共享記憶體的方式,就很好的解決了這一問題。

現代作業系統,對於記憶體管理,採用的是虛擬記憶體技術,也就是每個進程都有自己獨立的虛擬記憶體空間,不同進程的虛擬記憶體映射到不同的物理記憶體中。所以,即使進程 A 和 進程 B 的虛擬地址是一樣的,其實訪問的是不同的物理記憶體地址,對於數據的增刪查改互不影響。

共享記憶體的機制,就是拿出一塊虛擬地址空間來,映射到相同的物理記憶體中。這樣這個進程寫入的東西,另外一個進程馬上就能看到了,都不需要拷貝來拷貝去,傳來傳去,大大提高了進程間通訊的速度。


訊號量

用了共享記憶體通訊方式,帶來新的問題,那就是如果多個進程同時修改同一個共享記憶體,很有可能就衝突了。例如兩個進程都同時寫一個地址,那先寫的那個進程會發現內容被別人覆蓋了。

為了防止多進程競爭共享資源,而造成的數據錯亂,所以需要保護機制,使得共享的資源,在任意時刻只能被一個進程訪問。正好,訊號量就實現了這一保護機制。

訊號量其實是一個整型的計數器,主要用於實現進程間的互斥與同步,而不是用於快取進程間通訊的數據

訊號量表示資源的數量,控制訊號量的方式有兩種原子操作:

  • 一個是 P 操作,這個操作會把訊號量減去 -1,相減後如果訊號量 < 0,則表明資源已被佔用,進程需阻塞等待;相減後如果訊號量 >= 0,則表明還有資源可使用,進程可正常繼續執行。
  • 另一個是 V 操作,這個操作會把訊號量加上 1,相加後如果訊號量 <= 0,則表明當前有阻塞中的進程,於是會將該進程喚醒運行;相加後如果訊號量 > 0,則表明當前沒有阻塞中的進程;

P 操作是用在進入共享資源之前,V 操作是用在離開共享資源之後,這兩個操作是必須成對出現的。

接下來,舉個例子,如果要使得兩個進程互斥訪問共享記憶體,我們可以初始化訊號量為 1

具體的過程如下:

  • 進程 A 在訪問共享記憶體前,先執行了 P 操作,由於訊號量的初始值為 1,故在進程 A 執行 P 操作後訊號量變為 0,表示共享資源可用,於是進程 A 就可以訪問共享記憶體。
  • 若此時,進程 B 也想訪問共享記憶體,執行了 P 操作,結果訊號量變為了 -1,這就意味著臨界資源已被佔用,因此進程 B 被阻塞。
  • 直到進程 A 訪問完共享記憶體,才會執行 V 操作,使得訊號量恢復為 0,接著就會喚醒阻塞中的執行緒 B,使得進程 B 可以訪問共享記憶體,最後完成共享記憶體的訪問後,執行 V 操作,使訊號量恢復到初始值 1。

可以發現,訊號初始化為 1,就代表著是互斥訊號量,它可以保證共享記憶體在任何時刻只有一個進程在訪問,這就很好的保護了共享記憶體。

另外,在多進程里,每個進程並不一定是順序執行的,它們基本是以各自獨立的、不可預知的速度向前推進,但有時候我們又希望多個進程能密切合作,以實現一個共同的任務。

例如,進程 A 是負責生產數據,而進程 B 是負責讀取數據,這兩個進程是相互合作、相互依賴的,進程 A 必須先生產了數據,進程 B 才能讀取到數據,所以執行是有前後順序的。

那麼這時候,就可以用訊號量來實現多進程同步的方式,我們可以初始化訊號量為 0

具體過程:

  • 如果進程 B 比進程 A 先執行了,那麼執行到 P 操作時,由於訊號量初始值為 0,故訊號量會變為 -1,表示進程 A 還沒生產數據,於是進程 B 就阻塞等待;
  • 接著,當進程 A 生產完數據後,執行了 V 操作,就會使得訊號量變為 0,於是就會喚醒阻塞在 P 操作的進程 B;
  • 最後,進程 B 被喚醒後,意味著進程 A 已經生產了數據,於是進程 B 就可以正常讀取數據了。

可以發現,訊號初始化為 0,就代表著是同步訊號量,它可以保證進程 A 應在進程 B 之前執行。


訊號

上面說的進程間通訊,都是常規狀態下的工作模式。對於異常情況下的工作模式,就需要用「訊號」的方式來通知進程。

訊號跟訊號量雖然名字相似度 66.66%,但兩者用途完全不一樣,就好像 Java 和 JavaScript 的區別。

在 Linux 作業系統中, 為了響應各種各樣的事件,提供了幾十種訊號,分別代表不同的意義。我們可以通過 kill -l 命令,查看所有的訊號:

$ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

運行在 shell 終端的進程,我們可以通過鍵盤輸入某些組合鍵的時候,給進程發送訊號。例如

  • Ctrl+C 產生 SIGINT 訊號,表示終止該進程;
  • Ctrl+Z 產生 SIGTSTP 訊號,表示停止該進程,但還未結束;

如果進程在後台運行,可以通過 kill 命令的方式給進程發送訊號,但前提需要知道運行中的進程 PID 號,例如:

  • kill -9 1050 ,表示給 PID 為 1050 的進程發送 SIGKILL 訊號,用來立即結束該進程;

所以,訊號事件的來源主要有硬體來源(如鍵盤 Cltr+C )和軟體來源(如 kill 命令)。

訊號是進程間通訊機制中唯一的非同步通訊機制,因為可以在任何時候發送訊號給某一進程,一旦有訊號產生,我們就有下面這幾種,用戶進程對訊號的處理方式。

1.執行默認操作。Linux 對每種訊號都規定了默認操作,例如,上面列表中的 SIGTERM 訊號,就是終止進程的意思。Core 的意思是 Core Dump,也即終止進程後,通過 Core Dump 將當前進程的運行狀態保存在文件裡面,方便程式設計師事後進行分析問題在哪裡。

2.捕捉訊號。我們可以為訊號定義一個訊號處理函數。當訊號發生時,我們就執行相應的訊號處理函數。

3.忽略訊號。當我們不希望處理某些訊號的時候,就可以忽略該訊號,不做任何處理。有兩個訊號是應用進程無法捕捉和忽略的,即 SIGKILLSEGSTOP,它們用於在任何時候中斷或結束某一進程。


Socket

前面提到的管道、消息隊列、共享記憶體、訊號量和訊號都是在同一台主機上進行進程間通訊,那要想跨網路與不同主機上的進程之間通訊,就需要 Socket 通訊了。

實際上,Socket 通訊不僅可以跨網路與不同主機的進程間通訊,還可以在同主機上進程間通訊。

我們來看看創建 socket 的系統調用:

int socket(int domain, int type, int protocal)

三個參數分別代表:

  • domain 參數用來指定協議族,比如 AF_INET 用於 IPV4、AF_INET6 用於 IPV6、AF_LOCAL/AF_UNIX 用於本機;
  • type 參數用來指定通訊特性,比如 SOCK_STREAM 表示的是位元組流,對應 TCP、SOCK_DGRAM 表示的是數據報,對應 UDP、SOCK_RAW 表示的是原始套接字;
  • protocal 參數原本是用來指定通訊協議的,但現在基本廢棄。因為協議已經通過前面兩個參數指定完成,protocol 目前一般寫成 0 即可;

根據創建 socket 類型的不同,通訊的方式也就不同:

  • 實現 TCP 位元組流通訊: socket 類型是 AF_INET 和 SOCK_STREAM;
  • 實現 UDP 數據報通訊:socket 類型是 AF_INET 和 SOCK_DGRAM;
  • 實現本地進程間通訊: 「本地位元組流 socket 」類型是 AF_LOCAL 和 SOCK_STREAM,「本地數據報 socket 」類型是 AF_LOCAL 和 SOCK_DGRAM。另外,AF_UNIX 和 AF_LOCAL 是等價的,所以 AF_UNIX 也屬於本地 socket;

接下來,簡單說一下這三種通訊的編程模式。

針對 TCP 協議通訊的 socket 編程模型

  • 服務端和客戶端初始化 socket,得到文件描述符;
  • 服務端調用 bind,將綁定在 IP 地址和埠;
  • 服務端調用 listen,進行監聽;
  • 服務端調用 accept,等待客戶端連接;
  • 客戶端調用 connect,向伺服器端的地址和埠發起連接請求;
  • 服務端 accept 返回用於傳輸的 socket 的文件描述符;
  • 客戶端調用 write 寫入數據;服務端調用 read 讀取數據;
  • 客戶端斷開連接時,會調用 close,那麼服務端 read 讀取數據的時候,就會讀取到了 EOF,待處理完數據後,服務端調用 close,表示連接關閉。

這裡需要注意的是,服務端調用 accept 時,連接成功了會返回一個已完成連接的 socket,後續用來傳輸數據。

所以,監聽的 socket 和真正用來傳送數據的 socket,是「兩個」 socket,一個叫作監聽 socket,一個叫作已完成連接 socket

成功連接建立之後,雙方開始通過 read 和 write 函數來讀寫數據,就像往一個文件流裡面寫東西一樣。

針對 UDP 協議通訊的 socket 編程模型

UDP 是沒有連接的,所以不需要三次握手,也就不需要像 TCP 調用 listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和埠號,因此也需要 bind。

對於 UDP 來說,不需要要維護連接,那麼也就沒有所謂的發送方和接收方,甚至都不存在客戶端和服務端的概念,只要有一個 socket 多台機器就可以任意通訊,因此每一個 UDP 的 socket 都需要 bind。

另外,每次通訊時,調用 sendto 和 recvfrom,都要傳入目標主機的 IP 地址和埠。

針對本地進程間通訊的 socket 編程模型

本地 socket 被用於在同一台主機上進程間通訊的場景:

  • 本地 socket 的編程介面和 IPv4 、IPv6 套接字編程介面是一致的,可以支援「位元組流」和「數據報」兩種協議;
  • 本地 socket 的實現效率大大高於 IPv4 和 IPv6 的位元組流、數據報 socket 實現;

對於本地位元組流 socket,其 socket 類型是 AF_LOCAL 和 SOCK_STREAM。

對於本地數據報 socket,其 socket 類型是 AF_LOCAL 和 SOCK_DGRAM。

本地位元組流 socket 和 本地數據報 socket 在 bind 的時候,不像 TCP 和 UDP 要綁定 IP 地址和埠,而是綁定一個本地文件,這也就是它們之間的最大區別。


總結

由於每個進程的用戶空間都是獨立的,不能相互訪問,這時就需要藉助內核空間來實現進程間通訊,原因很簡單,每個進程都是共享一個內核空間。

Linux 內核提供了不少進程間通訊的方式,其中最簡單的方式就是管道,管道分為「匿名管道」和「命名管道」。

匿名管道顧名思義,它沒有名字標識,匿名管道是特殊文件只存在於記憶體,沒有存在於文件系統中,shell 命令中的「|」豎線就是匿名管道,通訊的數據是無格式的流並且大小受限,通訊的方式是單向的,數據只能在一個方向上流動,如果要雙向通訊,需要創建兩個管道,再來匿名管道是只能用於存在父子關係的進程間通訊,匿名管道的生命周期隨著進程創建而建立,隨著進程終止而消失。

命名管道突破了匿名管道只能在親緣關係進程間的通訊限制,因為使用命名管道的前提,需要在文件系統創建一個類型為 p 的設備文件,那麼毫無關係的進程就可以通過這個設備文件進行通訊。另外,不管是匿名管道還是命名管道,進程寫入的數據都是快取在內核中,另一個進程讀取數據時候自然也是從內核中獲取,同時通訊數據都遵循先進先出原則,不支援 lseek 之類的文件定位操作。

消息隊列克服了管道通訊的數據是無格式的位元組流的問題,消息隊列實際上是保存在內核的「消息鏈表」,消息隊列的消息體是可以用戶自定義的數據類型,發送數據時,會被分成一個一個獨立的消息體,當然接收數據時,也要與發送方發送的消息體的數據類型保持一致,這樣才能保證讀取的數據是正確的。消息隊列通訊的速度不是最及時的,畢竟每次數據的寫入和讀取都需要經過用戶態與內核態之間的拷貝過程。

共享記憶體可以解決消息隊列通訊中用戶態與內核態之間數據拷貝過程帶來的開銷,它直接分配一個共享空間,每個進程都可以直接訪問,就像訪問進程自己的空間一樣快捷方便,不需要陷入內核態或者系統調用,大大提高了通訊的速度,享有最快的進程間通訊方式之名。但是便捷高效的共享記憶體通訊,帶來新的問題,多進程競爭同個共享資源會造成數據的錯亂。

那麼,就需要訊號量來保護共享資源,以確保任何時刻只能有一個進程訪問共享資源,這種方式就是互斥訪問。訊號量不僅可以實現訪問的互斥性,還可以實現進程間的同步,訊號量其實是一個計數器,表示的是資源個數,其值可以通過兩個原子操作來控制,分別是 P 操作和 V 操作

與訊號量名字很相似的叫訊號,它倆名字雖然相似,但功能一點兒都不一樣。訊號是進程間通訊機制中唯一的非同步通訊機制,訊號可以在應用進程和內核之間直接交互,內核也可以利用訊號來通知用戶空間的進程發生了哪些系統事件,訊號事件的來源主要有硬體來源(如鍵盤 Cltr+C )和軟體來源(如 kill 命令),一旦有訊號發生,進程有三種方式響應訊號 1. 執行默認操作、2. 捕捉訊號、3. 忽略訊號。有兩個訊號是應用進程無法捕捉和忽略的,即 SIGKILLSEGSTOP,這是為了方便我們能在任何時候結束或停止某個進程。

前面說到的通訊機制,都是工作於同一台主機,如果要與不同主機的進程間通訊,那麼就需要 Socket 通訊了。Socket 實際上不僅用於不同的主機進程間通訊,還可以用於本地主機進程間通訊,可根據創建 Socket 的類型不同,分為三種常見的通訊方式,一個是基於 TCP 協議的通訊方式,一個是基於 UDP 協議的通訊方式,一個是本地進程間通訊方式。

以上,就是進程間通訊的主要機制了。你可能會問了,那執行緒通訊間的方式呢?

同個進程下的執行緒之間都是共享進程的資源,只要是共享變數都可以做到執行緒間通訊,比如全局變數,所以對於執行緒間關注的不是通訊方式,而是關注多執行緒競爭共享資源的問題,訊號量也同樣可以在執行緒間實現互斥與同步:

  • 互斥的方式,可保證任意時刻只有一個執行緒訪問共享資源;
  • 同步的方式,可保證執行緒 A 應在執行緒 B 之前執行;

好了,今日幫張三同學複習就到這了,希望張三同學早日收到心意的 offer,給夏天划上充滿汗水的句號。


好文推薦

「進程和執行緒」基礎知識全家桶,30 張圖一套帶走

20 張圖揭開「記憶體管理」的迷霧,瞬間豁然開朗

30 張圖帶你走進作業系統的「互斥與同步」