时隔多年重新学习TCPIP,主要看了博客TCP的那些事儿和 TCP/IP详解卷一,这里以问题驱动的形式,对应书做些小测试,来验证和解释一些概念和问题。主要总结socket函数与tcp连接建立断开之间的关系,tcp状态变迁,KeepAlive以及RST异常场景。之后写了Go的HTTP实现,要再总结一下tcp trouble shooting。
socket拾遗
Linux内核认为套接字就是有描述符的打开文件。
- socket+bind socket创建
socketFD
套接字描述符,bind为其绑定地址或端口。如果地址和端口都没指定,内核默认绑一个地址和端口。这也是为什么客户端connect后自动绑了个端口与服务端建立连接。
- connect 如果是TCP socket,connect将激发TCP的三次握手过程。
- listen 套接字转换为被动套接字,内核为每个被动套接字维护了两个队列,分别是未完成连接队列SYN_RCVD和已完成连接队列ESTABLISHED。收到connect触发的SYN后,内核在未完成连接队列中建立条目,回复
SYN+ACK
,直到再次收到客户端的ACK
后,条目从未完成队列转移到已完成队列。
- accept 从已完成队列头部返回下一个已完成连接。
1 | server: socket + bind + listen + accept + ------- + close |
accept queue
问题1:如果server端处于监听状态 但是应用层进程一直不调用accept取走已完成连接
listen(addr, backlog)
设置了已完成队列的最大长度backlog 默认为128
,即完成握手但还没有被应用程序accept的连接 的数目超过backlog,新的连接将被拒绝。未完成连接队列大小由内核参数/proc/sys/net/ipv4/tcp_max_syn_backlog
默认为2048。
问题2:connect的返回时机
下面的例子还说明一个问题。connect的返回以及连接的建立都与accept无关,即使server的应用层未接受这个conn,connect仍可以获得connFD并且发送数据,如图可见server侧的connFD的接受缓存RecvQ中有client侧发送的数据。
connfd + listenfd
问题3:区分已连接描述符connFD与server端的监听描述符listenFD的原因
client端通过connect返回connFD,而server端通过accept获取connFD,之后两端通过这个描述符通信。结合问题2,server侧不执行accept只是server端应用层拿不到connFD而已,并不影响client端在connFD上发数据的操作。至于区分listenfd和connfd的原因,是为了可以建立并发服务器,同时处理许多client端的请求。
过程与状态变迁
问题4: 为什么要三次握手 四次挥手
连接建立时双方要交换ISN初始序列号,这个号用来保证应用层接收到的数据不会因为网络上的传输的问题而乱序。因此双方都要发送和接受SYN,并回复ACK。由于建立连接是同步的过程,服务端可以将ACK和SYN合并,所以只有三次。
连接断开时双方要发FIN,并在收到FIN后回复ACK,但是这时被动关闭的一方并不把ACK和FIN合并返回。这是因为TCP是全双工的,每个方向必须单独地关闭,TCP连接可以是半关闭的。收到FIN只意味着这一方向上没有数据再可以接受了,它给应用程序发送EOF,但接受FIN的一方仍然可以发送数据。
正常交互过程
下面wireshark抓的一组数据是一个正常握手挥手的过程,可以完整的看到交互过程。在三次握手后client端发送了Seq为1 Len为5的数据包,最后client端发起FIN关闭,server端在回复ACK后才发送FIN,最后client发ACK完成四次挥手。
问题5: 如何确定ISN
ISN和一个假的时钟绑在一起,每4us对ISN+1,直到超过2^32,又再回到0。ISN的周期约为4.55个小时。只要MSL时间(TCP包最大存活时间)小于4.55小时,在断链重连时就不会重用到ISN。
握手状态变迁
client
发送SYN后进入SYN-SENT状态,只要接受到SYN+ACK就进入ESTABLISHED状态。这跟server
端的应用层是不是accept这个连接没有关系,握手过程在TCP传输层完成。
server
收到SYN发送SYN+ACK后就进入SYN-RECVD状态,然后开始等,直到收到ACK状态变为ESTABLISHED,这时应用层可以调用accept取出已就绪队列的连接开始读写。如果server在等ACK时超时,server侧的连接将一直无法建立,它会重传SYN+ACK。
问题6: SYN超时处理
server没有收到client返回的ACK,将以指数退避的间隔时间重发SYN-ACK。默认尝试5次总共需要1s+2s+4s+8s+16s+32s=63s 才会断开这个连接。为了避免SYN Flood攻击 Linux通过设置tcp_syncookies
来处理。基本原理是在SYN队列满了之后,TCP会发个特殊的cookieSeq回去,如果是攻击者不会回应。对于正常的大负载的连接情况,Linux提供几个参数来调节SYN超时问题:1) tcp_synack_retries
减少重试次数; 2) tcp_max_syn_backlog
增大SYN连接数;3)tcp_abort_on_overflow
直接拒绝连接。
挥手状态变迁
挥手过程的状态变迁分多种情况。上面的例子中client结束时conn.Close()
将发送FIN包,自己进入FIN_WAIT_1状态。下图中client端的应用层进程已经退出了,但底层的conn仍然在FIN_WAIT2状态。
- 发送FIN之后先收到ACK则进入FIN_WAIT_2状态等待对方的FIN包,直到收到对方的FIN包进入TIME_WAIT状态,最后CLOSED。
- 发送FIN之后先收到FIN则说明两端同时在关闭,这时进入CLOSING状态,收到ACK后就进入TIME_WAIT状态。
- 若ACK+FIN同时收到就进入TIME_WAIT状态。
问题7:为什么要TIME_WAIT状态
- 因为主动关闭的一方要等待以便重发ACK。实际上底层谁先close谁就最后进入TIME_WAIT状态。假设最后一个ACK丢失了,被动关闭一方会重发它的FIN。主动关闭一方必须维持一个有效状态,以便能够重发ACK。如果不维持这种状态而进入CLOSED,那么主动关闭的socket在处于CLOSED状态时,接收到FIN后将会响应一个RST。被动关闭一方接收到RST后会认为出错了。
- 防止混淆重新建立的连接与关闭的连接,TCP不允许新连接复用TIME_WAIT状态下的conn,新连接的建立必须等先前网络中残余的数据报都丢失了。
RST异常场景
connection reset by peer
和EOF
是经常在日志里看到的错误。前者是因为收到了对方发来的RST,后者则是正常的FIN。关闭连接时发RST通常是因为close时发现自己缓冲区RevQ还有数据没有读。下面总结几个会发RST的场景。
- connect不存在的端口,对方会回复RST。
- 异常终止时接收缓冲还有数据将发RST。
- 往对方已经close的连接上写数据,对方回复RST。
问题8: 既然可以支持半关闭,为什么往关闭的连接上写数据会失败
close
实际上是关闭conn的两个流向。如果要实现半关闭,则使用shutdown
系统调用,go里面支持closeRead
和closeWrite
两个方法来实现半关闭。
下面例子就是client发了5个字节给server,但是server应用层没有read,一直存在于server的RecvQ中,这时server调用close关闭连接,底层发给client的是RST包,表示连接异常关闭。
1 | c>s: F[S] seq 1761306812, win 43690, options [...] |
保活定时器
先总结下tcp的四个定时器及其功能
- 重传定时器 当超时没有收到某个Seq的ACK后,将以指数退避的方式重传
- TIME_WAIT定时器 主动关闭连接的一方在收到对方的FIN发送ACK后将等待2MSL时间才关闭连接
- 坚持定时器 为零窗口设计,当接收方告知发送方窗口大小为0后,启动坚持定时器周期性的询问窗口更新通知。以免窗口通知丢失,导致双方无法通信。
- 保活定时器 TCP-keepalive 探查连接双方是否还活着。
问题8: 如果server就是不close,client一直收不到FIN,是否CLOSE_WAIT和FIN_WAIT_2这对状态会无限制保持下去。
FIN_WAIT_2的生命周期由tcp_fin_timeout
设置,如果收不到FIN包连接终将CLOSED。但是CLOSE_WAIT的状态可能持续保持下去。如果server配置了keepAlive时间则每个t就发TCP Keep-Alive包看conn是否被关闭。当client的FIN_WAIT_2 状态超时变为CLOSED之后,server的KeepAlive包将引发client的RST,最后连接终止。与之对比的是,当client半连接时,FIN_WAIT_2状态不会超时,那么KeepAlive包将一直有Keep AliveACK回应,连接不会RST终止。
1 | tcpConn.SetKeepAlive(true) |
下面例子中keepalive时间是5s,连接建立后没有收发数据,因此KeepAlive包每5秒发一次。10s时client端关闭连接发了FIN包进入FIN_WAIT_2状态,该状态60s后超时变为CLOSED状态。在这之后KeepAlive才失败导致RST重置连接。