Skip to content

TCP 两次挥手

前面在四次挥手中提到,关闭的时候双方都发出了一个 FIN 和收到了一个 ACK

正常情况下 TCP 连接的两端,是不同IP+端口的进程。

但如果 TCP 连接的两端,IP+端口是一样的情况下,那么在关闭连接的时候,也同样做到了一端发出了一个 FIN,也收到了一个 ACK,只不过正好这两端其实是同一个socket

TCP两次挥手

而这种两端IP+端口都一样的连接,叫TCP 自连接

是的,你没看错,我也没打错别字。同一个 socket 确实可以自己连自己,形成一个连接。


一个 socket 能建立连接?

上面提到了,同一个客户端 socket,自己对自己发起连接请求。是可以成功建立连接的。这样的连接,叫TCP 自连接

下面我们尝试下复现。

注意我是在以下系统进行的实验。在mac上多半无法复现。

sh
#  cat /etc/os-release
NAME="CentOS Linux"
VERSION="7 (Core)"
ID="centos"
ID_LIKE="rhel fedora"
VERSION_ID="7"
PRETTY_NAME="CentOS Linux 7 (Core)"

通过nc命令可以很简单的创建一个TCP 自连接

sh
# nc -p 6666 127.0.0.1 6666

上面的 -p 可以指定源端口号。也就是指定了一个端口号为6666的客户端去连接 127.0.0.1:6666

sh
# netstat -nt | grep 6666
tcp        0      0 127.0.0.1:6666          127.0.0.1:6666          ESTABLISHED

整个过程中,都没有服务端参与。可以抓个包看下。

image-20210810093309117

可以看到,相同的 socket,自己连自己的时候,握手是三次的。挥手是两次的。

TCP自连接

上面这张图里,左右都是同一个客户端,把它画成两个是为了方便大家理解状态的迁移。

我们可以拿自连接的握手状态对比下正常情况下的 TCP 三次握手。

正常情况下的TCP三次握手

看了自连接的状态图,再看看下面几个问题。


一端发出第一次握手后,如果又收到了第一次握手的 SYN 包,TCP 连接状态会怎么变化?

第一次握手过后,连接状态就变成了SYN_SENT状态。如果此时又收到了第一次握手的 SYN 包,那么连接状态就会从SYN_SENT状态变成SYN_RCVD

c
// net/ipv4/tcp_input.c
static int tcp_rcv_synsent_state_process()
{
    // SYN_SENT状态下,收到SYN包
	if (th->syn) {
        // 状态置为 SYN_RCVD
		tcp_set_state(sk, TCP_SYN_RECV);
	}
}

一端发出第二次握手后,如果又收到第二次握手的 SYN+ACK 包,TCP 连接状态会怎么变化?

第二握手过后,连接状态就变为SYN_RCVD了,此时如果再收到第二次握手的SYN+ACK包。连接状态会变为ESTABLISHED

c
// net/ipv4/tcp_input.c
int tcp_rcv_state_process()
{
    // 前面省略很多逻辑,能走到这就认为肯定有ACK
	if (true) {
        // 判断下这个ack是否合法
		int acceptable = tcp_ack(sk, skb, FLAG_SLOWPATH | FLAG_UPDATE_TS_RECENT) > 0;
		switch (sk->sk_state) {
		case TCP_SYN_RECV:
			if (acceptable) {
        // 状态从 SYN_RCVD 转为 ESTABLISHED
				tcp_set_state(sk, TCP_ESTABLISHED);
			}
		}
	}
}

一端第一次挥手后,又收到第一次挥手的包,TCP 连接状态会怎么变化?

第一次挥手过后,一端状态就会变成 FIN-WAIT-1。正常情况下,是要等待第二次挥手的ACK。但实际上却等来了 一个第一次挥手的 FIN包, 这时候连接状态就会变为CLOSING

c
// net/
static void tcp_fin(struct sock *sk)
{
	switch (sk->sk_state) {
	case TCP_FIN_WAIT1:
		tcp_send_ack(sk);
    // FIN-WAIT-1状态下,收到了FIN,转为 CLOSING
		tcp_set_state(sk, TCP_CLOSING);
		break;
	}
}

这可以说是隐藏剧情了。

CLOSING 很少见,除了出现在自连接关闭外,一般还会出现在 TCP 两端同时关闭连接的情况下。

