Linux 中的进程间通信:套接字和信号

了解进程如何在 Linux 中相互同步。
205 位读者喜欢这篇文章。
Mesh networking connected dots

这是关于 Linux 中进程间通信 (IPC) 系列的第三篇也是最后一篇文章。第一篇文章侧重于通过共享存储(文件和内存段)进行 IPC,第二篇文章则介绍了基本通道:管道(命名管道和匿名管道)和消息队列。本文从高端 IPC(套接字)转向低端 IPC(信号)。代码示例详细说明了细节。

套接字

正如管道有两种类型(命名管道和匿名管道)一样,套接字也是如此。IPC 套接字(也称为 Unix 域套接字)为同一物理设备(主机)上的进程启用基于通道的通信,而网络套接字为可能在不同主机上运行的进程启用这种 IPC,从而引入了网络。网络套接字需要底层协议(如 TCP(传输控制协议)或更低级别的 UDP(用户数据报协议))的支持。

相比之下,IPC 套接字依赖于本地系统内核来支持通信;特别是,IPC 套接字使用本地文件作为套接字地址进行通信。尽管存在这些实现差异,但 IPC 套接字和网络套接字 API 在本质上是相同的。即将到来的示例涵盖了网络套接字,但示例服务器和客户端程序可以在同一台机器上运行,因为服务器使用网络地址 localhost (127.0.0.1),即本地机器在本地机器上的地址。

配置为流的套接字(如下所述)是双向的,并且控制遵循客户端/服务器模式:客户端通过尝试连接到服务器来发起对话,服务器尝试接受连接。如果一切正常,则客户端的请求和服务器的响应可以流经通道,直到在任一端关闭通道,从而断开连接。

[下载 Linux 中进程间通信的完整指南]

迭代服务器仅适用于开发,它一次处理一个已连接的客户端直到完成:先处理第一个客户端从开始到结束,然后是第二个,依此类推。缺点是,对特定客户端的处理可能会挂起,从而使所有在后面等待的客户端都处于饥饿状态。生产级服务器将是并发的,通常使用多进程和多线程的某种组合。例如,我桌面机器上的 Nginx Web 服务器有一个由四个工作进程组成的池,可以并发处理客户端请求。以下代码示例通过使用迭代服务器将混乱降至最低;因此,重点仍然是基本 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, arbitrary payload size */
                  0);          /* system picks underlying protocol (TCP) */
  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 terminate, 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;
}

上面的服务器程序执行经典的四个步骤来准备好自己以接受客户端请求,然后接受单个请求。每个步骤都以服务器调用的系统函数命名

  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。由于成功调用 socket 会返回熟悉的文件描述符,因此套接字的写入和读取语法与本地文件(例如)相同。

bind 调用是最复杂的,因为它反映了套接字 API 中的各种改进。感兴趣的点是此调用将套接字绑定到服务器机器上的内存地址。但是,listen 调用很简单

if (listen(fd, MaxConnects) < 0)

第一个参数是套接字的文件描述符,第二个参数指定在服务器在尝试连接时发出连接被拒绝错误之前可以容纳多少个客户端连接。(MaxConnects 在头文件 sock.h 中设置为 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 读/写套接字,尽管在任一侧执行 close 操作都足以关闭连接。客户端随后退出,但如前所述,服务器保持开放以供使用。

套接字示例,请求消息回显给客户端,暗示了服务器和客户端之间任意丰富对话的可能性。也许这是套接字的主要吸引力。在现代系统中,客户端应用程序(例如,数据库客户端)通常通过套接字与服务器通信。如前所述,本地 IPC 套接字和网络套接字仅在少数实现细节上有所不同;一般来说,IPC 套接字具有更低的开销和更好的性能。对于两者,通信 API 本质上是相同的。

信号

信号中断正在执行的程序,并在这个意义上与其通信。大多数信号可以被忽略(阻止)或处理(通过指定的代码),但 SIGSTOP(暂停)和 SIGKILL(立即终止)是两个值得注意的例外。符号常量(如 SIGKILL)具有整数值,在本例中为 9。

信号可能在用户交互中产生。例如,用户从命令行按 Ctrl+C 以终止从命令行启动的程序;Ctrl+C 生成 SIGTERM 信号。与 SIGKILL 不同,用于终止SIGTERM 可以被阻止或处理。一个进程也可以向另一个进程发送信号,从而使信号成为 IPC 机制。

考虑如何从另一个进程优雅地关闭多进程应用程序(如 Nginx Web 服务器)。kill 函数

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

可以被一个进程用来终止另一个进程或进程组。如果函数 kill 的第一个参数大于零,则此参数被视为目标进程的 pid(进程 ID);如果参数为零,则该参数标识信号发送者所属的进程组。

kill 的第二个参数是标准信号编号(例如,SIGTERMSIGKILL)或 0,这使得对 signal 的调用成为关于第一个参数中的 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 */
}

