前言
在socket的编程中偶然发现了两端同时connect可以不经过listen-accept而建立tcp连接,然后去找了许多资料,发现这个现象叫同时打开
,下面就来具体研究这个连接的建立情况。
同时打开需要去连接对方的端口,因此客户在connect之前需要bind一个端口。
参考资料:
- 1)(33条消息) 关于TCP同时打开-无需Listener的TCP连接建立过程_dog250的博客-CSDN博客
- 2)TCP连接建立 之 同时打开 - 走看看 (zoukankan.com)
- 3)(33条消息) 3.4 同时打开_Remy1119的博客-CSDN博客
- 4)动图图解!没有accept,能建立TCP连接吗? - 知乎 (zhihu.com)
- 5)一道腾讯面试题目:没有listen,能否建立TCP连接 - 知乎 (zhihu.com)
- 6)TCP的握手(三次、同时)与挥手(四次、同时)理解 - 掘金 (juejin.cn)
在参考资料的基础上,整理了整个逻辑,并补充了序列号确认的流程和能同步的原因,以及syn丢包时能成功建立的原因。
connect与accept
我从tcp正常的建立说起。
在简单的服务器-客户端模型中,服务器在执行listen()
方法之后还会执行一个accept()
方法,并阻塞等待客户端连接。客户端在创建socket后可以调用connect()
方法来连接服务器。这个过程中,TCP的三次握手究竟什么时候完成呢?
在参考资料4中,作者在执行accept前进行sleep等待,让客户端直接去connect并抓包。作者的抓包结果如下:
从抓包结果看来,就算不执行accept()方法,三次握手照常进行,并顺利建立连接。
从listen开始说起,在服务器执行listen方法后,就会进入监听(LISTEN)状态,内核会为每个处于监听状态的socket分配两个队列:半连接队列和全连接队列。相信这两个队列大家也不陌生了。
- 半连接队列(SYN队列),服务端收到第一次握手后,会将
socket
加入到这个队列中,队列内的socket
都处于SYN_RECV
状态。然后发回syn+ack执行第二次握手。 - 全连接队列(ACCEPT队列),在服务端收到第三次握手后,会将半连接队列的
socket
取出,放到全连接队列中。队列里的socket
都处于ESTABLISHED
状态。这里面的连接,就等着服务端执行accept()后被取出了。
这就是说,accept实际上只是从全连接队列取出一条连接,不参与TCP三次握手的过程。
这两个队列将详细点,全连接队列(icsk_accept_queue)是个链表,而半连接队列(syn_table)是个哈希表。
这是因为全连接队列只需要把头部的连接给accept即可,维护一个线性结构就可以了。
而半连接队列中的连接都不是完整的连接,在等待第三次握手的到来。当第三次握手到来时,要根据IP端口来看是哪个socket,这就需要哈希表查找。这样,两个队列是时间复杂度都是O(1)。
参考资料4还谈到了这两个队列满了会怎么样,不是本文重点,有兴趣的朋友可以去看看。
- 全连接队列满了,再来第三次握手也会丢弃,此时如果
tcp_abort_on_overflow=1
,还会直接发RST
给客户端。 - 半连接队列满了,可能是因为受到了
SYN Flood
攻击,可以设置tcp_syncookies
,绕开半连接队列。
connect是如何保存socket信息的
当服务端回复syn+ack时,客户端实际上也没有处于listen状态的套接字,没有半连接队列和全连接队列,但却可以完成三次握手。这意味着,客户端进行connect调用后,该套接字一定被加入到某个表中,并可以被匹配到。
跟踪内核源码可以看到当调用connect时,对应的套接字就被加入了全局的tcp已连接(established)的表中:
1 | int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len) |
注意这句注释 enter ourselves into the hash tables,在inet_hash_connect
函数中,socket在调用connect的时候就会把自己加入到establish hash表,虽然它此时连syn都还没有发送。确切的讲,应该是在它调用的__inet_check_established
函数中。
当内核收到syn+ack报文时,内核是先在established表中查找,再进行listen表的查找。对于客户端来说,syn+ack报文必然可以在已连接表中匹配上对应的套接字。
同时打开
现在进入本文的重点
同时打开连接是指通信的双方在接收到对方的SYN包之前,都进行了主动打开的操作并发出了自己的SYN包。如之前所说一个四元组标识一个TCP连接,因此如果一个TCP连接要同时打开需要通信的双方知晓对方的IP和端口信息才行,这种场景在实际情况中很少发生(NAT穿透中可能会多一些)。同时打开的流程如下图
状态转移
套接字有以下三种状态:
- 在发送syn之后状态处于SYN_SENT状态;
- SYN_RECV状态;
- ESTABLISHED状态。
在正常的连接过程中,客户端向服务器在监听的端口发起connect,第一次握手发送syn后进入SYN_SENT状态,服务器发回syn+ack之后进入ESTABLISHED状态,服务器处的端口处于SYN_RECV状态,并在第三次握手中发回ack后进入ESTABLISHED状态。
SYN_RECV状态还可以是:处于SYN_SENT状态的套接字接收到syn后会进入的状态。这个过程中,客户发送syn并收到syn,就是一个同时打开的过程(双方同时connect)。
假设两台设备双方均发送syn给对端,理想情况下,在发送syn之后状态处于SYN_SENT状态,此时双方均收到对端的发来的syn,则立即进入SYN_RECV状态,并且都向对端回复syn+ack,在收到syn+ack之后,连接从SYN_RECV状态切换到ESTABLISHED状态。
在发送syn进入SYN_SENT状态之后,收到对端发来的syn包处理流程如下。如果收到syn,进入SYN_RECV状态。如果收到的是ack(syn+ack和ack两种),会进入另外的处理流程,稍后再说。
1 | static int tcp_rcv_synsent_state_process(struct sock *sk, struct sk_buff *skb, |
这个负责发送SYN+ACK的函数tcp_send_synack有些特殊:
1 | int tcp_send_synack(struct sock *sk) |
在同时打开的情况下,发送队列中已经有一个SYN包等待确认。tcp_send_synack的基本功能是:将发送队列中的SYN包加上ACK标记位再发送。这样TCP收到SYN后发送的SYN+ACK的序列号与最开始发送的SYN包一致,ack的确认号是对方syn的序列号+1,连接就可以在收到对端的SYN+ACK后得以正常建立。
在SYN_RECV状态收到对端发来的syn+ack包,则直接进入ESTABLISHED已连接状态:
1 | int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb) |
序列号确认
如流程图所示,假设客户端A的syn序列号为x,B的syn序列号为y。双方收到对方的syn时,发送的syn+ack序列号为,A:syn-x+ack-y+1
;B:syn-y+ack-x+1
。
这样,A收到B的syn+ack时,A知道这个是B过来的(syn还是y);并且知道B收到的就是自己的syn(ack确认号是x+1),至此双方序列号就完成了同步。
在这个过程中,有三种情况被其他客户端干扰:
- 如果客户端C只伪造syn-z发给A,A发给B的syn-ack确认号就是z+1,B就知道不对了;
- 如果客户端C只伪造
syn-z+ack-n
发给了A,A看到syn是z而不是B的y就直接丢弃了。 - 如果客户端C两个报文都发给A,这样A查看syn+ack时发现syn是z,还要进一步查看ack,发现ack不是自己的x+1,就知道是错误了。
syn丢包
在双方交换四个数据包的过程中,可能会发生丢包。有四种情况:
- 客户AB向对方发送syn,进入SYN_SEND状态,两个包都丢了,超时重传。
- 客户AB向对方发送syn,但B的syn包丢了。这时AB都在SYN_SENT状态,B收到A的syn后进入SYN_RECV状态,然后发送syn+ack。
- A收到syn+ack时还在SYN_SENT状态,注意到源码中有另一条“ack”的处理路径。
- 实际上客户A在发送syn后收到syn+ack是与listen中的服务器正常建立连接的过程(第二次握手),A并不知道对方是服务器还是客户端,B发的
syn-y+ack-x+1
中的y在A看来就是服务器的初始序列号。此时A会返回ack并进入ESTABLISHED状态。其中ack的序列号是y+1。 - B收到ack后发现没有syn,检查ack序列号正确,就知道是自己的syn包丢了,进入ESTABLISHED状态,连接建立完成。
- 这个过程由于B的syn包丢了,所以实际上变成了client-server模型,退化为正常的三次握手。
- 客户AB向对方发送syn都收到了,AB都进入SYN_RECV状态,发送syn+ack,如果两个包都丢了,超时重传。
- 客户AB向对方发送syn都收到了,AB都进入SYN_RECV状态,发送syn+ack。
- 假设B发送的包丢了,这时B收到syn+ack直接进入ESTABLISHED状态。
- A一直在等待syn+ack,当超时时,A会猜测是自己的syn丢了(且syn+ack也丢了,因为syn+ack没丢的话就是第二种情况client-server)或是对方的syn+ack丢了。
- 此时如上所述,A在SYN_RECV状态超时会重传syn+ack,对于A来说,B没发回syn+ack可能有两种情况:
- 如果B在ESTABLISHED状态,知道自己syn+ack丢了,发回syn+ack让A进入ESTABLISHED状态。
- 如果B根本就没收到syn(B知道自己发了syn),此时退化为client-server模型,发回ack。
至此,连接建立完成。
同时关闭
同时关闭的流程和四次挥手差不多
总结
- 客户端在connect后在内核中会把套接字加入
已连接
哈希表,收到syn+ack报文先查已连接哈希表
再查listen半连接哈希表
。 - 客户端同时打开时,双方会进行syn、syn+ack四种报文的发送接收,在三种状态中转移:发送syn的SYN_SENT状态、接收syn发送syn+ack的SYN_RECV状态、接收syn+ack的ESTABLISHED状态。
- 序列号的同步中,syn发的序列号是随机初始序列号,syn+ack回复的序列号是syn的序列号(标识了该包是自己发的),确认号是对方syn序列号+1(标识了对方的包自己已经收到)。收到syn+ack后会检测序列号(该包是否是发syn的对方发来的)以及确认号(该包是否是自己想连接的发了syn过去的对方发来的)。
- syn丢包中,如果有一个对方丢了syn包,就退化成client-server模型;如果有一个对方丢了syn+ack包,自己会重传syn+ack,对方要么重传syn+ack(对方的syn+ack丢了),要么重传ack(自己的syn丢了,退化为client-server模型)。