1. 程式人生 > 實用技巧 >涼了!張三同學沒答好「程序間通訊」,被面試官掛了....

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


前言

開場小故事

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

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

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

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

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

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

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


正文

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

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

管道

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

$psauxf|grepmysql

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

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

,用完了就銷燬。

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

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

$mkfifomyPipe

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

$ls-l
prw-r--r--.1rootroot0Jul1702:45myPipe

接下來,我們往 myPipe 這個管道寫入資料:

$echo"hello">myPipe//將資料寫進管道
//停住了...

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

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

$cat<myPipe//讀取管道里的資料
hello

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

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

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

匿名管道的建立,需要通過下面這個系統呼叫:

intpipe(intfd[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)SIGHUP2)SIGINT3)SIGQUIT4)SIGILL5)SIGTRAP
6)SIGABRT7)SIGBUS8)SIGFPE9)SIGKILL10)SIGUSR1
11)SIGSEGV12)SIGUSR213)SIGPIPE14)SIGALRM15)SIGTERM
16)SIGSTKFLT17)SIGCHLD18)SIGCONT19)SIGSTOP20)SIGTSTP
21)SIGTTIN22)SIGTTOU23)SIGURG24)SIGXCPU25)SIGXFSZ
26)SIGVTALRM27)SIGPROF28)SIGWINCH29)SIGIO30)SIGPWR
31)SIGSYS34)SIGRTMIN35)SIGRTMIN+136)SIGRTMIN+237)SIGRTMIN+3
38)SIGRTMIN+439)SIGRTMIN+540)SIGRTMIN+641)SIGRTMIN+742)SIGRTMIN+8
43)SIGRTMIN+944)SIGRTMIN+1045)SIGRTMIN+1146)SIGRTMIN+1247)SIGRTMIN+13
48)SIGRTMIN+1449)SIGRTMIN+1550)SIGRTMAX-1451)SIGRTMAX-1352)SIGRTMAX-12
53)SIGRTMAX-1154)SIGRTMAX-1055)SIGRTMAX-956)SIGRTMAX-857)SIGRTMAX-7
58)SIGRTMAX-659)SIGRTMAX-560)SIGRTMAX-461)SIGRTMAX-362)SIGRTMAX-2
63)SIGRTMAX-164)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 的系統呼叫:

intsocket(intdomain,inttype,intprotocal)

三個引數分別代表:

  • 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 張圖帶你走進作業系統的「互斥與同步」