Linux 下的進程間通訊:通訊端和信號

2019-06-02 23:44:00

學習在 Linux 中進程是如何與其他進程進行同步的。

本篇是 Linux 下進程間通訊(IPC)系列的第三篇同時也是最後一篇文章。聚焦在通過共用儲存(檔案和共用記憶體段)來進行 IPC,則通過管道(無名的或者命名的)及訊息佇列來達到相同的目的。這篇文章將目光從高處(通訊端)然後到低處(信號)來關注 IPC。程式碼範例將用力地充實下面的解釋細節。

通訊端

正如管道有兩種型別(命名和無名)一樣,通訊端也有兩種型別。IPC 通訊端(即 Unix 通訊端)給予進程在相同裝置(主機)上基於通道的通訊能力;而網路通訊端給予進程執行在不同主機的能力,因此也帶來了網路通訊的能力。網路通訊端需要底層協定的支援,例如 TCP(傳輸控制協定)或 UDP(使用者資料包協定)。

與之相反,IPC 通訊端依賴於本地系統核心的支援來進行通訊;特別的,IPC 通訊使用一個原生的檔案作為通訊端地址。儘管這兩種通訊端的實現有所不同,但在本質上,IPC 通訊端和網路通訊端的 API 是一致的。接下來的例子將包含網路通訊端的內容,但範例伺服器和用戶端程式可以在相同的機器上執行,因為伺服器使用了 localhost(127.0.0.1)這個網路地址,該地址表示的是本地機器上的本地機器地址。

通訊端以流的形式(下面將會討論到)被設定為雙向的,並且其控制遵循 C/S(用戶端/伺服器端)模式:用戶端通過嘗試連線一個伺服器來初始化對話,而伺服器端將嘗試接受該連線。假如萬事順利,來自用戶端的請求和來自伺服器端的響應將通過管道進行傳輸,直到其中任意一方關閉該通道,從而斷開這個連線。

一個疊代伺服器(只適用於開發)將一直和連線它的用戶端打交道:從最開始服務第一個用戶端,然後到這個連線關閉,然後服務第二個用戶端,迴圈往復。這種方式的一個缺點是處理一個特定的用戶端可能會掛起,使得其他的用戶端一直在後面等待。生產級別的伺服器將是並行的,通常使用了多進程或者多執行緒的混合。例如,我桌上型電腦上的 Nginx 網路伺服器有一個 4 個工人worker的進程池,它們可以並行地處理用戶端的請求。在下面的程式碼範例中,我們將使用疊代伺服器,使得我們將要處理的問題保持在一個很小的規模,只關注基本的 API,而不去關心並行的問題。

最後,隨著各種 POSIX 改進的出現,通訊端 API 隨著時間的推移而發生了顯著的變化。當前針對伺服器端和用戶端的範例程式碼特意寫的比較簡單,但是它著重強調了基於流的通訊端中連線的雙方。下面是關於流控制的一個總結,其中伺服器端在一個終端中開啟,而用戶端在另一個不同的終端中開啟:

  • 伺服器端等待用戶端的連線,對於給定的一個成功連線,它就讀取來自用戶端的資料。
  • 為了強調是雙方的對談,伺服器端會對接收自用戶端的資料做回應。這些資料都是 ASCII 字元程式碼,它們組成了一些書的標題。
  • 用戶端將書的標題寫給伺服器端的進程,並從伺服器端的回應中讀取到相同的標題。然後用戶端和伺服器端都在螢幕上列印出標題。下面是伺服器端的輸出,用戶端的輸出也和它完全一樣:
Listening on port 9876 for clients...War and PeacePride and PrejudiceThe Sound and the Fury

範例 1. 使用通訊端的用戶端程式

#include <string.h>#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <sys/types.h>#include <sys/socket.h>#include <netinet/tcp.h>#include <arpa/inet.h>#include "sock.h"void report(const char* msg, int terminate) {  perror(msg);  if (terminate) exit(-1); /* failure */}int main() {  int fd = socket(AF_INET,     /* network versus AF_LOCAL */          SOCK_STREAM, /* reliable, bidirectional: TCP */          0);          /* system picks underlying protocol */  if (fd < 0) report("socket", 1); /* terminate */      /* bind the server's local address in memory */  struct sockaddr_in saddr;  memset(&saddr, 0, sizeof(saddr));          /* clear the bytes */  saddr.sin_family = AF_INET;                /* versus AF_LOCAL */  saddr.sin_addr.s_addr = htonl(INADDR_ANY); /* host-to-network endian */  saddr.sin_port = htons(PortNumber);        /* for listening */    if (bind(fd, (struct sockaddr *) &saddr, sizeof(saddr)) < 0)    report("bind", 1); /* terminate */      /* listen to the socket */  if (listen(fd, MaxConnects) < 0) /* listen for clients, up to MaxConnects */    report("listen", 1); /* terminate */  fprintf(stderr, "Listening on port %i for clients...\n", PortNumber);  /* a server traditionally listens indefinitely */  while (1) {    struct sockaddr_in caddr; /* client address */    int len = sizeof(caddr);  /* address length could change */        int client_fd = accept(fd, (struct sockaddr*) &caddr, &len);  /* accept blocks */    if (client_fd < 0) {      report("accept", 0); /* don't terminated, though there's a problem */      continue;    }    /* read from client */    int i;    for (i = 0; i < ConversationLen; i++) {      char buffer[BuffSize + 1];      memset(buffer, '\0', sizeof(buffer));       int count = read(client_fd, buffer, sizeof(buffer));      if (count > 0) {    puts(buffer);    write(client_fd, buffer, sizeof(buffer)); /* echo as confirmation */      }    }    close(client_fd); /* break connection */  }  /* while(1) */  return 0;}

上面的伺服器端程式執行典型的 4 個步驟來準備回應用戶端的請求,然後接受其他的獨立請求。這裡每一個步驟都以伺服器端程式呼叫的系統函數來命名。

  1. socket(…):為通訊端連線獲取一個檔案描述符
  2. bind(…):將通訊端和伺服器主機上的一個地址進行系結
  3. listen(…):監聽用戶端請求
  4. accept(…):接受一個特定的用戶端請求

上面的 socket 呼叫的完整形式為:

int sockfd = socket(AF_INET,      /* versus AF_LOCAL */                    SOCK_STREAM,  /* reliable, bidirectional */                    0);           /* system picks protocol (TCP) */

第一個引數特別指定了使用的是一個網路通訊端,而不是 IPC 通訊端。對於第二個引數有多種選項,但 SOCK_STREAMSOCK_DGRAM(資料包)是最為常用的。基於流的通訊端支援可信通道,在這種通道中如果發生了資訊的丟失或者更改,都將會被報告。這種通道是雙向的,並且從一端到另外一端的有效載荷在大小上可以是任意的。相反的,基於資料包的通訊端大多是不可信的,沒有方向性,並且需要固定大小的載荷。socket 的第三個引數特別指定了協定。對於這裡展示的基於流的通訊端,只有一種協定選擇:TCP,在這裡表示的 0。因為對 socket 的一次成功呼叫將返回相似的檔案描述符,通訊端可以被讀寫,對應的語法和讀寫一個本地檔案是類似的。

bind 的呼叫是最為複雜的,因為它反映出了在通訊端 API 方面上的各種改進。我們感興趣的點是這個呼叫將一個通訊端和伺服器端所在機器中的一個記憶體地址進行系結。但對 listen 的呼叫就非常直接了:

if (listen(fd, MaxConnects) < 0)

第一個引數是通訊端的檔案描述符,第二個引數則指定了在伺服器端處理一個拒絕連線錯誤之前,有多少個用戶端連線被允許連線。(在標頭檔案 sock.hMaxConnects 的值被設定為 8。)

accept 呼叫預設將是一個阻塞等待:伺服器端將不做任何事情直到一個用戶端嘗試連線它,然後進行處理。accept 函數返回的值如果是 -1 則暗示有錯誤發生。假如這個呼叫是成功的,則它將返回另一個檔案描述符,這個檔案描述符被用來指代另一個可讀可寫的通訊端,它與 accept 呼叫中的第一個引數對應的接收通訊端有所不同。伺服器端使用這個可讀可寫的通訊端來從用戶端讀取請求然後寫回它的回應。接收通訊端只被用於接受用戶端的連線。