处于CLOSING状态下时,只要再收到一个ACK,就能进入 TIME-WAIT 状态,然后等个2MSL,连接就彻底断开了。这跟正常的四次挥手还是有些差别的。大家可以滑到文章开头的 TCP 四次挥手再对比下。


代码复现自连接

可能大家会产生怀疑,这是不是nc这个软件本身的bug

那我们可以尝试下用strace看看它内部都做了啥。

sh
# strace nc -p 6666 127.0.0.1 6666
// ...
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3
fcntl(3, F_GETFL)                       = 0x2 (flags O_RDWR)
fcntl(3, F_SETFL, O_RDWR|O_NONBLOCK)    = 0
setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(3, {sa_family=AF_INET, sin_port=htons(6666), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
connect(3, {sa_family=AF_INET, sin_port=htons(6666), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 EINPROGRESS (Operation now in progress)
// ...

无非就是以创建了一个客户端socket句柄,然后对这个句柄执行 bind, 绑定它的端口号是6666,然后再向 127.0.0.1:6666发起connect方法。

我们可以尝试用C语言去复现一遍。

下面的代码,只用于复现问题。直接跳过也完全不影响阅读。

c
#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <ctype.h>
#include <string.h>
#include <strings.h>


int main()
{
    int lfd, cfd;
    struct sockaddr_in serv_addr, clie_addr;
    socklen_t clie_addr_len;
    char buf[BUFSIZ];
    int n = 0, i = 0, ret = 0 ;
    printf("This is a client \n");

    /*Step 1: 创建客户端端socket描述符cfd*/
    cfd = socket(AF_INET, SOCK_STREAM, 0);
    if(cfd == -1)
    {
        perror("socket error");
        exit(1);
    }

    int flag=1,len=sizeof(int);
	if( setsockopt(cfd, SOL_SOCKET, SO_REUSEADDR, &flag, len) == -1)
	{
		perror("setsockopt");
		exit(1);
	}


    bzero(&clie_addr, sizeof(clie_addr));
    clie_addr.sin_family = AF_INET;
    clie_addr.sin_port = htons(6666);
    inet_pton(AF_INET,"127.0.0.1", &clie_addr.sin_addr.s_addr);

    /*Step 2: 客户端使用bind绑定客户端的IP和端口*/
    ret = bind(cfd, (struct sockaddr* )&clie_addr, sizeof(clie_addr));
    if(ret != 0)
    {
        perror("bind error");
        exit(2);
    }

    /*Step 3: connect链接服务器端的IP和端口号*/
    bzero(&serv_addr, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(6666);
    inet_pton(AF_INET,"127.0.0.1", &serv_addr.sin_addr.s_addr);
    ret = connect(cfd,(struct sockaddr *)&serv_addr, sizeof(serv_addr));
    if(ret != 0)
    {
        perror("connect error");
        exit(3);
    }

    /*Step 4: 向服务器端写数据*/
    while(1)
    {
        fgets(buf, sizeof(buf), stdin);
        write(cfd, buf, strlen(buf));
        n = read(cfd, buf, sizeof(buf));
        write(STDOUT_FILENO, buf, n);//写到屏幕上
    }
    /*Step 5: 关闭socket描述符*/
    close(cfd);
    return 0;
}

保存为 client.c 文件,然后执行下面命令,会发现连接成功。

sh
# gcc client.c -o client && ./client
This is a client
sh
# netstat -nt | grep 6666
tcp        0      0 127.0.0.1:6666          127.0.0.1:6666          ESTABLISHED

说明,这不是 nc 的 bug。事实上,这也是内核允许的一种情况。


自连接的解决方案

自连接一般不太常见,但遇到了也不难解决。

解决方案比较简单,只要能保证客户端和服务端的端口不一致就行。

事实上,我们写代码的时候一般不会去指定客户端的端口,系统会随机给客户端分配某个范围内的端口。而这个范围,可以通过下面的命令进行查询

sh
# cat /proc/sys/net/ipv4/ip_local_port_range
32768   60999

也就是只要我们的服务器端口不在32768-60999这个范围内,比如设置为8888。就可以规避掉这个问题。

另外一个解决方案,可以参考golang标准网络库的实现,在连接建立完成之后判断下 IP 和端口是否一致,如果遇到自连接,则断开重试。

go
func dialTCP(net string, laddr, raddr *TCPAddr, deadline time.Time) (*TCPConn, error) {
	// 如果是自连接,这里会重试
	for i := 0; i < 2 && (laddr == nil || laddr.Port == 0) && (selfConnect(fd, err) || spuriousENOTAVAIL(err)); i++ {
		if err == nil {
			fd.Close()
		}
		fd, err = internetSocket(net, laddr, raddr, deadline, syscall.SOCK_STREAM, 0, "dial", sockaddrToTCP)
	}
    // ...
}

func selfConnect(fd *netFD, err error) bool {
	// 判断是否端口、IP一致
	return l.Port == r.Port && l.IP.Equal(r.IP)
}

四次握手

前面提到的TCP自连接是一个客户端自己连自己的场景。那不同客户端之间是否可以互联?

答案是可以的,有一种情况叫TCP 同时打开

TCP同时打开

大家可以对比下,TCP 同时打开在握手时的状态变化,跟 TCP 自连接是非常的像。

比如SYN_SENT状态下,又收到了一个SYN,其实就相当于自连接里,在发出了第一次握手后,又收到了第一次握手的请求。结果都是变成 SYN_RCVD

SYN_RCVD 状态下收到了 SYN+ACK,就相当于自连接里,在发出第二次握手后,又收到第二次握手的请求,结果都是变成 ESTABLISHED他们的源码其实都是同一块逻辑。


复现 TCP 同时打开

分别在两个控制台下,分别执行下面两行命令。

sh
while true; do nc -p 2224 127.0.0.1 2223 -v;done

while true; do nc -p 2223 127.0.0.1 2224 -v;done

上面两个命令的含义也比较简单,两个客户端互相请求连接对方的端口号,如果失败了则不停重试。

执行后看到的现象是,一开始会疯狂失败,重试。一段时间后,连接建立完成。

sh
# netstat -an | grep  2223
Proto Recv-Q Send-Q Local Address           Foreign Address         State
tcp        0      0 127.0.0.1:2224          127.0.0.1:2223          ESTABLISHED
tcp        0      0 127.0.0.1:2223          127.0.0.1:2224          ESTABLISHED

期间抓包获得下面的结果。

可以看到,这里面建立连接用了四次交互。因此可以说这是通过**"四次握手"**建立的连接。

而且更重要的是,这里面只涉及两个客户端,没有服务端

看到这里,不知道大家有没有跟我一样,被刷新了一波认知,对socket有了重新的认识。

在以前的观念里,建立连接,必须要有一个客户端和一个服务端,并且服务端还要执行一个listen()和一个accept()。而实际上,这些都不是必须的。

那么下次,面试官问你**"没有listen(), TCP 能建立连接吗?"**, 我想大家应该知道该怎么回答了。

但问题又来了,只有两个客户端,没有listen() ,为什么能建立TCP连接?

如果大家感兴趣,我们以后有机会再填上这个坑。


总结

  • 四次挥手中,不管是程序主动执行close(),还是进程被杀,都有可能发出第一次挥手FIN包。如果机器上FIN-WAIT-2状态特别多,一般是因为对端一直不执行close()方法发出第三次挥手。

  • Close()同时关闭发送和接收消息的功能。shutdown()单独关闭发送或接受消息。

  • 第二、第三次挥手,是有可能合在一起的。于是四次挥手就变成三次挥手了。

  • 同一个 socket 自己连自己,会产生TCP 自连接,自连接的挥手是两次挥手

  • 没有listen,两个客户端之间也能建立连接。这种情况叫TCP 同时打开,它由四次握手产生。


最后

今天提到的,不管是两次挥手,还是自连接,或是TCP 同时打开什么的。

咋一看,可能对日常搬砖没什么用,实际上也确实没什么用。

并且在面试上大概率也不会被问到。

毕竟一般面试官也不在意茴字有几种写法。

这篇文章的目的,主要是想从另外一个角度让大家重新认识下socket。原来TCP是可以自己连自己的,甚至两个客户端之间,不用服务端也能连起来。

这实在是,太出乎意料了。



如果文章对你有帮助,欢迎.....

算了。

兄弟们都是自家人,点不点赞,在不在看什么的,没关系的,大家看开心了就好。

在看,点赞什么的,我不是特别在意,真的,真的,别不信啊。

不三连也真的没关系的。

兄弟们不要在意啊。

我是虚伪的小白,我们下期见!


别说了,一起在知识的海洋里呛水吧

关注公众号:【小白 debug】


不满足于在留言区说骚话?

加我,我们建了个划水吹牛皮群,在群里,你可以跟你下次跳槽可能遇到的同事或面试官聊点阳间的话题。就超!开!心!

文章推荐:

正在精进