358 lines
16 KiB
Markdown
358 lines
16 KiB
Markdown
|
# 11 | 优雅地关闭还是粗暴地关闭 ?
|
|||
|
|
|||
|
你好,我是盛延敏,这里是网络编程实战第11讲,欢迎回来。
|
|||
|
|
|||
|
上一讲我们讲到了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信号。
|
|||
|
|
|||
|
这回知道你的程序莫名其妙终止的原因了吧。
|
|||
|
|
|||
|
![](https://static001.geekbang.org/resource/image/f2/9a/f283b804c7e33e25a900fedc8c36f09a.png?wh=4152*3256)
|
|||
|
我们可以像这样注册一个信号处理函数,对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
|
|||
|
Hi,data2
|
|||
|
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函数。
|
|||
|
|
|||
|
## 思考题
|
|||
|
|
|||
|
和往常一样,给你留两道思考题。
|
|||
|
|
|||
|
第一道题,你可以看到在今天的服务器端程序中,直接调用`exit(0)`完成了FIN报文的发送,这是为什么呢?为什么不调用close函数或shutdown函数呢?
|
|||
|
|
|||
|
第二道题关于信号量处理,今天的程序中,使用的是`SIG_IGN`默认处理,你知道默认处理和自定义函数处理的区别吗?不妨查查资料,了解一下。
|
|||
|
|
|||
|
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起来交流。
|
|||
|
|