你好,我是盛延敏,这里是网络编程实战第 11 讲,欢迎回来。
上一讲我们讲到了 TCP 的四次挥手,其中发起连接关闭的一方会有一段时间处于 TIME_WAIT 状态。那么究竟如何来发起连接关闭呢?这一讲我们就来讨论一下。
我们知道,一个 TCP 连接需要经过三次握手进入数据传输阶段,最后来到连接关闭阶段。在最后的连接关闭阶段,我们需要重点关注的是“半连接”状态。
因为 TCP 是双向的,这里说的方向,指的是数据流的写入 - 读出的方向。
比如客户端到服务器端的方向,指的是客户端通过套接字接口,向服务器端发送 TCP 报文;而服务器端到客户端方向则是另一个传输方向。在绝大数情况下,TCP 连接都是先关闭一个方向,此时另外一个方向还是可以正常进行数据传输。
举个例子,客户端主动发起连接的中断,将自己到服务器端的数据流方向关闭,此时,客户端不再往服务器端写入数据,服务器端读完客户端数据后就不会再有新的报文到达。但这并不意味着,TCP 连接已经完全关闭,很有可能的是,服务器端正在对客户端的最后报文进行处理,比如去访问数据库,存入一些数据;或者是计算出某个客户端需要的值,当完成这些操作之后,服务器端把结果通过套接字写给客户端,我们说这个套接字的状态此时是“半关闭”的。最后,服务器端才有条不紊地关闭剩下的半个连接,结束这一段 TCP 连接的使命。
当然,我这里描述的,是服务器端“优雅”地关闭了连接。如果服务器端处理不好,就会导致最后的关闭过程是“粗暴”的,达不到我们上面描述的“优雅”关闭的目标,形成的后果,很可能是服务器端处理完的信息没办法正常传送给客户端,破坏了用户侧的使用场景。
接下来我们就来看看关闭连接时,都有哪些方式呢?
close 函数
首先,我们来看最常见的 close 函数:
1
2
|
int close(int sockfd)
|
这个函数很简单,对已连接的套接字执行 close 操作就可以,若成功则为 0,若出错则为 -1。
这个函数会对套接字引用计数减一,一旦发现套接字引用计数到 0,就会对套接字进行彻底释放,并且会关闭TCP 两个方向的数据流。
套接字引用计数是什么意思呢?因为套接字可以被多个进程共享,你可以理解为我们给每个套接字都设置了一个积分,如果我们通过 fork 的方式产生子进程,套接字就会积分 +1,如果我们调用一次 close 函数,套接字积分就会 -1。这就是套接字引用计数的含义。
close 函数具体是如何关闭两个方向的数据流呢?
在输入方向,系统内核会将该套接字设置为不可读,任何读操作都会返回异常。
在输出方向,系统内核尝试将发送缓冲区的数据发送给对端,并最后向对端发送一个 FIN 报文,接下来如果再对该套接字进行写操作会返回异常。
如果对端没有检测到套接字已关闭,还继续发送报文,就会收到一个 RST 报文,告诉对端:“Hi, 我已经关闭了,别再给我发数据了。”
我们会发现,close 函数并不能帮助我们关闭连接的一个方向,那么如何在需要的时候关闭一个方向呢?幸运的是,设计 TCP 协议的人帮我们想好了解决方案,这就是 shutdown 函数。
shutdown 函数
shutdown 函数的原型是这样的:
1
2
|
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 对象的处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
|
# 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 这个信号的处理。后面我会结合程序的结果展开说明。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
|
#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,观察一段时间后我们看到:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
$./graceclient 127.0.0.1
data1
now sending data1
send bytes:5
data2
now sending data2
send bytes:5
Hi,data1
close
|
1
2
3
4
5
6
7
8
9
10
11
12
|
$./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 信号进行处理,避免程序莫名退出:
1
2
3
4
5
6
7
8
9
10
|
static void sig_pipe(int signo) {
printf("\nreceived %d datagrams\n", count);
exit(0);
}
signal(SIGINT, sig_pipe);
|
接下来,再次启动服务器,再启动客户端,依次在标准输入上输入 data1、data2 和 shutdown 函数,观察一段时间后我们看到:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
$./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
|
1
2
3
4
5
6
7
8
9
10
11
12
|
$./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
默认处理,你知道默认处理和自定义函数处理的区别吗?不妨查查资料,了解一下。
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起来交流。