假设以下场景:
在 tcp 建立连接后,先主动关闭其服务端,之后再在客户端下对其 socket 进行写操作,正常思维都会认为,这个写操作肯定会返回错误吧?
还真不一定。
今天在写代码时就遇到了这个问题,还纠结了挺久的,最后翻了下 linux 内核源码,才确定了答案。
先用下面的程序模拟下这个场景:
#include <arpa/inet.h>
#include <assert.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdio.h>
#include <strings.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
int tcp_connect() {
int sockfd, err;
struct sockaddr_in addr;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
assert(sockfd != -1);
bzero(&addr, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
addr.sin_port = htons(9999);
err = connect(sockfd, (struct sockaddr *)&addr, sizeof(addr));
assert(err == 0);
return sockfd;
}
int main(int argc, char **argv) {
int n;
int sockfd = tcp_connect();
signal(SIGPIPE, SIG_IGN); // 防止 write 触发 SIGPIPE,便于测试
printf("请于 5 秒钟内关闭服务端...\n");
sleep(5);
// write 1
n = write(sockfd, "hello\n", 6);
if (n == -1) {perror("第一次 write 失败");
return -1;
}
assert(n == 6);
printf("第一次 write 成功!\n");
sleep(1); // 确保客户端收到 tcp 的 reset 消息
// write 2
n = write(sockfd, "world\n", 6);
if (n == -1) {perror("第二次 write 失败");
return -1;
}
assert(n == 6);
printf("第二次 write 成功!\n");
return 0;
}
这段程序代表客户端,服务端就用 ncat 来模拟。
下面是执行流程:
先打开一个 terminal,用 ncat 开一个服务端:
$ ncat -l 9999
再打开另一个 terminal,编译上面的程序,然后执行:
$ gcc main.c
$ ./a.out
请于 5 秒钟内关闭服务端...
第一次 write 成功!
第二次 write 失败: Broken pipe
当客户端提示关闭服务端时,要切换到对应的 terminal,关闭服务端。
从上面的输出可以看到,之后的两次写,第一次成功了,第二次才失败。
奇怪吧。
我们用 tcpdump 抓包看下,第一次是否是真的写成功了:
$ sudo tcpdump -i any -n# port 9999
1 17:59:07.812599 IP 127.0.0.1.51614 > 127.0.0.1.9999: Flags [S], seq 1076934668, win 65495, options [mss 65495,sackOK,TS val 134308422 ecr 0,nop,wscale 7], length 0
2 17:59:07.812648 IP 127.0.0.1.9999 > 127.0.0.1.51614: Flags [S.], seq 3833531274, ack 1076934669, win 65483, options [mss 65495,sackOK,TS val 134308422 ecr 134308422,nop,wscale 7], length 0
3 17:59:07.812691 IP 127.0.0.1.51614 > 127.0.0.1.9999: Flags [.], ack 1, win 512, options [nop,nop,TS val 134308422 ecr 134308422], length 0
4 17:59:09.832579 IP 127.0.0.1.9999 > 127.0.0.1.51614: Flags [F.], seq 1, ack 1, win 512, options [nop,nop,TS val 134310442 ecr 134308422], length 0
5 17:59:09.835181 IP 127.0.0.1.51614 > 127.0.0.1.9999: Flags [.], ack 2, win 512, options [nop,nop,TS val 134310445 ecr 134310442], length 0
6 17:59:12.813697 IP 127.0.0.1.51614 > 127.0.0.1.9999: Flags [P.], seq 1:7, ack 2, win 512, options [nop,nop,TS val 134313423 ecr 134310442], length 6
7 17:59:12.813735 IP 127.0.0.1.9999 > 127.0.0.1.51614: Flags [R], seq 3833531276, win 0, length 0
还真是成功了,看上面第 6 个包,发送的数据长度是 6,即:我们代码中的 hellon。
这里大概解释下 tcpdump 的输出:
前三个包是 tcp 的三次握手,完成之后代表 tcp 建立连接成功。
第四个包是我们在关闭服务端时,服务端发给客户端的 fin 包,表示关闭连接请求。
第五个包是客户端发给服务端的 tcp 层的 ack,表示已经收到 fin 包。
第六个包是客户端发给服务端的 hello\n 字符串。
第七个包是服务端的 tcp 层发给客户端的 reset 包,因为此时服务端的 socket 已经关闭了。
由 tcpdump 的输出可以确定,第一次 write 的确是写成功了,但为什么呢?明明服务端的 socket 都已经关闭了,为什么还可以发送呢?并且为什么第一次可以发送,第二次就不行了呢?
来看下内核源码是怎么做的:
// net/ipv4/tcp_input.c
int tcp_sendmsg_locked(struct sock *sk, struct msghdr *msg, size_t size)
{
...
err = -EPIPE;
if (sk->sk_err || (sk->sk_shutdown & SEND_SHUTDOWN))
goto do_error;
...
// 省略这部分是 tcp 发送数据的代码
...
return copied + copied_syn;
...
do_error:
...
return err;
}
EXPORT_SYMBOL_GPL(tcp_sendmsg_locked);
该方法就是 tcp 发消息的方法。
由上可见,只有当 socket 发生错误时,或者我们关闭了 socket 的 send 端,上面的 write 方法才会返回错误,其他情况下,write 的数据都会正常发送。
由 tcp 的相关知识我们可以知道,当服务端发送 fin 消息给客户端时,客户端的 socket 进入了 CLOSE_WAIT 状态,即:等待客户端的程序关闭其 socket。
也就是说,fin 消息并没有使客户端的 socket 发生错误,也并没有关闭客户端 socket 的 send 端(但是关闭了客户端 socket 的 receive 端),所以第一次 write 就成功的将数据发送出去了。
那第二次 write 为什么失败呢?
看上面 tcpdump 的输出就知道了,当第一次 write 之后,服务端的操作系统收到数据,发现其对应的 socket 已经关闭了,所以就发送了个 reset 包给客户端。
客户端在收到 reset 包后,执行了下面的代码:
// net/ipv4/tcp_input.c
void tcp_reset(struct sock *sk)
{
...
switch (sk->sk_state) {
...
case TCP_CLOSE_WAIT:
sk->sk_err = EPIPE;
break;
...
}
...
tcp_done(sk);
...
}
由上可见,sk->sk_err 被设置为了 EPIPE,其实,在下面的 tcp_done 方法里,也关闭了 socket 的 send 端,不过这个已经影响不大了。
所以,在我们第二次调用 write 时,当执行到 tcp_sendmsg_locked 方法时,就直接跳到了 do_error,即:返回 err 给用户。
至此,就完美解释了,为什么会有上述奇怪的现象。
其实,我们不用看代码,仔细想想 tcp 的细节,也是可以理解,操作系统为什么会有这样的行为。
在第一次 write 之前,我们的 socket 收到 fin 包,进入到 CLOSE_WAIT 状态,此时,其实并不能说明服务端已经完全关闭了连接,它还有可能是发送 fin 包,只是为了关闭其 send 端,但它还是可以读的,所以我们理应也可以继续写。
这样想就更容易明白些了吧。
不过,从源码角度看这个问题,还是来的更实在些。
如果有对 tcp 源码有兴趣的同学,可以看下我之前写的 tcp 源码分析系列文章:
TCP/IP 状态转换图及源码分析文章列表
完。
更多原创文章,请关注我微信公众号: