歡迎光臨
每天分享高質量文章

Linux 下的行程間通信:套接字和信號 | Linux 中國

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

— Marty Kalin

 

本篇是 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 字符代碼,它們組成了一些書的標題。
◈ 客戶端將書的標題寫給服務器端的行程,並從服務器端的回應中讀取到相同的標題。然後客戶端和服務器端都在屏幕上打印出標題。下麵是服務器端的輸出,客戶端的輸出也和它完全一樣:
  1. Listening on port 9876 for clients...
  2. War and Peace
  3. Pride and Prejudice
  4. The Sound and the Fury

示例 1. 使用套接字的客戶端程式

  1. #include <string.h>
  2. #include <stdio.h>
  3. #include <stdlib.h>
  4. #include <unistd.h>
  5. #include <sys/types.h>
  6. #include <sys/socket.h>
  7. #include <netinet/tcp.h>
  8. #include <arpa/inet.h>
  9. #include "sock.h"
  10. void report(const char* msg, int terminate) {
  11. perror(msg);
  12. if (terminate) exit(-1); /* failure */
  13. }
  14. int main() {
  15. int fd = socket(AF_INET, /* network versus AF_LOCAL */
  16. SOCK_STREAM, /* reliable, bidirectional: TCP */
  17. 0); /* system picks underlying protocol */
  18. if (fd < 0) report("socket", 1); /* terminate */
  19. /* bind the server's local address in memory */
  20. struct sockaddr_in saddr;
  21. memset(&saddr, 0, sizeof(saddr)); /* clear the bytes */
  22. saddr.sin_family = AF_INET; /* versus AF_LOCAL */
  23. saddr.sin_addr.s_addr = htonl(INADDR_ANY); /* host-to-network endian */
  24. saddr.sin_port = htons(PortNumber); /* for listening */
  25. if (bind(fd, (struct sockaddr *) &saddr, sizeof(saddr)) < 0)
  26. report("bind", 1); /* terminate */
  27. /* listen to the socket */
  28. if (listen(fd, MaxConnects) < 0) /* listen for clients, up to MaxConnects */
  29. report("listen", 1); /* terminate */
  30. fprintf(stderr, "Listening on port %i for clients...\n", PortNumber);
  31. /* a server traditionally listens indefinitely */
  32. while (1) {
  33. struct sockaddr_in caddr; /* client address */
  34. int len = sizeof(caddr); /* address length could change */
  35. int client_fd = accept(fd, (struct sockaddr*) &caddr, &len); /* accept blocks */
  36. if (client_fd < 0) {
  37. report("accept", 0); /* don't terminated, though there's a problem */
  38. continue;
  39. }
  40. /* read from client */
  41. int i;
  42. for (i = 0; i < ConversationLen; i++) {
  43. char buffer[BuffSize + 1];
  44. memset(buffer, '\0', sizeof(buffer));
  45. int count = read(client_fd, buffer, sizeof(buffer));
  46. if (count > 0) {
  47. puts(buffer);
  48. write(client_fd, buffer, sizeof(buffer)); /* echo as confirmation */
  49. }
  50. }
  51. close(client_fd); /* break connection */
  52. } /* while(1) */
  53. return 0;
  54. }

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

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

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

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

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

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

  1. if (listen(fd, MaxConnects) < 0)

第一個引數是套接字的檔案描述符,第二個引數則指定了在服務器端處理一個拒絕連接錯誤之前,有多少個客戶端連接被允許連接。(在頭檔案 sock.h 中 MaxConnects 的值被設置為 8。)

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

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

