首页
学习
活动
专区
圈层
工具
发布
50 篇文章
1
图文并茂VLAN详解,让你看一遍就理解VLAN
2
做了几年的网工也未必了解VLAN和VXLAN的区别,今天我来告诉你!
3
全网内容最全,质量最高的MPLS及MPLS VPN技术详解,瑞哥力荐!
4
Google BBR拥塞控制算法背后的数学解释 | 深度
5
QUIC 0-RTT实现简析及一种分布式的0-RTT实现方案
6
AGPS定位基本原理浅析
7
Golang语言情怀-第56期 Go 语言标准库翻译 crypto/cipher
8
神锁离线版插件端到端加密比HTTPS更安全
9
基于 TLS 1.3的微信安全通信协议 mmtls 介绍(上)
10
基于 TLS 1.3的微信安全通信协议 mmtls 介绍(下)
11
24位腾讯云专家精彩演讲,4万字《腾讯云技术实践精选集 2021》发布!(附合集下载)
12
浅谈VPC二三,秒懂秒透
13
提速 30%!腾讯TQUIC 网络传输协议
14
25 张图,一万字,拆解 Linux 网络包发送过程
15
nginx http模块数据存储结构
16
多机房多活,多机房平滑迁移架构方案全集(上+中+下)
17
小孩都看得懂的多臂老虎机和汤姆森采样
18
什么是 Go runtime.KeepAlive?
19
Rust 热点| Discord 为什么从 Go 切换到了 Rust
20
用户态 tcpdump 如何实现抓到内核网络包的?
21
一文读懂 | coredump文件是如何生成的
22
网工知识大扫盲——三层交换技术
23
聊聊 top 命令中的 CPU 使用率
24
什么是HDFS的纠删码
25
Linux ptrace 的实现
26
天天讲路由,那 Linux 路由到底咋实现的!?
27
Linux系统研究 - 操作系统是如何管理tcp连接的 (2)
28
一个有关tcp的非常有意思的问题
29
对上一篇文章中tcp问题的进一步思考
30
epoll和shutdown使用不当可能导致死循环
31
socket的SO_REUSEADDR参数全面分析
32
多进程可以监听同一端口吗
33
golang | 是返回struct还是返回struct的指针
34
​TCP 拥塞控制详解
35
C|网络|TCP-BBR拥塞控制剖析
36
如何使用 Go 语言写游戏服务器?
37
Nginx 日志 worker_connections are not enough while connecting to upstream
38
如何用九条命令在一分钟内检查Linux服务器性能?
39
服务器病了吗? Linux 服务器的那些性能参数指标
40
边缘计算比云计算强在哪里?终于有人讲明白了
41
详解边缘计算系统逻辑架构:云、边、端协同
42
边缘计算成为下一个爆发点,云计算巨头和CDN巨头谁会赢?
43
告知你不为人知的 UDP:连接性和负载均衡
44
为什么需要智能网卡?
45
从CDN到边缘计算,近水楼台是否先得月?
46
经典网络还是VPC,开发者作何选择?
47
Linux内核网络udp数据包发送(二)——UDP协议层分析
48
有没有人告诉你—写时拷贝的真相
49
[linux][network]net bridge技术分析
50
软硬件融合技术内幕 进阶篇 (1) —— 从小霸王到云计算

一个有关tcp的非常有意思的问题

假设以下场景:

在tcp建立连接后,先主动关闭其服务端,之后再在客户端下对其socket进行写操作,正常思维都会认为,这个写操作肯定会返回错误吧?

还真不一定。

今天在写代码时就遇到了这个问题,还纠结了挺久的,最后翻了下linux内核源码,才确定了答案。

先用下面的程序模拟下这个场景:

代码语言:javascript
复制
#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开一个服务端:

代码语言:javascript
复制
$ ncat -l 9999

再打开另一个terminal,编译上面的程序,然后执行:

代码语言:javascript
复制
$ gcc main.c
$ ./a.out
请于5秒钟内关闭服务端...
第一次write成功!
第二次write失败: Broken pipe

当客户端提示关闭服务端时,要切换到对应的terminal,关闭服务端。

从上面的输出可以看到,之后的两次写,第一次成功了,第二次才失败。

奇怪吧。

我们用tcpdump抓包看下,第一次是否是真的写成功了:

代码语言:javascript
复制
$ 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,即:我们代码中的hello\n。

这里大概解释下tcpdump的输出:

前三个包是tcp的三次握手,完成之后代表tcp建立连接成功。

第四个包是我们在关闭服务端时,服务端发给客户端的fin包,表示关闭连接请求。

第五个包是客户端发给服务端的tcp层的ack,表示已经收到fin包。

第六个包是客户端发给服务端的hello\n字符串。

第七个包是服务端的tcp层发给客户端的reset包,因为此时服务端的socket已经关闭了。

由tcpdump的输出可以确定,第一次write的确是写成功了,但为什么呢?明明服务端的socket都已经关闭了,为什么还可以发送呢?并且为什么第一次可以发送,第二次就不行了呢?

来看下内核源码是怎么做的:

代码语言:javascript
复制
// 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包后,执行了下面的代码:

代码语言:javascript
复制
// 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 状态转换图及源码分析文章列表

完。

下一篇
举报
领券