10| 什么是优雅退出?

10| 什么是优雅退出?
引言上一章我们讲到了 TCP 的四次挥手其中发起连接关闭的一方会有一段时间处于 TIME_WAIT 状态。那么究竟如何来发起连接关闭呢这一讲我们就来讨论一下。我们知道一个 TCP 连接需要经过三次握手进入数据传输阶段最后来到连接关闭阶段。在最后的连接关闭阶段我们需要重点关注的是“半连接”状态。因为 TCP 是双向的这里说的方向指的是数据流的写入 - 读出的方向。比如客户端到服务器端的方向指的是客户端通过套接字接口向服务器端发送 TCP 报文而服务器端到客户端方向则是另一个传输方向。在绝大多数情况下TCP 连接都是先关闭一个方向此时另外一个方向还是可以正常进行数据传输。举个例子客户端主动发起连接的中断将自己到服务器端的数据流方向关闭此时客户端不再往服务器端写入数据服务器端读完客户端数据后就不会再有新的报文到达。但这并不意味着TCP 连接已经完全关闭很有可能的是服务器端正在对客户端的最后报文进行处理比如去访问数据库存入一些数据或者是计算出某个客户端需要的值当完成这些操作之后服务器端把结果通过套接字写给客户端我们说这个套接字的状态此时是“半关闭”的。最后服务器端才有条不紊地关闭剩下的半个连接结束这一段 TCP 连接的使命。当然我这里描述的是服务器端“优雅”地关闭了连接。如果服务器端处理不好就会导致最后的关闭过程是“粗暴”的达不到我们上面描述的“优雅”关闭的目标形成的后果很可能是服务器端处理完的信息没办法正常传送给客户端破坏了用户侧的使用场景。接下来我们就来看看关闭连接时都有哪些方式呢close 函数首先我们来看最常见的 close 函数int close(int sockfd)这个函数很简单对已连接的套接字执行 close 操作就可以若成功则为 0若出错则为 -1。这个函数会对套接字引用计数减一一旦发现套接字引用计数到 0就会对套接字进行彻底释放并且会关闭 TCP 两个方向的数据流。套接字引用计数是什么意思呢因为套接字可以被多个进程共享你可以理解为我们给每个套接字都设置了一个积分如果我们通过 fork 的方式产生子进程套接字就会积分 1 如果我们调用一次 close 函数套接字积分就会 -1。这就是套接字引用计数的含义。close 函数具体是如何关闭两个方向的数据流呢在输入方向系统内核会将该套接字设置为不可读任何读操作都会返回异常。在输出方向系统内核尝试将发送缓冲区的数据发送给对端并最后向对端发送一个 FIN 报文接下来如果再对该套接字进行写操作会返回异常。如果对端没有检测到套接字已关闭还继续发送报文就会收到一个 RST 报文告诉对端“Hi, 我已经关闭了别再给我发数据了。”我们会发现close 函数并不能帮助我们关闭连接的一个方向那么如何在需要的时候关闭一个方向呢幸运的是设计 TCP 协议的人帮我们想好了解决方案这就是 shutdown 函数。shutdown 函数shutdown 函数的原型是这样的int shutdown(int sockfd, int howto)对已连接的套接字执行 shutdown 操作若成功则为 0若出错则为 -1。howto 是这个函数的设置选项它的设置有三个主要选项SHUT_RD(0)关闭连接的“读”这个方向对该套接字进行读操作直接返回 EOF。从数据角度来看套接字上接收缓冲区已有的数据将被丢弃如果再有新的数据流到达会对数据进行 ACK然后悄悄地丢弃。也就是说对端还是会接收到 ACK在这种情况下根本不知道数据已经被丢弃了。SHUT_WR(1)关闭连接的“写”这个方向这就是常被称为“半关闭”的连接。此时不管套接字引用计数的值是多少都会直接关闭连接的写方向。套接字上发送缓冲区已有的数据将被立即发送出去并发送一个 FIN 报文给对端。应用程序如果对该套接字进行写操作会报错。SHUT_RDWR(2)相当于 SHUT_RD 和 SHUT_WR 操作各一次关闭套接字的读和写两个方向。讲到这里不知道你是不是有和我当初一样的困惑使用 SHUT_RDWR 来调用 shutdown 不是和 close 基本一样吗都是关闭连接的读和写两个方向。其实这两个还是有差别的。第一个差别close 会关闭连接并释放所有连接对应的资源而 shutdown 并不会释放掉套接字和所有的资源。第二个差别close 存在引用计数的概念并不一定导致该套接字不可用shutdown 则不管引用计数直接使得该套接字不可用如果有别的进程企图使用该套接字将会受到影响。第三个差别close 的引用计数导致不一定会发出 FIN 结束报文而 shutdown 则总是会发出 FIN 结束报文这在我们打算关闭连接通知对端的时候是非常重要的。体会 close 和 shutdown 的差别下面我们通过构建一组客户端和服务器程序来进行 close 和 shutdown 的实验。客户端程序从标准输入不断接收用户输入把输入的字符串通过套接字发送给服务器端同时将服务器端的应答显示到标准输出上。如果用户输入了“close”则会调用 close 函数关闭连接休眠一段时间等待服务器端处理后退出如果用户输入了“shutdown”调用 shutdown 函数关闭连接的写方向注意我们不会直接退出而是会继续等待服务器端的应答直到服务器端完成自己的操作在另一个方向上完成关闭。在这里我们会第一次接触到 select 多路复用这里不展开讲你只需要记住使用 select 使得我们可以同时完成对连接套接字和标准输入两个 I/O 对象的处理。# include lib/common.h # define MAXLINE 4096 int main(int argc, char **argv) { if (argc ! 2) { error(1, 0, usage: graceclient IPaddress); } int socket_fd; socket_fd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in server_addr; bzero(server_addr, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(SERV_PORT); inet_pton(AF_INET, argv[1], server_addr.sin_addr); socklen_t server_len sizeof(server_addr); int connect_rt connect(socket_fd, (struct sockaddr *) server_addr, server_len); if (connect_rt 0) { error(1, errno, connect failed ); } char send_line[MAXLINE], recv_line[MAXLINE 1]; int n; fd_set readmask; fd_set allreads; FD_ZERO(allreads); FD_SET(0, allreads); FD_SET(socket_fd, allreads); for (;;) { readmask allreads; int rc select(socket_fd 1, readmask, NULL, NULL, NULL); if (rc 0) error(1, errno, select failed); if (FD_ISSET(socket_fd, readmask)) { n read(socket_fd, recv_line, MAXLINE); if (n 0) { error(1, errno, read error); } else if (n 0) { error(1, 0, server terminated \n); } recv_line[n] 0; fputs(recv_line, stdout); fputs(\n, stdout); } if (FD_ISSET(0, readmask)) { if (fgets(send_line, MAXLINE, stdin) ! NULL) { if (strncmp(send_line, shutdown, 8) 0) { FD_CLR(0, allreads); if (shutdown(socket_fd, 1)) { error(1, errno, shutdown failed); } } else if (strncmp(send_line, close, 5) 0) { FD_CLR(0, allreads); if (close(socket_fd)) { error(1, errno, close failed); } sleep(6); exit(0); } else { int i strlen(send_line); if (send_line[i - 1] \n) { send_line[i - 1] 0; } printf(now sending %s\n, send_line); size_t rt write(socket_fd, send_line, strlen(send_line)); if (rt 0) { error(1, errno, write failed ); } printf(send bytes: %zu \n, rt); } } } } }我对这个程序的细节展开解释一下第一部分是套接字的创建和 select 初始化工作9-10 行创建了一个 TCP 套接字12-16 行设置了连接的目标服务器 IPv4 地址绑定到了指定的 IP 和端口18-22 行使用创建的套接字向目标 IPv4 地址发起连接请求30-32 行为使用 select 做准备初始化描述字集合这部分我会在后面详细解释这里就不再深入。第二部分是程序的主体部分从 33-80 行 使用 select 多路复用观测在连接套接字和标准输入上的 I/O 事件其中38-48 行当连接套接字上有数据可读将数据读入到程序缓冲区中。40-41 行如果有异常则报错退出42-43 行如果读到服务器端发送的 EOF 则正常退出。49-77 行当标准输入上有数据可读读入后进行判断。如果输入的是“shutdown”则关闭标准输入的 I/O 事件感知并调用 shutdown 函数关闭写方向如果输入的是“close”则调用 close 函数关闭连接64-74 行处理正常的输入将回车符截掉调用 write 函数通过套接字将数据发送给服务器端。服务器端程序稍微简单一点连接建立之后打印出接收的字节并重新格式化后发送给客户端。服务器端程序有一点需要注意那就是对 SIGPIPE 这个信号的处理。后面我会结合程序的结果展开说明。#include lib/common.h static int count; static void sig_int(int signo) { printf(\nreceived %d datagrams\n, count); exit(0); } int main(int argc, char **argv) { int listenfd; listenfd socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in server_addr; bzero(server_addr, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); server_addr.sin_port htons(SERV_PORT); int rt1 bind(listenfd, (struct sockaddr *) server_addr, sizeof(server_addr)); if (rt1 0) { error(1, errno, bind failed ); } int rt2 listen(listenfd, LISTENQ); if (rt2 0) { error(1, errno, listen failed ); } signal(SIGINT, sig_int); signal(SIGPIPE, SIG_IGN); int connfd; struct sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); if ((connfd accept(listenfd, (struct sockaddr *) client_addr, client_len)) 0) { error(1, errno, bind failed ); } char message[MAXLINE]; count 0; for (;;) { int n read(connfd, message, MAXLINE); if (n 0) { error(1, errno, error read); } else if (n 0) { error(1, 0, client closed \n); } message[n] 0; printf(received %d bytes: %s\n, n, message); count; char send_line[MAXLINE]; sprintf(send_line, Hi, %s, message); sleep(5); int write_nc send(connfd, send_line, strlen(send_line), 0); printf(send bytes: %zu \n, write_nc); if (write_nc 0) { error(1, errno, error write); } } }服务器端程序的细节也展开解释一下第一部分是套接字和连接创建过程11-12 行创建了一个 TCP 套接字14-18 行设置了本地服务器 IPv4 地址绑定到了 ANY 地址和指定的端口20-40 行使用创建的套接字依次执行 bind、listen 和 accept 操作完成连接建立。第二部分是程序的主体通过 read 函数获取客户端传送来的数据流并回送给客户端51-52 行显示收到的字符串在 56 行对原字符串进行重新格式化之后调用 send 函数将数据发送给客户端。注意在发送之前让服务器端程序休眠了 5 秒以模拟服务器端处理的时间。我们启动服务器再启动客户端依次在标准输入上输入 data1、data2 和 close观察一段时间后我们看到$./graceclient 127.0.0.1 data1 now sending data1 send bytes:5 data2 now sending data2 send bytes:5 close$./graceserver received 5 bytes: data1 send bytes: 9 received 5 bytes: data2 send bytes: 9 client closed客户端依次发送了 data1 和 data2服务器端也正常接收到 data1 和 data2。在客户端 close 掉整个连接之后服务器端接收到 SIGPIPE 信号直接退出。客户端并没有收到服务器端的应答数据。我在下面放了一张图这张图详细解释了客户端和服务器端交互的时序图。因为客户端调用 close 函数关闭了整个连接当服务器端发送的“Hi, data1”分组到底时客户端给回送一个 RST 分组服务器端再次尝试发送“Hi, data2”第二个应答分组时系统内核通知 SIGPIPE 信号。这是因为在 RST 的套接字进行写操作会直接触发 SIGPIPE 信号。这回知道你的程序莫名其妙终止的原因了吧。我们可以像这样注册一个信号处理函数对 SIGPIPE 信号进行处理避免程序莫名退出static void sig_pipe(int signo) { printf(\nreceived %d datagrams\n, count); exit(0); } signal(SIGINT, sig_pipe);接下来再次启动服务器再启动客户端依次在标准输入上输入 data1、data2 和 shutdown 函数观察一段时间后我们看到$./graceclient 127.0.0.1 data1 now sending data1 send bytes:5 data2 now sending data2 send bytes:5 shutdown Hi, data1 Hidata2 server terminated$./graceserver received 5 bytes: data1 send bytes: 9 received 5 bytes: data2 send bytes: 9 client closed和前面的结果不同服务器端输出了 data1、data2客户端也输出了“Hi,data1”和“Hi,data2”客户端和服务器端各自完成了自己的工作后正常退出。我们再看下客户端和服务器端交互的时序图。因为客户端调用 shutdown 函数只是关闭连接的一个方向服务器端到客户端的这个方向还可以继续进行数据的发送和接收所以“Hi,data1”和“Hi,data2”都可以正常传送当服务器端读到 EOF 时立即向客户端发送了 FIN 报文客户端在 read 函数中感知了 EOF也进行了正常退出。总结在这一讲中我们讲述了 close 函数关闭连接的方法使用 close 函数关闭连接有两个需要明确的地方。close 函数只是把套接字引用计数减 1未必会立即关闭连接close 函数如果在套接字引用计数达到 0 时立即终止读和写两个方向的数据传送。基于这两点在期望关闭连接其中一个方向时应该使用 shutdown 函数。