示例 2. 使用套接字的客戶端

  1. #include <string.h>
  2. #include <stdio.h>
  3. #include <stdlib.h>
  4. #include <unistd.h>
  5. #include <sys/types.h>
  6. #include <sys/socket.h>
  7. #include <arpa/inet.h>
  8. #include <netinet/in.h>
  9. #include <netinet/tcp.h>
  10. #include <netdb.h>
  11. #include "sock.h"
  12. const char* books[] = {"War and Peace",
  13. "Pride and Prejudice",
  14. "The Sound and the Fury"};
  15. void report(const char* msg, int terminate) {
  16. perror(msg);
  17. if (terminate) exit(-1); /* failure */
  18. }
  19. int main() {
  20. /* fd for the socket */
  21. int sockfd = socket(AF_INET, /* versus AF_LOCAL */
  22. SOCK_STREAM, /* reliable, bidirectional */
  23. 0); /* system picks protocol (TCP) */
  24. if (sockfd < 0) report("socket", 1); /* terminate */
  25. /* get the address of the host */
  26. struct hostent* hptr = gethostbyname(Host); /* localhost: 127.0.0.1 */
  27. if (!hptr) report("gethostbyname", 1); /* is hptr NULL? */
  28. if (hptr->h_addrtype != AF_INET) /* versus AF_LOCAL */
  29. report("bad address family", 1);
  30. /* connect to the server: configure server's address 1st */
  31. struct sockaddr_in saddr;
  32. memset(&saddr, 0, sizeof(saddr));
  33. saddr.sin_family = AF_INET;
  34. saddr.sin_addr.s_addr =
  35. ((struct in_addr*) hptr->h_addr_list[0])->s_addr;
  36. saddr.sin_port = htons(PortNumber); /* port number in big-endian */
  37. if (connect(sockfd, (struct sockaddr*) &saddr, sizeof(saddr)) < 0)
  38. report("connect", 1);
  39. /* Write some stuff and read the echoes. */
  40. puts("Connect to server, about to write some stuff...");
  41. int i;
  42. for (i = 0; i < ConversationLen; i++) {
  43. if (write(sockfd, books[i], strlen(books[i])) > 0) {
  44. /* get confirmation echoed from server and print */
  45. char buffer[BuffSize + 1];
  46. memset(buffer, '\0', sizeof(buffer));
  47. if (read(sockfd, buffer, sizeof(buffer)) > 0)
  48. puts(buffer);
  49. }
  50. }
  51. puts("Client done, about to exit...");
  52. close(sockfd); /* close the connection */
  53. return 0;
  54. }

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

  1. 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 函式:

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

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

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

示例 3. 一個多行程系統的優雅停止

  1. #include <stdio.h>
  2. #include <signal.h>
  3. #include <stdlib.h>
  4. #include <unistd.h>
  5. #include <sys/wait.h>
  6. void graceful(int signum) {
  7.   printf("\tChild confirming received signal: %i\n", signum);
  8.   puts("\tChild about to terminate gracefully...");
  9.   sleep(1);
  10.   puts("\tChild terminating now...");
  11.   _exit(0); /* fast-track notification of parent */
  12. }
  13. void set_handler() {
  14.   struct sigaction current;
  15.   sigemptyset(&current.sa_mask);         /* clear the signal set */
  16.   current.sa_flags = 0;                  /* enables setting sa_handler, not sa_action */
  17.   current.sa_handler = graceful;         /* specify a handler */
  18.   sigaction(SIGTERM, &current, NULL);    /* register the handler */
  19. }
  20. void child_code() {
  21.   set_handler();
  22.   while (1) {   /` loop until interrupted `/
  23.     sleep(1);
  24.     puts("\tChild just woke up, but going back to sleep.");
  25.   }
  26. }
  27. void parent_code(pid_t cpid) {
  28.   puts("Parent sleeping for a time...");
  29.   sleep(5);
  30.   /* Try to terminate child. */
  31.   if (-1 == kill(cpid, SIGTERM)) {
  32.     perror("kill");
  33.     exit(-1);
  34.   }
  35.   wait(NULL); /` wait for child to terminate `/
  36.   puts("My child terminated, about to exit myself...");
  37. }
  38. int main() {
  39.   pid_t pid = fork();
  40.   if (pid < 0) {
  41.     perror("fork");
  42.     return -1; /* error */
  43.   }
  44.   if (0 == pid)
  45.     child_code();
  46.   else
  47.     parent_code(pid);
  48.   return 0;  /* normal */
  49. }

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

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

下麵是一次運行的輸出:

  1. % ./shutdown
  2. Parent sleeping for a time...
  3.         Child just woke up, but going back to sleep.
  4.         Child just woke up, but going back to sleep.
  5.         Child just woke up, but going back to sleep.
  6.         Child just woke up, but going back to sleep.
  7.         Child confirming received signal: 15  ## SIGTERM is 15
  8.         Child about to terminate gracefully...
  9.         Child terminating now...
  10. My child terminated, about to exit myself...

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

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

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

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

◈ child_code 函式首先呼叫 set_handler 然後進入它的可能永久睡眠的迴圈。下麵是我們將要查看的 set_handler 函式:

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

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

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

這個系列的總結

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

◈ 共享檔案
◈ 共享記憶體(通過信號量)
◈ 管道(命名和無名)
◈ 訊息佇列
◈ 套接字
◈ 信號

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

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

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

已同步到看一看
赞(0)

分享創造快樂