Linux中國

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&apos;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&apos;t terminated, though there&apos;s a problem */
      continue;
    }

    /* read from client */
    int i;
    for (i = 0; i < ConversationLen; i++) {
      char buffer[BuffSize + 1];
      memset(buffer, &apos;&apos;, 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&apos;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, &apos;&apos;, 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: %in", 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 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的電子郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國