tcp 协议 是互联网中最常用的协议 , 开发人员基本上天天和它打交道,对它进行深入了解。 可以帮助我们排查定位bug和进行程序优化。下面我将就TCP几个点做深入的探讨
一、TCP连接阶段
一、TCP 三次握手
1、两次握手行不行?
客户端:收到 ack 后 分配连接资源。 发送数据
服务器 : 收到 syn 后立即 分配连接资源
缺陷:
当ack 丢失后, 服务器认为连接建立了,等待客户端发送数据。 但是客户端并没有建立连接。服务器一直在等待数据,耗费资源。
PS: 网上有说法是什么TCP全双工需要互相确认,个人觉得和这个没有任何关系的。
2、三次握手
客户端:收到ACK, 立即分配资源
服务器:收到ACK, 立即分配资源
缺陷: 同样问题, 最后一个ACK,服务器没有收到, 这是 客户端分配了资源, 但是服务器没有分配资源。 客户端并不知道,继续发送数据。服务器会返回 RST 信号。 因此问题影响不大。
3、四次、五次、六次...握手?
既然三次握手也不是100%可靠, 那四次,五次,六次。。。呢? 其实都一样,不管多少次都有丢包问题。
二、连接状态变化
1、正常连接
2、半连接
client 只发送一个 SYN, server 分配一个tcb, 放入syn队列中。 这时候连接叫半连接状态;如果server 收不到 client 的ACK, 会不停重试 发送 ACK-SYN 给client 。重试间隔 为 2 的 N 次方 叠加(2^0 , 2^1, 2^2 ....);直至超时才释放syn队列中的这个 TCB;
在半连接状态下, 一方面会占用队列配额资源,另一方面占用内存资源。我们应该让半连接状态存在时间尽可能的小
3、异常状况
当client 向一个未打开的端口发起连接请求时,会收到一个RST回复包
三、backlog 队列
1、tcp接收端有两个队列:一个是 syn 队列 , 另一个是 accept 队列
- syn 队列 : 收到 客户端syn 的时候, 在syn队列分配一个 TCB,也叫半连接队列
-
accept 队列 : 当收到客户端 ack 时候,把 TCB 从syn队列移到 accept中来, 也叫全连接队列
2、队列大小如何设置?
- 设置syn 队列大小
/proc/sys/net/ipv4/tcp_max_syn_backlog
- 设置 accept 队列大小
int listen(int sockfd, int backlog);
/proc/sys/net/core/somaxconn
当listen 的 backlog 和 somaxconn 都设置了得时候, 取两者min值
3、当前连接队列大小查看
#ss -a
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port
tcp LISTEN 1 0 *:8888 *:*
Recv-Q 是accept 队列当前个数, Send-Q 设置最大值
4、SYN队列满的情况
- 若设置 net.ipv4.tcp_syncookies = 0 ,则直接丢弃当前 SYN 包;
- 若设置 net.ipv4.tcp_syncookies = 1 ,发送ACK+SYN;
5、ACCEPT队列满的情况
- 若设置 tcp_abort_on_overflow = 1 ,则 TCP 协议栈回复 RST 包,并直接从 SYN queue 中删除该连接信息;
- 若设置 tcp_abort_on_overflow = 0 ,则 TCP 协议栈将该连接标记为 acked ,但仍保留在 SYN queue 中,并启动 timer 以便重发 SYN-ACK 包;当 SYN-ACK 的重传次数超过 net.ipv4.tcp_synack_retries 设置的值时,再将该连接从 SYN queue 中删除;
6、SYN flooding
这种SYN洪水攻击是一种常见攻击方式,就是利用半连接队列特性,占满syn 队列的 资源,导致 client无法连接上。
解决方案:
- 缩短SYN队列超时时间 : 设置系统配置 net.ipv4.tcp_synack_retries, 减少 重发 syn-ack 次数
- Syn Cache技术 : 这种技术是在收到SYN数据报文时不急于去分配TCB,而是先回应一个SYN ACK报文,并在一个专用HASH表(Cache)中保存这种半开连接信息,直到收到正确的回应ACK报文再分配TCB到accept队列。在linux系统中这种Cache每个半开连接只需使用160字节,远小于TCB所需的736个字节。
- Syn Cookie技术: 相对于 Syn Cache技术, 这里不分配任何资源,只是巧妙的用算法计算一个Sequence Number 放在 SYN 请求中。 为了能正确匹配识别 client 回应的 ACK 中 Sequence Number。 完全靠算法 来 完成。 如果开启了 SYN cookies 选项,在半连接队列满时,SYN cookies 并不丢弃 SYN 请求,而是将源目的 IP、源目的端口号、接收到的客户端初始序列号以及其他一些安全数值等信息进行 hash 运算,并加密后得到服务器端的初始序列号,称之为 cookie 。服务器端在发送初始序列号为 cookie 的 SYN+ACK 包后,会将分配的连接请求块释放。如果接收到客户端的 ACK 包,服务器端将客户端的 ACK 序列号减 1 得到的值,与上述要素 hash 运算得到的值比较,如果相等,直接完成三次握手,构建新的连接。SYN cookies 机制的核心就是避免攻击造成的大量构造无用的连接请求块,导致内存耗尽,而无法处理正常的连接请求。(echo 1 > /proc/sys/net/ipv4/tcp_syncookies)
7、哪些是内核自动完成的动作,哪些是应用层触发
- client 调用 connect 函数 , 三次握手在内核协议栈自动完成,connect 函数立即返回,不管server端是否是调用了 accept函数没有。
- server调用 accept函数, 才从 accept队列移除 TCB资源 ,返回socket 句柄句柄 给应用程序。
- 如果server端的synt队列未满 ,client端调用connect 函数,不管server是否调用accept与否,都会立即返回; 如果队列满,client则一直重复发送SYN( 间隔 2的幂递增)到直至超时返回。
二、TCP关闭阶段
一、TCP 四次 挥手
1、正常关闭流程
2、三次挥手行不行
为什么不像握手那样合并成三次挥手? 因为和刚开始连接情况,连接是大家都从0开始, 关闭时有历史包袱的。server(被动关闭方) 收到 client(主动关闭方) 的关闭请求FIN包。 这时候可能还有未发送完的数据,不能丢弃。 所以需要分开。事实可能是这样
当然,在没有待发数据,并且允许 Delay ACK 情况下, FIN-ACK合并还是非常常见的事情,这是三次挥手是可以的。
3、二次挥手行不行
同上
4、半关闭(CLOSE_WAIT)
CLOSE_WAIT 是被动关闭方才有的状态。
被动关闭方 [收到 FIN 包 发送 ACK 应答] 到 [发送FIN, 收到ACK ] 期间的状态为 CLOSE_WAIT, 这个状态仍然能发送数据。 我们叫做半关闭, 下面用个例子来分析:
这个是我实际生产环境碰到的一个问题,长连接会话场景,server端收到client的rpc call 请求1,处理发现请求包有问题,就强制关闭结束这次会话, 但是 因为client 发送 第二次请求之前,并没有去调用recv,所以并不知道 这个连接被server关闭, 继续发送 请求2 , 此时是半连接,能够成功发送到对端机器,但是recv结果后,遇到连接已经关闭错误。
CLOSE_WAIT 和 后面提到的 TIME_WAIT 一样,都是存在潜在危害,CLOSE_WAIT 状态下 文件句柄资源没有释放。要知道系统 句柄资源也是有限的。要尽快释放close掉。
5、同时关闭
如果 client 和 server 恰好同时发起关闭连接。这种情况下,两边都是主动连接,都会进入 TIME_WAIT状态
4、TIME_WAIT 状态
TIME_WAIT 状态 是主动关闭方才有的状态:这种状态下有个让开发人员都很苦恼的普遍性问题,端口耗尽, 我们知道,端口数据类型是 unsigned short。 最大值是 65535. 所以 一台机 上 最多分配 65535个端口(不考虑accept的tcp资源情况下,也可以看成一台机器最多只能分配这么链接数)。
然而,TIME_WAIT* 持续保持时间是 2*MSL( Maximum segment lifetime)。 默认是 2分钟。( 通过这个可以修改 /proc/sys/net/ipv4/tcp_fin_timeout)。想想在并发高的机器上 2分钟 很容易发起超过 6w多个短链接请求。 这时候就出现端口不够用,connect 错误“Cannot assign requested address” 。TIME_WAIT的如此设计是为了解决2个问题:
以下两种讨论的两种情况都是 假设 前后两次连接都是相同四元组( 源ip,源端口,目的ip,目的端口),都是属于 "在不相关的连接中接受延迟的段"现象
1、被动关闭方在LAST_ACK状态(已经发送FIN),等待主动关闭方的ACK应答,但是 ACK丢掉, 主动方并不知道,以为成功关闭。因为没有TIME_WAIT等待时间,可以立即创建新的连接, 新的连接发送SYN到前面那个未关闭的被动方,被动方认为是收到错误指令,会发送RST。导致创建连接失败。
2、主动关闭方断开连接,如果没有TIME_WAIT等待时间,可以马上建立一个新的连接,但是前一个已经断开连接的,延迟到达的数据包。 被新建的连接接收,如果刚好seq 和 ack字段 都正确, seq在滑动窗口范围内(只能说机率非常小,但是还是有可能会发生),会被当成正确数据包接收,导致数据串包。 如果不在window范围内,则没有影响( 发送一个确认报文(ack 字段为期望ack的序列号,seq为当前发送序列号),状态变保持原样)
4、解决TIME_WAIT 问题
TIME_WAIT 问题比较比较常见,特别是CGI机器,并发量高,大量连接后段服务的tcp短连接。因此也衍生出了多种手段解决。虽然每种方法解决不是那么完美,但是带来的好处一般多于坏处。还是在日常工作中会使用。
1、改短TIME_WAIT 等待时间
net/ipv4/tcp_fin_timeout
这个是第一个想到的解决办法,既然等待时间太长,就改成时间短,快速回收端口。但是实际情况往往不乐观,对于并发的机器,你改多短才能保证回收速度呢,有时候几秒钟就几万个连接。太短的话,就会有前面两种问题小概率发生。
2、禁止Socket lingering
struct linger stLinger;
stLinger.l_onoff = 1;
stLinger.l_linger = 0;
setsockopt(fdC, SOL_SOCKET, SO_LINGER, ( void *)&stLinger, sizeof(stLinger));
这种情况下关闭连接,会直接抛弃缓冲区中待发送的数据,会发送一个RST给对端,相当于直接抛弃TIME_WAIT, 进入CLOSE状态。同样因为取消了 TIME_WAIT 状态,会有前面两种问题小概率发生。
3、tcp_tw_reuse
net.ipv4.tcp_tw_reuse选项是 从 TIME_WAIT 状态的队列中,选取条件:1、remote 的 ip 和端口相同, 2、选取一个时间戳小于当前时间戳; 用来解决端口不足的尴尬。
net/ipv4/tcp_ipv4.c
int tcp_twsk_unique(struct sock *sk, struct sock *sktw, void *twp)
{
/* ……省略…… */
if (tcptw->tw_ts_recent_stamp &&
(!twp || (sock_net(sk)->ipv4.sysctl_tcp_tw_reuse &&
get_seconds() - tcptw->tw_ts_recent_stamp > 1))) {
/* ……省略…… */
return 1;
}
return 0;
}
net/ipv4/inet_hashtables.c
static int __inet_check_established(struct inet_timewait_death_row *death_row,
struct sock *sk, __u16 lport,
struct inet_timewait_sock **twp)
{
/* ……省略…… */
sk_nulls_for_each(sk2, node, &head->chain) {
if (sk2->sk_hash != hash)
continue;
if (likely(INET_MATCH(sk2, net, acookie,
saddr, daddr, ports, dif))) {
if (sk2->sk_state == TCP_TIME_WAIT) {
tw = inet_twsk(sk2);
if (twsk_unique(sk, sk2, twp))
break;
}
goto not_unique;
}
}
/* ……省略…… */
}
现在端口可以复用了,看看如何面对前面TIME_WAIT 那两种问题。 我们仔细回顾用一下前面两种问题。都是在新建连接中收到老连接的包导致的问题, 那么如果我能在新连接中识别出此包为非法包,是不是就可以丢掉这些无用包,解决问题呢。
需要实现这些功能,需要扩展一下tcp 包头。 增加 时间戳字段。 发送者 在每次发送的时候。 在tcp包头里面带上发送时候的时间戳。 当接收者接收的时候,在ACK应答中除了TCP包头中带自己此时发送的时间戳,并且把收到的时间戳附加在后面。也就是说ACK包中有两个时间戳字段。结构如下:
那我们接下来一个个分析tcp_tw_reuse是如何解决TIME_WAIT的两个问题的
tcp_tw_reuse 使用有三个条件:
1、必须开启 timestamp,通过 net.ipv4.tcp_timestamp 参数设置, linux 2.6 后缺省是打开的;
2、必须客户端和server同时支持 timestamp. 在连接握手阶段协商:当一方不开启时,两方都将停用timestamps。比如client端发送的SYN包中带有timestamp选项,但server端并没有开启该选项。则回复的SYN-ACK将不带timestamp选项,同时client后续回复的ACK也不会带有timestamp选项。当然,如果client发送的SYN包中就不带timestamp,双向都将停用timestamp。
3、 tcp_tw_reuse 只对outgoing connections 有效。也就是发起连接方 client 有效。 server端产生新的连接是不会复用 TIME_WAIT 资源
- LAST_ACK收到 SYN问题: LAST_ACK 状态 socket 苦苦等待 主动关闭方的ACK应答,但是却收到了SYN。注意:敲黑板,划重点了。此时检查有timestamp字段的话,不会立即发送RST包,而是比较timestamp字段时间戳非常新,明显不是自己这个连接建立时候SYN延迟包。这时候恢复 FIN + ACK, 对方收到 回复一个RST结束上个连接。 继续发SYN建立新的连接 。这样就解决料老的连接无法关闭,新的连接无法建立的问题。
- 收到上一个连接数据串包问题,也是检查这个数据包的TCP头里面的timestamp,比较发现远远小于上一次TCP包的timestamp ,于是丢弃这个包即可。
4、tcp_tw_recycle
tcp_tw_recycle自 Linux内核4.12版以来,已被弃用, 大家可以放心的不用了
tcp_tw_recycle 也是借助 timestamp机制。顾名思义, tcp_tw_reuse 是复用 端口,并不会减少 TIME-WAIT 数量。你去查询机器上TIME-WAIT 数量,还是 几千几万个,这点对有强迫症的同学感觉很不舒服。tcp_tw_recycle 是 提前 回收 TIME-WAIT资源。会减少 机器上 TIME-WAIT 数量。
tcp_tw_recycle 工作原理是。
动态缩小TIME-WAIT 时间, 不再是 2*MSL时间回收来回收TIME-WAIT资源,而是根据 RTO(Retransmission TimeOut)即重传超时时间, 这个时间根据一套特定算法来动态计算,当TIME-WAIT 停留时间大于 RTO。系统释放资源。 这招可以减少TIME-WAIT 数量
为了解决TIME-WAIT 收到不相关连接数据包的问题, 内核会纪录 TIME-WAIT 状态下 最后一次 收到包的时间戳,和ip地址等信息。(注意是IP地址没有端口信息哦)。如果收到连接SYN包(注意只在连接时候检测哦),先检查remote IP 有没有 相关的 TIME-WAIT状态,如果有则比较时间戳,如果时间戳比最后一次收到时间戳还要小,则这个包被认为是延迟包,丢弃掉。
存在问题: 一般情况下我们不建议使用tcp_tw_recycle , 因为它在 存在 NAT 网关的时候。 会出问题。NAT是地址共享, 而 tcp_tw_recycle 机制下恰恰又是根据ip纪录时间戳信息的。在 NAT下,多台机器上client。 对于server 可能是同一个IP。由于客户端的时间戳可能不一致。会导致连接不上问题。
NAT网络在国内使用非常普遍,家庭网络基本上NAT网络, N个设备共享一个IP地址。 公司办公网基本上也是如此。