五、异常TCP建立情况
1)connect系统调用耗时失控
客户端在发起connect系统调用的的时候,主要工作就是端口选择。在选择的过程中有一个大循环,从ip_local_port_range的一个随机位置开始把这个范围遍历一遍,找到可用端口则退出循环。如果端口很充足,那么循环只需要执行少数几次就可以退出。但是如果端口消耗掉很多已经不充足,或者干脆就没有可用的了,那么这个循环就得执行很多遍。
int inet_hash_connect(...) { inet_get_local_range(&low, &high); remaining = (high - low) + 1; for(int i = 1; i <= remaining; i++) { // 其中offset是一个随机数 port = low + (i + offset) % remaining; head = &hinfo->bhash[inet_bhashfn(net, port, hinfo->bhash_size)]; // 加锁 spin_lock(&head->lock); // 一大段端口选择逻辑,选择成功就goto ok,选择不成功就goto next_port ...... next_port: // 解锁 spin_unlock(&head->lock); } }
在每次循环内部需要等待所以及在哈希表中执行多次的搜索。并且这里的锁是自旋锁,如果资源被占用,进程并不会挂起,而是占用CPU不断地尝试去获得锁。假设端口范围ip_local_port_range配置的是10000~30000,而且已经用尽了。那么每次当发起连接的时候,都需要把循环执行两万遍才退出。这时会涉及大量的哈希查找以及自旋锁等待开销,系统态CPU将出现大幅度上涨。
所以当connect系统调用的CPU大幅度上涨时,可以尝试修改内核常熟ipv4.ip_local_port_range多预留一些端口、改用长连接或者尽快回收TIME_WAIT等方式。
2)第一次握手丢包
服务端在响应来自客户端的第一次握手请求的时候,会判断半连接队列和全连接队列是否溢出。如果发生溢出的,可能会直接将握手包丢弃,而不会反馈给客户端。
1. 半连接队列满
int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb) { // 看看半连接队列是否满了 if(inet_csk_reqsk_queue_is_full(sk) && !isn) { want_cookie = tcp_syn_flood_action(sk, skb, "TCP"); if(!want_cookie) goto drop; } ...... }
在以上代码中inet_csk_reqsk_is_full如果返回true就表示半连接队列满了,另外tcp_syn_flood_action判断是否打开了内核参数cp_syncookies,如果未打开则返回false。
也就是说,如果半连接队列满了,而且没有开启tcp_syncookies,那么来自客户端的握手包将goto drop,即直接丢弃。
SYN Flood攻击就是通过耗光服务端上的半连接队列来使得正常的用户连接请求无法被响应。不过在现在的Linux内核里只要打开tcp_syncookies,半连接队列满了仍然可以保证正常握手的进行。
2. 全连接队列满
当半连接队列判断通过以后,紧接着还由全连接队列的相关判断。如果满了服务端还是会丢弃它。
int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb) { // 看看半连接队列是否满了 ...... // 在全连接队列满的情况下,如果有young_ack,那么直接丢弃 if(sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1) { NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS); goto drop; } ...... }
sk_aceeptq_is_full判断全连接队列是否满了,inet_csk_reqsk_queue_young判断有没有young_ack(未处理完的半连接请求)。如果全连接队列满且同时有young_ack,那么内核同样直接丢掉该SYN握手包。
3. 客户端发起重试
假设服务端发生了全/半连接队列溢出而导致的丢包,那么转换到客户端的视角来看就是SYN包没有任何响应。
因为客户端在发出握手包的时候,开启了一个重传定时器。如果收不到预期的synack,超时的逻辑就会开始执行。不过重传定时器的时间单位都是以秒来计算的,这意味着如果有握手重传发生,即使第一次重传就能成功,那接口最快响应也是一秒以后的事情了,这对接口耗时影响非常大。以下是connect系统调用关于重传的逻辑。
int tcp_connect(sruct sock *sk) { ...... // 实际发出SYN err = tp->fastopen_req ? tcp_send_syn_data(sk, buff) : tcp_transmit_skb(sk, buff, 1, sk->sk_allocation); // 启动重传定时器 inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS, inet_csk(sk)->icsk_rto, TCP_RTO_MAX); }
其中inet_csk(sk)->icsk_rto是超时时间,该值初始化的时候被设置为TCP_TIMEOUT_INIT(1秒,在一些老版本的内核里为3秒)。
void tcp_connect_init(struct sock *sk) { // 初始化为TCP_TIMEOUT_INIT inet_csk(sk)->icsk_rto = TCP_TIMEOUT_INIT; ...... }
如果能正常接收到服务端响应的synack,那么客户端的这个定时器会清除。这段逻辑在tcp_rearm_rto里,具体的调用顺序为tcp_rcv_state_process->tcp_rcv_synsent_state_process->tcp_ack->tcp_clean_rtx_queue->tcp_rearm_rto;
void tcp_stream_rto(struct sock *sk) { inet_csk_clear_xmit_timer(sk, ICSK_TIME_RETRANS); }
如果服务端发生了丢包,那么定时器到时候会进入回调函数tcp_write_timer中进行重传(其实不只是握手,连接状态的超时重传也是在这里完成的)。
static void tcp_write_timer(unsigned long data) { tcp_write_timer_handler(sk); ...... } void tcp_write_timer_handler(struct sock *sk) { // 取出定时器类型 event = icsk->icsk_pending; switch(event) { case ICSK_TIME_RETRANS: // 清除定时器 // icsk_pending用于标记一个 TCP 连接当前有哪些定时器是激活状态,是一个位掩码,每一位都对应一个特定的定时器 icsk->icsk_pending = 0; tcp_retransmit_timer(sk); break; ...... } }
这里tcp_transmit_timer是重传的主要函数。在这里完成重传以及下一次定时器到期的时间设置。
void tcp_retransmit_timer(struct sock *sk) { ...... // 超过了重传次数则退出 if(tcp_write_timeout(sk)) goto out; // 重传 if(tcp_retransmit_skb(sk, tcp_write_queue_head(head)) > 0) { // 重传失败 ...... } // 退出前重新设置下一次的超时时间 out_reset_timer: // 计算超时时间 if(sk->sk_state == TCP_ESTABLISHED) { ...... } else { icsk->icsk_rto = min(icsk->icsk_rto << 1, TCP_RTO_MAX); } // 设置 inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS, icsk->icsk_rto, TCP_RTO_MAX); }
tcp_write_timeout用来判断是否重试过多,如果是则退出重试逻辑。
对于SYN握手包主要的判断依据是net.ipv4_tcp_syn_retries(内核参数,对于一个新建连接,内核要发送多少个SYN连接请求才决定放弃。不应该大于255,默认值是5),但其实并不是简单的对比次数,而是转化成了时间进行对比。所以如果在线上看到了实际重传次数和对应内核参数不一致也不用太奇怪。
接着调用tcp_retransmit_skb函数重发了发送队列里的头元素。
最后再次设置下一次超时的时间,为前一次时间的两倍。
4. 实际抓包结果
客户端发出TCP第一次握手之后,在1秒以后进行了第一次握手重试。重试仍然没有响应,那么接下来一次又分别在3秒、7秒、15秒、31秒和63秒等事件共重试了六次(我的tcp_syn_retries设置为6)。
当服务端第一次握手的时候出现了半/全连接队列溢出导致的丢包,那么接口响应的时间将会很久(只进行一次重试都需要一秒的时间),用户体验会受到很大的影响。并且如果某一个时间段内有多个进程/线程卡在了和Redis或者MySQL的握手连接上,那么可能会导致线程池剩下的线程数量不足以处理服务。
3)第三次握手丢包
客户端在收到服务器的synack相应的时候,就认为连接建立成功了,然后会将自己的连接状态设置为ESTABLISHED,发出第三次握手请求。但服务端在第三次握手的时候还有可能有意外发生。
static struct sock *tcp_v4_hnd_req(struct sock *sk, struct sk_buff *skb) { // 查找listen socket的半连接队列 struct request_sock *req = inet_csk_search_req(sk, &prev, th->source, iph->saddr, iph->daddr); if(req) return tcp_check_req(sk, skb, req, prev, false); ...... } struct sock *tcp_check_req(...) { // 创建子socket child = inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk, skb, req, NULL); ...... // 清理半连接队列 inet_csk_reqsk_queue_unlink(sk, req, prev); inet_csk_reqsk_queue_removed(sk, req); // 添加全连接队列 inet_csk_reqsk_queue_add(sk, req, child); return child; }
在第三次握手时,首先从半连接队列里拿到半连接对象,之后通过tcp_check_req => inet_csk(sk)->icsk_af_ops->syn_recv_sock来创建子socket
这里syn_recv_sock是一个函数指针,在ipv4中指向了tcp_v4_syn_recv_sock。
struct sock *tcp_v4_syn_recv_sock(struct sock *sk, ...) { // 判断全连接队列是不是满了 if(sk_acceptq_is_full(sk)) goto exit_overflow; ...... }
从上述代码可以看出,第三次握手的时候,如果服务器全连接队列满了,来自客户端的ack握手包又被直接丢弃。
由于客户端在发起第三次握手之后就认为连接建立了,所以如果第三次握手失败,是由服务端来重发synack(服务端发送synack之后启动了定时器,并将该半连接对象保存在了半连接队列中)。服务端等到半连接定时器到时后,想客户端重新发起synack,客户端收到后再重新恢复第三次握手。如果这个期间服务端全连接队列一直都是满的,那么服务端重试5次(受内核参数net.ipv4.tcp_synack_retries控制)后就放弃了。
客户端在发起第三次握手之后往往就开始发送数据,其实这个时候连接还没有真的建立起来。如果第三次握手失败了,那么它发出去的数据,包括重试都将被服务端无视,知道连接真正建立成功后才行。
4)握手异常总结
1.端口不足:导致connect系统调用的时候过多地执行自旋锁等待与哈希查找,会引起CPU开销上涨。严重的情况下会耗光CPU,影响用户逻辑的执行。
1.调整ip_local_port_range来尽量加大端口范围
2.尽量复用连接,使用长连接来削减频繁的握手处理
3.开启tcp_tw_reuse和tcp_tw_recycle
2.服务端在第一次握手丢包(半连接队列满且tcp_syncookies为0 || 全连接队列满且有未完成的半连接请求):客户端不断发起syn重试
3.服务端在第三次握手丢包(全连接队列满):服务端不断发起synack重试
握手重试对服务端影响很大,常见的解决方法如下:
1.打开syncookies:防止SYN Flood攻击等
2.加大连接队列长度:全连接是min(backlog,net.core.somaxconn),半连接是min(backlog,somaxconn,tcp_max_syn_backlog) + 1向上取整到2的幂次(且不小于16)
3.尽快调用accept
4.尽早拒绝:例如MySQL和Redis等服务器的内核参数tcp_abort_on_overflow设置为1,如果队列满了直接reset指令发送给客户端,告诉其不要继续等待。这时候客户端会收到错误“connection reset by peer”
5.尽量减少TCP连接的次数
六、如何查看是否有连接队列溢出发生
1)全连接队列溢出判断
全连接队列溢出都会记录到ListenOverflows这个MIB(管理信息库),对应SNMP统计信息中的ListenDrops这一项。
int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb) { // 查看半连接队列是否满了 ...... // 在全连接队列满的情况下,如果有young_ack,那么直接丢弃 if(sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1) { NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS); goto drop; } ...... drop: NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENDROPS); } struct sock *tcp_v4_syn_recv_sock(struct sock *sk, ...) { // 判断全连接队列是不是满了 if(sk_acceptq_is_full(sk)) goto exit_overflow; ...... exit_overflow: NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS); exit: NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENDROPS); }
可以看到服务端在响应第一次握手和第三次握手的时候,在全队列满了时都会调用NET_INC_STATS_BH来增加LINUX_MIB_LISTENOVERFLOWS和LINUX_MIB_LISTENDROPS这两个MIB。
在proc.c中,这两个MIB会被整合到SNMP统计信息。
在执行netstat-s的时候,该工具会读取SNMP统计信息并展现出来。
#watch 'netstat -s | grep overflowed' 198 times the listen queue of a socket overflowed
通过netstat -s输出中的xx times the listen queue如果查看到数字有变化,则说明一定是服务端上发生了全连接队列溢出了。
2)半连接队列溢出判断
半连接队列溢出时更新的是LINUX_MIB_LISTENDROPS这个MIB,然而不只是半连接队列发生溢出的时候会增加该值,全连接队列满了该值也会增加。所以根据netstat -s查看半连接队列是否溢出是不靠谱的。
对于半连接队列是否溢出这个问题,一般直接看服务器tcp_syncookies是不是1就行了。如果该值是1,那么根本不会发生半连接溢出丢包。而如果不是1,则建议改为1。
如果因为其他原因不想打开,那么除了netstat -s,也可以同时查看listen端口上的SYN_RECV的数量,如果该数量达到了半连接队列的长度(根据内核参数和自己传递的backlog可以计算出来)则可以确定有半连接队列溢出。
七、问题解答
1.为什么服务端程序都需要先listen一下
内核在响应listen调用的时候创建了半连接、全连接两个队列,这两个队列是三次握手中很重要的数据结构,有了它吗才能正常响应客户端的三次握手。所以服务器提供服务前都需要先listen一下才行。
半连接队列和全连接队列长度如何确定
2.半连接队列:max((min(backlog, somaxconn, tcp_max_syn_backlog) + 1)向上取整到2的幂次), 16)
全连接队列:min(backlog, somaxconn)
3.“Cannot assign requested address”这个报错是怎么回事
一条TCP连接由一个四元组构成,其中目的IP和端口以及自身的IP都是在连接建立前确定了的,只有自身的端口需要动态选择出来。客户端会在connect发起的时候自动选择端口号。具体的选择就是随机地从ip_local_port_range选择一个位置开始循环判断,跳过ip_local_reserver_ports里设置的要避开的端口,然后挨个判断是否可用。如果循环完也没有找到可用端口,就会抛出这个错误。
4.一个客户端端口可以同时用在两条连接上吗
connect调用在选择端口的时候如果端口没有被用上那就是可用的,但是如果被用过也不代表这个端口就不可用。
如果用过,则会去判断是否有老的连接四元组与当前要建立的这个新连接四元组完全一致,如果不完全一致则该端口仍然可用。
5.服务端半/全连接队列满了会怎么样
服务端响应第一次握手的时候会进行半连接队列和全连接队列是否满的判断
如果半连接队列满了且未开启tcp_syncookies,丢弃握手包
如果全连接队列满了且存在young_acck,丢弃握手包
服务端响应第三次握手的时候会进行全连接队列是否满的判断
如果全连接队列满了则丢弃握手包
6.新连接的soket内核对象是什么时候建立的
内核其实在第三次握手完毕的时候就把sock对象创建好了。在用户进程调用accept的时候,直接把该对象取出来,再包装一个socket对象就返回了。
7.建立一条TCP连接需要消耗多长时间
一般网络的RTT值根据服务器物理距离的不同大约是在零点几秒、几十毫秒之间。这个时间要比CPU本地的系统调用耗时长得多。所以正常情况下,在客户端或者是服务端看来,都基本上约等于一个RTT。
如果一旦出现了丢包,无论是那种原因,需要重传定时器来接入的话,耗时就最少要一秒了。
8.服务器负载很正常,但是CPU被打到底了时怎么回事
如果在端口极其不充足的情况下,connect系统调用的内部循环需要全部执行完毕才能判断出来没有端口可用。如果要发出的连接请求特别频繁,connect就会消耗掉大量的CPU。如果要发出的连接请求特别频繁,connect就会消耗掉大量的CPU。当服务器上的进程不多,但是每个进程都在疯狂的消耗CPU,这时候就会出现CPU被消耗光,但是服务器负载却不高的情况。
参考资料:
3.3 连接建立完成_tcp_v4_hnd_req_Remy1119的博客-CSDN博客
TCP输入 之 tcp_v4_rcv - AlexAlex - 博客园 (cnblogs.com)
Linux TCP数据包接收处理 tcp_v4_rcv - kk Blog —— 通用基础 (abcdxyzk.github.io)
Linux操作系统学习笔记(二十三)网络通信之收包 | Ty-Chen's Home
Linux socket系统调用(三)----tcp_sock、sock、socket结构体以及TCP slab缓存建立_Blue summer的博客-CSDN博客
《深入理解Linux网络》—— 张彦飞