在設計上,伺服器端可以一直執行下去。當然伺服器端可以通過在命令列中使用 Ctrl+C 來終止它。

範例 2. 使用通訊端的用戶端

#include <string.h>#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <sys/types.h>#include <sys/socket.h>#include <arpa/inet.h>#include <netinet/in.h>#include <netinet/tcp.h>#include <netdb.h>#include "sock.h"const char* books[] = {"War and Peace",               "Pride and Prejudice",               "The Sound and the Fury"};void report(const char* msg, int terminate) {  perror(msg);  if (terminate) exit(-1); /* failure */}int main() {  /* fd for the socket */  int sockfd = socket(AF_INET,      /* versus AF_LOCAL */              SOCK_STREAM,  /* reliable, bidirectional */              0);           /* system picks protocol (TCP) */  if (sockfd < 0) report("socket", 1); /* terminate */  /* get the address of the host */  struct hostent* hptr = gethostbyname(Host); /* localhost: 127.0.0.1 */   if (!hptr) report("gethostbyname", 1); /* is hptr NULL? */  if (hptr->h_addrtype != AF_INET)       /* versus AF_LOCAL */    report("bad address family", 1);    /* connect to the server: configure server's address 1st */  struct sockaddr_in saddr;  memset(&saddr, 0, sizeof(saddr));  saddr.sin_family = AF_INET;  saddr.sin_addr.s_addr =      ((struct in_addr*) hptr->h_addr_list[0])->s_addr;  saddr.sin_port = htons(PortNumber); /* port number in big-endian */    if (connect(sockfd, (struct sockaddr*) &saddr, sizeof(saddr)) < 0)    report("connect", 1);    /* Write some stuff and read the echoes. */  puts("Connect to server, about to write some stuff...");  int i;  for (i = 0; i < ConversationLen; i++) {    if (write(sockfd, books[i], strlen(books[i])) > 0) {      /* get confirmation echoed from server and print */      char buffer[BuffSize + 1];      memset(buffer, '\0', sizeof(buffer));      if (read(sockfd, buffer, sizeof(buffer)) > 0)    puts(buffer);    }  }  puts("Client done, about to exit...");  close(sockfd); /* close the connection */  return 0;}

用戶端程式的設定程式碼和伺服器端類似。兩者主要的區別既不是在於監聽也不在於接收,而是連線:

if (connect(sockfd, (struct sockaddr*) &saddr, sizeof(saddr)) < 0)

connect 的呼叫可能因為多種原因而導致失敗,例如用戶端擁有錯誤的伺服器端地址或者已經有太多的用戶端連線上了伺服器端。假如 connect 操作成功,用戶端將在一個 for 迴圈中,寫入它的請求然後讀取返回的響應。在對談後,伺服器端和用戶端都將呼叫 close 去關閉這個可讀可寫通訊端,儘管任何一邊的關閉操作就足以關閉它們之間的連線。此後用戶端可以退出了,但正如前面提到的那樣,伺服器端可以一直保持開放以處理其他事務。

從上面的通訊端範例中,我們看到了請求資訊被回顯給用戶端,這使得用戶端和伺服器端之間擁有進行豐富對話的可能性。也許這就是通訊端的主要魅力。在現代系統中,用戶端應用(例如一個資料庫用戶端)和伺服器端通過通訊端進行通訊非常常見。正如先前提及的那樣,本地 IPC 通訊端和網路通訊端只在某些實現細節上面有所不同,一般來說,IPC 通訊端有著更低的消耗和更好的效能。它們的通訊 API 基本是一樣的。

信號

信號會中斷一個正在執行的程式,在這種意義下,就是用信號與這個程式進行通訊。大多數的信號要麼可以被忽略(阻塞)或者被處理(通過特別設計的程式碼)。SIGSTOP (暫停)和 SIGKILL(立即停止)是最應該提及的兩種信號。這種符號常數有整數型別的值,例如 SIGKILL 對應的值為 9

