目录
摘要
通过 shutdown() 关闭读写操作,会发生什么?具体点呢,考虑两个场景:
场景一:C 发送数据完毕,想调用 shutdown 关闭写操作,这时候 TCP 数据包抓包是否可以看出 C 执行了这个操作;S 往 C 发数据后,C 是否还回 ACK?
场景二:C 读取数据完毕,想调用 shutdown 关闭读操作,这时候 TCP 数据包抓包是否可以看出 C 执行了这个操作?S 继续往 C 发数据,整条 TCP 数据流会发生什么情况?
文中引用 Linux 内核源码基于版本 5.4.259,并做了一些删减以提高可读性。
1 API
先来看下 shutdown 的接口:
NAME
shutdown - shut down part of a full-duplex connection
SYNOPSIS
#include <sys/socket.h>
int shutdown(int sockfd, int how);
DESCRIPTION
The shutdown() call causes all or part of a full-duplex connection on the socket associated with sockfd
to be shut down. If how is SHUT_RD, further receptions will be disallowed. If how is SHUT_WR, further
transmissions will be disallowed. If how is SHUT_RDWR, further receptions and transmissions will be dis‐
allowed.
RETURN VALUE
On success, zero is returned. On error, -1 is returned, and errno is set appropriately.
shutdown 接口比较简单,仅需只需要传入文件描述符与执行的动作这两个参数即可。
2 shutdown(sockfd, SHUT_WR)
先来看场景一:C 发送数据完毕,想调用 shutdown 关闭写操作,这时候 TCP 数据包抓包是否可以看出 C 执行了这个操作;S 往 C 发数据后,C 是否还回 ACK?
能否看出 C 执行了这个操作呢?那我们需要知道 close 的表现:如果是 close 一个 socket,相当于直接关闭了读写,发 FIN,后续收到包会回 RST。
对于 shutdown 关闭写的场景,,只是关闭了写,那还是可以读的,所以 C 仍然会继续回 ACK,能否看出 C 执行了这个操作呢?关闭写应当会发送一个 FIN,而后续收到数据又会继续回 ACK, 所以应该是能区分出来才对?最好的方式就是写个代码验证了:
搞一个 server:
void server_process(int sock)
{
char buf[10240];
while (1) {
ssize_t ret = recv(sock, buf, sizeof(buf), 0);
if (ret < 0) {
if (errno != EAGAIN) {
printf("server recv failed: %s\n", strerror(errno));
break;
}
continue;
}
if (ret == 0) {
printf("read end!\n");
break;
}
buf[ret] = 0;
size_t ret_s = send(sock, buf, ret, 0);
printf("resp:%s %d/%d\n", buf, ret_s, ret);
}
}
int do_server()
{
int sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock < 0) {
printf("server socket failed: %s\n", strerror(errno));
return -1;
}
uint32_t ip;
inet_aton(g_server_ip, (struct in_addr *)&ip);
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = ip;
addr.sin_port = htons(g_server_port);
if (bind(sock, (struct sockaddr *)&addr, (socklen_t)sizeof(addr)) < 0) {
printf("server bind failed: %s\n", strerror(errno));
return -1;
}
if (listen(sock, 10) < 0) {
printf("listen failed: %s\n", strerror(errno));
return -1;
}
while (1) {
int new_sock = accept(sock, NULL, NULL);
if (new_sock < 0) {
continue;
}
server_process(new_sock);
close(new_sock);
}
return 0;
}
有 server 必有 client:
int do_client()
{
int sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock < 0) {
printf("client socket failed: %s\n", strerror(errno));
return -1;
}
uint32_t ip;
inet_aton(g_server_ip, (struct in_addr *)&ip);
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = ip;
addr.sin_port = htons(g_server_port);
if (connect(sock, (struct sockaddr *)&addr, (socklen_t)sizeof(addr)) < 0) {
printf("client connect failed: %s\n", strerror(errno));
return -1;
}
char buf[10240];
int i = 0;
while (1) {
int n = snprintf(buf, sizeof(buf), "echo %d", i++);
size_t ret_s = send(sock, buf, n, 0);
if (ret_s != n) {
break;
}
//if (shutdown(sock, SHUT_RD) < 0) {
// printf("shutdown failed: %s\n", strerror(errno));
//}
ssize_t ret = recv(sock, buf, sizeof(buf), 0);
if (ret == 0) {
printf("read end!\n");
break;
}
if (ret < 0) {
if (errno != EAGAIN) {
printf("client recv failed: %s\n", strerror(errno));
break;
}
continue;
}
buf[ret] = 0;
printf("resp:%s %d/%d\n", buf, ret, n);
sleep(1);
}
return 0;
}
我们用 client 模拟角色 C,server 模拟角色 S,通过在 client 中添加 shutdown 调用复现场景。修改代码前,默认输出如下:
我们在 client 发送后,shutdown 关闭写,并通过 sleep 阻塞住循环,观察输出与抓包结果:
可以看出,client shutdown 关闭写发了一个 FIN,随后server 回了 length 6 的数据,并且 client 仍然继续响应了 ACK。所以是可以跟 close 关闭 socket 区分开的。
3 shutdown(sockfd, SHUT_WR)
同样的,修改代码,client 发送之后关闭读,修改后 client 代码如下:
int do_client()
{
int sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock < 0) {
printf("client socket failed: %s\n", strerror(errno));
return -1;
}
uint32_t ip;
inet_aton(g_server_ip, (struct in_addr *)&ip);
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = ip;
addr.sin_port = htons(g_server_port);
if (connect(sock, (struct sockaddr *)&addr, (socklen_t)sizeof(addr)) < 0) {
printf("client connect failed: %s\n", strerror(errno));
return -1;
}
char buf[10240];
int i = 0;
while (1) {
int n = snprintf(buf, sizeof(buf), "echo %d", i++);
size_t ret_s = send(sock, buf, n, 0);
if (ret_s != n) {
break;
}
if (shutdown(sock, SHUT_RD) < 0) {
printf("shutdown failed: %s\n", strerror(errno));
}
/*ssize_t ret = recv(sock, buf, sizeof(buf), 0);
if (ret == 0) {
printf("read end!\n");
break;
}
if (ret < 0) {
if (errno != EAGAIN) {
printf("client recv failed: %s\n", strerror(errno));
break;
}
continue;
}
buf[ret] = 0;
printf("resp:%s %d/%d\n", buf, ret, n);*/
sleep(100);
}
return 0;
}
直接看输出:
数据流看不出变化,跟未执行 shutdown 关闭读操作的 TCP 流表现是一样的。
4 kernel 是怎么做的?
直接看下 shutdown 的源码就知道了,用户层调用 shutdown,首先通过系统调用进来,随后调用到 inet 层的 inet_shutdown 函数:
// net/ipv4/af_inet.c
int inet_shutdown(struct socket *sock, int how)
{
struct sock *sk = sock->sk;
int err = 0;
// 一些状态检查
switch (sk->sk_state) {
case TCP_CLOSE:
err = -ENOTCONN;
/* Hack to wake up other listeners, who can poll for
EPOLLHUP, even on eg. unconnected UDP sockets -- RR */
/* fall through */
default:
sk->sk_shutdown |= how;
if (sk->sk_prot->shutdown)
sk->sk_prot->shutdown(sk, how);
break;
/* Remaining two branches are temporary solution for missing
* close() in multithreaded environment. It is _not_ a good idea,
* but we have no choice until close() is repaired at VFS level.
*/
case TCP_LISTEN:
if (!(how & RCV_SHUTDOWN))
break;
/* fall through */
case TCP_SYN_SENT:
err = sk->sk_prot->disconnect(sk, O_NONBLOCK);
sock->state = err ? SS_DISCONNECTING : SS_UNCONNECTED;
break;
}
// 设置tcp连接状态, 比如从 estab -> fin_wait1
sk->sk_state_change(sk);
release_sock(sk);
return err;
}
我们看已连接场景下的流程,也就是 switch 中的 default分支,这里将 how 动作保存在了 shutdown 标记中,然后继续调用到 tcp 协议自己的 shutdown:
// net/ipv4/tcp.c
void tcp_shutdown(struct sock *sk, int how)
{
if (!(how & SEND_SHUTDOWN))
return;
/* If we've already sent a FIN, or it's a closed state, skip this. */
if ((1 << sk->sk_state) &
(TCPF_ESTABLISHED | TCPF_SYN_SENT |
TCPF_SYN_RECV | TCPF_CLOSE_WAIT)) {
/* Clear out any half completed packets. FIN if needed. */
if (tcp_close_state(sk))
tcp_send_fin(sk);
}
}
tcp_shutdown 中,首先判断不是关闭写的话,就直接 return 了,所以 shutdown 关闭读,真的就只是记录了一个标记,连 socket 状态也没有发生改变。如果是关闭写,则会走到 tcp_close_state、tcp_send_fin,其实就是将 state 转移到下一个状态,即 FIN_WAIT1:
static const unsigned char new_state[16] = {
/* current state: new state: action: */
[0 /* (Invalid) */] = TCP_CLOSE,
[TCP_ESTABLISHED] = TCP_FIN_WAIT1 | TCP_ACTION_FIN,
...
};
static int tcp_close_state(struct sock *sk)
{
int next = (int)new_state[sk->sk_state];
int ns = next & TCP_STATE_MASK;
tcp_set_state(sk, ns);
return next & TCP_ACTION_FIN;
}
看到这里,我们也能将原理同测试的现象对应起来了,也就那样~
附
最后附上完整的测试代码,有 linux 和 windows 的:
https://github.com/Fireplusplus/Linux/tree/master/tcp_shutdown
另外,windows 下关闭读的表现不太一样,C 继续收到数据会回 RST, 并且 C 继续 send 也会失败,真是无语!