上面的 shutdown 程序模拟了多进程系统的优雅关闭,在本例中,这是一个由父进程和单个子进程组成的简单系统。模拟工作方式如下

  • 父进程尝试 fork 一个子进程。如果 fork 成功,则每个进程执行自己的代码:子进程执行函数 child_code,父进程执行函数 parent_code
  • 子进程进入一个可能无限循环的循环,其中子进程休眠一秒钟,打印一条消息,然后返回休眠,依此类推。正是来自父进程的 SIGTERM 信号导致子进程执行信号处理回调函数 graceful。因此,信号使子进程跳出循环,并设置子进程和父进程的优雅终止。子进程在终止前打印一条消息。
  • 父进程在 fork 子进程后休眠五秒钟,以便子进程可以执行一段时间;当然,在这个模拟中,子进程主要处于休眠状态。然后,父进程调用 kill 函数,其中 SIGTERM 作为第二个参数,等待子进程终止,然后退出。

以下是示例运行的输出

% ./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 函数。以下是主要感兴趣的代码段

  • 如果对 fork 的调用成功,则父进程执行 parent_code 函数,子进程执行 child_code 函数。父进程在向子进程发送信号之前等待五秒钟
    puts("Parent sleeping for a time...");
    sleep(5);
    if (-1 == kill(cpid, SIGTERM)) {
    ...

    如果 kill 调用成功,则父进程对子进程的终止执行 wait,以防止子进程变成永久僵尸进程;在等待之后,父进程退出。

  • 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 终止之前打印一些消息。然后,第五条也是最后一条语句通过调用 sigaction 向系统注册处理程序。sigaction 的第一个参数是 SIGTERM(表示终止),第二个参数是当前的 sigaction 设置,最后一个参数(在本例中为 NULL)可用于保存先前的 sigaction 设置,可能供以后使用。

使用信号进行 IPC 确实是一种极简主义方法,但也是一种经过验证的方法。通过信号进行 IPC 显然属于 IPC 工具箱。

总结本系列

关于 IPC 的这三篇文章通过代码示例涵盖了以下机制

  • 共享文件
  • 共享内存(带信号量)
  • 管道(命名管道和匿名管道)
  • 消息队列
  • 套接字
  • 信号

即使在今天,当以线程为中心的语言(如 Java、C# 和 Go)变得如此流行时,IPC 仍然具有吸引力,因为通过多进程实现的并发比多线程具有明显的优势:默认情况下,每个进程都有自己的地址空间,这排除了多进程中基于内存的竞争条件,除非引入共享内存的 IPC 机制。(在多进程和多线程中,都必须锁定共享内存才能实现安全并发。)任何编写过甚至是通过共享变量进行通信的初级多线程程序的人都知道,编写线程安全、清晰且高效的代码可能有多么具有挑战性。对于今天的多处理器机器,使用单线程进程进行多进程处理仍然是一种可行——实际上,非常吸引人——的方式,可以在没有基于内存的竞争条件的固有风险的情况下利用它们。

当然,对于哪种 IPC 机制是最佳的问题,没有简单的答案。每种机制都涉及编程中典型的权衡:简单性与功能性。例如,信号是一种相对简单的 IPC 机制,但不支持进程之间丰富的对话。如果需要这种转换,那么其他选择之一更合适。带有锁定的共享文件相当简单明了,但如果进程需要共享大量数据流,则共享文件可能无法表现良好;管道甚至套接字(具有更复杂的 API)可能是更好的选择。让手头的问题指导选择。

尽管示例代码(可在我的网站上找到)都是用 C 语言编写的,但其他编程语言通常提供围绕这些 IPC 机制的简单封装。代码示例简短而简单,我希望能够鼓励您进行实验。

标签
User profile image.
我是计算机科学领域的学者(德保罗大学计算与数字媒体学院),在软件开发方面拥有广泛的经验,主要是在生产计划和调度(钢铁行业)以及产品配置(卡车和巴士制造)方面。关于书籍和其他出版物的详细信息,请访问

1 条评论

在正常的系统中,在终端上按 Ctrl-C 会生成 SIGINT (2)...
而不是上面提到的 SIGTERM (15)

Creative Commons License本作品根据知识共享署名-相同方式共享 4.0 国际许可协议获得许可。
© . All rights reserved.