以⼀种访问权限不允许的⽅式做了⼀个访问套接字的尝试_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 字符代码,它们组成了⼀些书的标题。◈ 客户端将书的标题写给服务器端的进程,并从服务器端
的回应中读取到相同的标题。然后客户端和服务器端都在屏幕上打印出标题。下⾯是服务器端的输出,客户端的输出也和它完全⼀样:
Listening on port 9876 War and PeacePride and PrejudiceThe 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 \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_STREAM 和 SOCK_DGRAM(数据报)是最为常⽤的。基于流的套接字⽀持可信通道,在这种通道中如果发⽣了信息的丢失或者更改,都将会被报告。这种通道是双向的,并且从⼀端到另外⼀端的有效载荷在⼤⼩上可以是任意的。相反的,基于数据报的套接字⼤多是不可信的,没有⽅向性,并且需要固定⼤⼩的载荷。socket 的第三个参数特别指定了协议。对于这⾥展⽰的基于流的套接字,只有⼀种协议选择:TCP,在这⾥表⽰的 0。因为对 socket 的⼀次成功调⽤将返回相似的⽂件描述符,套接字可以被读写,对应的语法和读写⼀个本地⽂件是类似的。
一个线程可以包含多个进程对 bind 的调⽤是最为复杂的,因为它反映出了在套接字 API ⽅⾯上的各种改进。我们感兴趣的点是这个调⽤将⼀个套接字和服务器端所在机器中的⼀个内存地址进⾏绑定。但对 listen的调⽤就⾮常直接了:
if (listen(fd, MaxConnects) < 0)
第⼀个参数是套接字的⽂件描述符,第⼆个参数则指定了在服务器端处理⼀个拒绝连接错误之前,有多少个客户端连接被允许连接。(在头⽂件 sock.h 中 MaxConnects 的值被设置为 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"con
st 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 "); 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 "); 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 的第⼆个参数要么是⼀个标准的信号数字(例如 SIGTERM 或 SIGKILL),要么是 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 "); sleep(1); puts("\tChild ");
_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 "); sleep(5); /* Try to terminate child. */ if (-1 == kill(cpid, SIGTER
M)) { perror("kill"); exit(-1); } wait(NULL); /` wait for child to terminate `/ puts("My child terminated, about to ");}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 函数,等待⼦进程的终⽌,然后⾃⼰再终⽌。
下⾯是⼀次运⾏的输出:
% ./shutdownParent sleeping for 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 Child My child terminated, about to
对于信号的处理,上⾯的⽰例使⽤了 sigaction 库函数(POSIX 推荐的⽤法)⽽不是传统的 signal 函数,signal 函数有移植性问题。下⾯是我们主要关⼼的代码⽚段:
◈ 假如对 fork 的调⽤成功了,⽗进程将执⾏ parent_code 函数,⽽⼦进程将执⾏ child_code 函数。 在给⼦进程发送信号之前,⽗进程将会等待 5 秒:
puts("Parent sleeping for ");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 来停⽌之前打印⼀些信息。第 5 ⾏和最后⼀⾏的语句将通过调⽤ sigaction来向系统注册上⾯的句柄。sigaction 的第⼀个参数是 SIGTERM ,⽤作终⽌;第⼆个参数是当前
的 sigaction 设定,⽽最后的参数(在这个例⼦中是 NULL )可被⽤来保存前⾯的 sigaction 设定,以备后⾯的可能使⽤。
使⽤信号来作为 IPC 的确是⼀个很轻量的⽅法,但确实值得尝试。通过信号来做 IPC 显然可以被归⼊ IPC ⼯具箱中。
这个系列的总结
在这个系列中,我们通过三篇有关 IPC 的⽂章,⽤⽰例代码介绍了如下机制:
◈ 共享⽂件◈ 共享内存(通过信号量)◈ 管道(命名和⽆名)◈ 消息队列◈ 套接字◈ 信号
甚⾄在今天,在以线程为中⼼的语⾔,例如 Java、C# 和 Go 等变得越来越流⾏的情况下,IPC 仍然很受欢迎,因为相⽐于使⽤多线程,通过多进程来实现并发有着⼀个明显的优势:默认情况下,每个进程都有它⾃⼰的地址空间,除⾮使⽤了基于共享内存的 IPC 机制(为了达到安全的并发,竞争条件在多线程和多进程的时候必须被加上锁),在多进程中可以排除掉基于内存的竞争条件。对于任何⼀个写过即使是基本的通过共享变量来通信的多线程程序的⼈来说,他都会知道想要写⼀个清晰、⾼效、线程安全的代码是多么具有挑战性。使⽤单线程的多进程的确是很有吸引⼒的,这是⼀个切实可⾏的⽅式,使⽤它可以利⽤好今天多处理器的机器,⽽不需要⾯临基于内存的竞争条件的风险。
当然,没有⼀个简单的答案能够回答上述 IPC 机制中的哪⼀个更好。在编程中每⼀种 IPC 机制都会涉及到⼀个取舍问题:是追求简洁,还是追求功能强⼤。以信号来举例,它是⼀个相对简单的 IPC 机制,但并不⽀持多个进程之间的丰富对话。假如确实需要这样的对话,另外的选择可能会更合适⼀些。带有锁的共享⽂件则相对直接,但是当要处理⼤量共享的数据流时,共享⽂件并不能很⾼效地⼯作。管道,甚⾄是套接字,有着更复杂的 API,可能是更好的选择。让具体的问题去指导我们的选择吧。
尽管所有的⽰例代码(可以在我的⽹站上获取到)都是使⽤ C 写的,其他的编程语⾔也经常提供这些 IPC 机制的轻量包装。这些代码⽰例都⾜够短⼩简单,希望这样能够⿎励你去进⾏实验。
作者:Marty Kalin 选题:lujun9972 译者:FSSlc 校对:wxy
本⽂由 LCTT 原创编译,Linux中国 荣誉推出
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论