信號可以在與使用者互動的情況下發生。例如,一個使用者從命令列中敲了 Ctrl+C 來終止一個從命令列中啟動的程式;Ctrl+C 將產生一個 SIGTERM 信號。SIGTERM 意即終止,它可以被阻塞或者被處理,而不像 SIGKILL 信號那樣。一個進程也可以通過信號和另一個進程通訊,這樣使得信號也可以作為一種 IPC 機制。

考慮一下一個多進程應用,例如 Nginx 網路伺服器是如何被另一個進程優雅地關閉的。kill 函數:

int kill(pid_t pid, int signum); /* declaration */

可以被一個進程用來終止另一個進程或者一組進程。假如 kill 函數的第一個引數是大於 0 的,那麼這個引數將會被認為是目標進程的 pid(進程 ID),假如這個引數是 0,則這個引數將會被視作信號傳送者所屬的那組進程。

kill 的第二個引數要麼是一個標準的信號數位(例如 SIGTERMSIGKILL),要麼是 0 ,這將會對信號做一次詢問,確認第一個引數中的 pid 是否是有效的。這樣優雅地關閉一個多進程應用就可以通過向組成該應用的一組進程傳送一個終止信號來完成,具體來說就是呼叫一個 kill 函數,使得這個呼叫的第二個引數是 SIGTERM 。(Nginx 主進程可以通過呼叫 kill 函數來終止其他工人進程,然後再停止自己。)就像許多庫函數一樣,kill 函數通過一個簡單的可變語法擁有更多的能力和靈活性。

範例 3. 一個多進程系統的優雅停止

#include <stdio.h>#include <signal.h>#include <stdlib.h>#include <unistd.h>#include <sys/wait.h>void graceful(int signum) {  printf("\tChild confirming received signal: %i\n", signum);  puts("\tChild about to terminate gracefully...");  sleep(1);  puts("\tChild terminating now...");  _exit(0); /* fast-track notification of parent */}void set_handler() {  struct sigaction current;  sigemptyset(&current.sa_mask);         /* clear the signal set */  current.sa_flags = 0;                  /* enables setting sa_handler, not sa_action */  current.sa_handler = graceful;         /* specify a handler */  sigaction(SIGTERM, &current, NULL);    /* register the handler */}void child_code() {  set_handler();  while (1) {   /` loop until interrupted `/    sleep(1);    puts("\tChild just woke up, but going back to sleep.");  }}void parent_code(pid_t cpid) {  puts("Parent sleeping for a time...");  sleep(5);  /* Try to terminate child. */  if (-1 == kill(cpid, SIGTERM)) {    perror("kill");    exit(-1);  }  wait(NULL); /` wait for child to terminate `/  puts("My child terminated, about to exit myself...");}int main() {  pid_t pid = fork();  if (pid < 0) {    perror("fork");    return -1; /* error */  }  if (0 == pid)    child_code();  else    parent_code(pid);  return 0;  /* normal */}

上面的停止程式模擬了一個多進程系統的優雅退出,在這個例子中,這個系統由一個父進程和一個子行程群組成。這次模擬的工作流程如下:

  • 父進程嘗試去 fork 一個子進程。假如這個 fork 操作成功了,每個進程就執行它自己的程式碼:子進程就執行函數 child_code,而父進程就執行函數 parent_code
  • 子進程將會進入一個潛在的無限迴圈,在這個迴圈中子進程將睡眠一秒,然後列印一個資訊,接著再次進入睡眠狀態,以此迴圈往復。來自父進程的一個 SIGTERM 信號將引起子進程去執行一個信號處理回撥函數 graceful。這樣這個信號就使得子進程可以跳出迴圈,然後進行子進程和父進程之間的優雅終止。在終止之前,進程將列印一個資訊。
  • fork 一個子進程後,父進程將睡眠 5 秒,使得子進程可以執行一會兒;當然在這個模擬中,子進程大多數時間都在睡眠。然後父進程呼叫 SIGTERM 作為第二個引數的 kill 函數,等待子進程的終止,然後自己再終止。

下面是一次執行的輸出:

