Linux 下的程式間通訊:套接字和訊號

Marty Kalin發表於2019-06-02

學習在 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 Peace
Pride and Prejudice
The 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 函式,等待子程式的終止,然後自己再終止。

下面是一次執行的輸出:

% ./shutdown
Parent 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 機制的輕量包裝。這些程式碼示例都足夠短小簡單,希望這樣能夠鼓勵你去進行實驗。


via: https://opensource.com/article/19/4/interprocess-communication-linux-networking

作者:Marty Kalin 選題:lujun9972 譯者:FSSlc 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出

Linux 下的程式間通訊:套接字和訊號

訂閱“Linux 中國”官方小程式來檢視

相關文章