大家好!我是大聪明-PLUS!
插座和信号
本节从高层(套接字)到底层(信号)介绍进程间通信(IPC)。详细信息将在代码示例中提供。
插座
套接字(类似于管道,包括命名管道和匿名管道)有两种形式。进程间通信(IPC)套接字 (也称为Unix 域套接字 )提供同一物理设备(即主机*)* 上进程之间的通信通道,而网络套接字则 提供运行在不同主机上的进程之间的通信。网络套接字需要支持TCP (传输控制协议)或更底层的UDP(用户数据报协议)。
相比之下,进程间通信(IPC)套接字使用本地系统内核进行通信*;* 具体来说,IPC 套接字之间通过用作套接字地址的本地文件进行通信。尽管实现方式有所不同,但 IPC 套接字和网络套接字的 API 基本相同。以下示例讨论的是网络套接字,但服务器和客户端程序示例可以在同一台机器上运行------服务器的网络地址是localhost (127.0.0.1),即本地机器本身的地址。
配置为流的套接字(如下所述)是双向的,并基于客户端-服务器原理运行:客户端通过尝试连接到服务器来发起通信,服务器则尝试接受连接。如果一切顺利,来自客户端的请求和来自服务器的响应将通过通道流动,直到通道的一端关闭为止。
迭代式 服务器按顺序处理已连接的客户端,一次处理一个:首先,从头到尾处理第一个客户端,然后处理第二个,依此类推。缺点是处理一个客户端可能会阻塞,从而导致队列中后续所有客户端无法访问服务器。并发 式 服务器同时使用多进程 和 多线程技术*。例如,我电脑上的* nginx Web 服务器有一个包含四个工作进程的进程池,可以并行处理多个进程。为了专注于 API 而不是并发问题,以下示例将使用迭代式服务器。
随着 POSIX 标准的不断发展,套接字 API 也随之发生了显著变化。当前的服务器和客户端代码示例经过了简化,但仍然突出了基于 流的套接字连接的双向特性。以下概述了服务器在一个终端运行、客户端在另一个终端运行时的控制流程:
-
服务器等待客户端连接,如果连接成功,则从客户端读取字节。
-
服务器返回从客户端接收到的字节(以强调交互的双向性)。这些字节是代表书籍标题的字符的ASCII码。
-
客户端向服务器发送书名,并收到服务器返回的相同书名。服务器和客户端都会显示这些书名。服务器的输出(与客户端的输出相同):
`Listening on port 9876 for clients...
War and Peace
Pride and Prejudice
The Sound and the Fury`
#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`;
}`
如上所示的服务器程序遵循经典的四步算法,先准备客户端请求,然后逐个接受请求。每个步骤都以服务器调用的系统函数命名:
-
socket(...) - 获取套接字连接的文件描述符
-
bind(...) - 将套接字绑定到服务器主机上的地址
-
listen(...) - 监听(等待)来自客户端的请求
-
accept(...) - 接受来自客户端的请求。
完整的通话socket记录如下:
int` `fd` `=` `socket`(`AF_INET`, `/* versus AF_LOCAL */`
`SOCK_STREAM`, `/* reliable, bidirectional */`
`0`); `/* system picks protocol (TCP)*/
第一个参数指定网络套接字(而非进程间通信套接字)。第二个参数有很多变体,但最常用的是SOCK_STREAM`and` 和 ` SOCK_DGRAM(datagram)`。
基于 流的套接字支持可靠的连接 类型,能够监控消息丢失或修改;通道是双向的,有效载荷大小可以 任意。相比之下,基于数据报的套接字可靠性较低,是单向的,并且仅支持固定大小的有效载荷。
对于流套接字(在我们的代码中使用),只有一种协议可供选择:TCP (表示为0 )。由于成功调用socket会返回熟悉的文件描述符,因此读写语法与普通文件相同。
这个调用bind比较复杂。有趣的是,调用这个函数会将套接字绑定到服务器计算机上的一个内存地址。
挑战listen很简单:
if` (`listen`(`fd`, `MaxConnects`) `<`; `0`)`
第一个参数是套接字文件描述符;第二个参数指定最大客户端连接数------服务器将在后续连接尝试中返回连接被拒绝错误*。* (MaxConnects定义见8头文件sock.h)
accept默认情况下,该调用是阻塞的:服务器会等待客户端连接。accept如果调用出错,该函数返回 -1。如果调用成功,它会返回另一个用于读写套接字的 accept文件描述符。服务器使用此套接字来读取客户端请求和写入客户端响应。传递给该调用的第一个参数是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循环中写入请求并读取响应。消息交换完毕后,服务器和客户端都会关闭其读/写套接字(但实际上只需其中一方关闭即可)。之后客户端终止,但如前所述,服务器会继续运行。
前面讨论的例子表明,客户端和服务器可以交换完全任意的数据,其中请求的消息会被返回给客户端。这或许是套接字的主要优势。在现代系统中,客户端和服务器应用程序之间通过套接字进行通信非常普遍(例如,数据库客户端)。如前所述,本地进程间通信(IPC)套接字和网络套接字在实现上并没有太大区别:它们本质上使用相同的API。然而,一般来说,IPC套接字的开销更低,性能更好。
信号
SIGSTOP信号会中断程序执行,从这个意义上讲,它们会与程序交互。大多数信号都可以被忽略或处理,但挂起(suspend)信号和SIGKILL立即终止(embrief)信号除外。
符号常量(SIGKILL)对应于整数(9)。
示例 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`(`¤t`.`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`, `¤t`, `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 */`
}`
信号可以通过用户交互生成。例如,要在命令行终止程序,用户按下Ctrl+C ,就会生成该信号SIGTERM。与 不同SIGKILL,SIGTERM该信号可以被忽略或处理。
一个进程可以向另一个进程发送信号,这意味着信号也可以被视为一种进程间通信(IPC)机制。让我们考虑一下另一个进程如何优雅地终止一个多进程应用程序(例如,Nginx Web 服务器)。
功能kill:
int` `kill`(`pid_t` `pid`, `int` `signum`);`
可以被一个进程用来终止另一个进程或一组进程的工作。
如果函数的第一个参数kill大于零,则将其视为要终止的进程的PID (进程 ID);如果该参数为 0,则将其视为信号发送者所属的进程组。
第二个参数kill可以是某个标准信号的编号(例如 `None`SIGTERM或 ` SIGKILLNone`),也可以是 0,0 会通过检查第一个参数中的进程 ID (PID) 是否有效来终止函数调用。要优雅地关闭多进程系统,可以调用该函数kill并将`None` 作为第二个参数------也就是向SIGTERM与系统对应的进程组发送终止 信号。(通过调用该函数, Nginx 主进程可以终止工作进程,然后自身终止。)与许多库函数一样,kill该函数以简洁的调用语法兼具强大的功能和灵活性(参见示例 3)。kill
上述程序模拟了多进程系统的优雅关闭过程,在本例中,该系统由一个父进程和一个子进程组成。模拟过程如下:
1)父进程尝试创建 一个子进程。如果成功,每个进程开始执行各自的代码:child_code子进程执行一个函数,parent_code父进程执行一个函数。
-
子进程进入一个(可能)无限循环,循环中它会休眠一秒钟,打印消息,再次休眠,如此往复。
SIGTERM父进程发出信号后,子进程会执行一个用于 处理graceful信号的回调函数,从而跳出循环,并优雅地终止父进程和子进程。在终止之前,子进程会打印一条消息。 -
父进程创建子进程后,会休眠 5 秒钟,让子进程运行一段时间(子进程大部分时间都会处于休眠状态)。然后,父进程调用
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。父进程等待 5 秒钟后再向子进程发送信号:
puts`(`"Parent sleeping for a time..."`);
`sleep`(`5`);
`if` (`-1` `==` `kill`(`cpid`, `SIGTERM`)) {
...`
如果调用成功,kill父进程会等待子进程终止,以防止子进程变成僵尸进程。等待结束后,父进程终止。
该函数child_code首先被调用set_handler,然后进入无限循环。该函数set_handler如下所示:
void` `set_handler`() {
`struct` `sigaction` `current`; `/* current setup */`
`sigemptyset`(`¤t`.`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`, `¤t`, `NULL`); `/* register the handler */`
}`
前三行是设置语句。第四条语句将处理程序设置为一个函数graceful,该函数会在调用退出程序之前打印一条消息_exit。然后,第五条和第六条语句通过调用 `registerHandler` 方法将处理程序注册到系统中sigaction。第一个参数是 `default` SIGTERM,第二个参数是当前设置sigaction,最后一个参数(NULL在本例中为 `false`)可用于保存之前的设置sigaction,例如,以供将来使用。
使用信号作为进程间通信机制是一种真正极简主义的方法,但却经受住了时间的考验。
结论
本指南通过代码示例介绍了以下进程间通信(IPC)机制:
-
共享文件
-
共享记忆和信号灯
-
有名管道和无名管道
-
消息队列
-
插座
-
信号
即使在今天,以线程为中心的 语言(Java、C#、Go)已经非常流行,进程间通信(IPC)仍然是一种极具吸引力的机制,因为通过多进程实现并行处理相比多线程具有明显的优势:每个进程默认拥有自己的地址空间,从而消除了基于内存的竞争条件。(除非涉及共享内存。为了确保并行处理的安全,无论是多进程还是多线程,共享内存都必须加锁。)任何编写过哪怕是最简单的、带有共享变量的多线程程序的人都知道,编写线程安全、简洁且高效的代码是多么困难。多进程仍然是一种利用现代多处理器计算机的优势,同时避免竞争条件固有风险的极具吸引力的方式。
当然,哪种进程间通信(IPC)机制更好并没有简单的答案------这始终需要在简洁性和功能性之间进行权衡。例如,信号是一种相对简单的IPC机制;然而,它在进程间通信方面提供的功能有限。如果需要这些功能,则应选择其他方案。带锁的共享文件也相当简单,但对于需要共享大量数据的场景来说并不理想;管道和套接字(以及更复杂的API)会是更好的选择。