% ./shutdownParent sleeping for a time...        Child just woke up, but going back to sleep.        Child just woke up, but going back to sleep.        Child just woke up, but going back to sleep.        Child just woke up, but going back to sleep.        Child confirming received signal: 15  ## SIGTERM is 15        Child about to terminate gracefully...        Child terminating now...My child terminated, about to exit myself...

對於信號的處理,上面的範例使用了 sigaction 庫函數(POSIX 推薦的用法)而不是傳統的 signal 函數,signal 函數有移植性問題。下面是我們主要關心的程式碼片段:

  • 假如對 fork 的呼叫成功了,父進程將執行 parent_code 函數,而子進程將執行 child_code 函數。在給子進程傳送信號之前,父進程將會等待 5 秒:

    puts("Parent sleeping for a time...");sleep(5);if (-1 == kill(cpid, SIGTERM)) {...sleepkillcpidSIGTERM...

    假如 kill 呼叫成功了,父進程將在子進程終止時做等待,使得子進程不會變成一個殭屍進程。在等待完成後,父進程再退出。

  • child_code 函數首先呼叫 set_handler 然後進入它的可能永久睡眠的迴圈。下面是我們將要檢視的 set_handler 函數:

    void set_handler() {  struct sigaction current;            /* current setup */  sigemptyset(&current.sa_mask);       /* clear the signal set */  current.sa_flags = 0;                /* for setting sa_handler, not sa_action */  current.sa_handler = graceful;       /* specify a handler */  sigaction(SIGTERM, &current, NULL);  /* register the handler */}

    上面程式碼的前三行在做相關的準備。第四個語句將為 graceful 設定為控制代碼,它將在呼叫 _exit 來停止之前列印一些資訊。第 5 行和最後一行的語句將通過呼叫 sigaction 來向系統註冊上面的控制代碼。sigaction 的第一個引數是 SIGTERM ,用作終止;第二個引數是當前的 sigaction 設定,而最後的引數(在這個例子中是 NULL )可被用來儲存前面的 sigaction 設定,以備後面的可能使用。

使用信號來作為 IPC 的確是一個很輕量的方法,但確實值得嘗試。通過信號來做 IPC 顯然可以被歸入 IPC 工具箱中。

這個系列的總結

在這個系列中,我們通過三篇有關 IPC 的文章,用範例程式碼介紹了如下機制:

  • 共用檔案
  • 共用記憶體(通過號誌)
  • 管道(命名和無名)
  • 訊息佇列
  • 通訊端
  • 信號

甚至在今天,在以執行緒為中心的語言,例如 Java、C# 和 Go 等變得越來越流行的情況下,IPC 仍然很受歡迎,因為相比於使用多執行緒,通過多進程來實現並行有著一個明顯的優勢:預設情況下,每個進程都有它自己的地址空間,除非使用了基於共用記憶體的 IPC 機制(為了達到安全的並行,競爭條件在多執行緒和多進程的時候必須被加上鎖),在多進程中可以排除掉基於記憶體的競爭條件。對於任何一個寫過即使是基本的通過共用變數來通訊的多執行緒程式的人來說,他都會知道想要寫一個清晰、高效、執行緒安全的程式碼是多麼具有挑戰性。使用單執行緒的多進程的確是很有吸引力的,這是一個切實可行的方式,使用它可以利用好今天多處理器的機器,而不需要面臨基於記憶體的競爭條件的風險。

當然,沒有一個簡單的答案能夠回答上述 IPC 機制中的哪一個更好。在程式設計中每一種 IPC 機制都會涉及到一個取捨問題:是追求簡潔,還是追求功能強大。以信號來舉例,它是一個相對簡單的 IPC 機制,但並不支援多個進程之間的豐富對話。假如確實需要這樣的對話,另外的選擇可能會更合適一些。帶有鎖的共用檔案則相對直接,但是當要處理大量共用的資料流時,共用檔案並不能很高效地工作。管道,甚至是通訊端,有著更複雜的 API,可能是更好的選擇。讓具體的問題去指導我們的選擇吧。

儘管所有的範例程式碼(可以在我的網站上獲取到)都是使用 C 寫的,其他的程式語言也經常提供這些 IPC 機制的輕量包裝。這些程式碼範例都足夠短小簡單,希望這樣能夠鼓勵你去進行實驗。