参考资料:深入理解Linux网络
- 为什么服务端程序都需要先listen一下?
- 半链接队列和全连接队列长度如何确定?
- “Cannot assign requested address”这个报错你知道是怎么回事吗?
- 一个客户端接口可以同时用在两条链接上吗?
- 服务端半/全链接队列满了会怎么样?
- 新连接的socket内核对象是什么时候建立的?
- 建立一条TCP连接需要消耗多长时间?
- 把服务器部署在北京,给纽约的用户访问可行吗?
- 服务器负载很正常,但是CPU被打倒底了是怎么回事?
服务端
int fd = socket(AF_INET, SOCK_STREAM, 0);
bind(fd, ...);
listen(fd, 128);
accept(fd, ...);
客户端
int fd = socket(AF_INET, SOCK_STREAM, 0);
connect(fd, ...);
深入理解listen
int fd = socket(AF_INET, SOCK_STREAM, 0);
bind(fd, ...);
listen(fd, 128);
accept(fd, ...);
listen系统调用
- 根据fd寻找socket内核对象
- backlog = min(backlog, net.core.somaxconn) (backlog与半连接队列、全连接队列有关)
- 半连接队列:SYN
- 全连接队列:ACK
协议栈listen(inet_listen(struct socket *sock,int backlog))
- 设置全连接队列长度 min(backlog、net.core.somaxconn)
- 如果遇到全连接队列溢出的问题,需要同时考虑backlog、net.core.somaxconn两个参数
- 接收队列内核对象的申请和初始化
sudo sysctl net.core.somaxconn
接收队列定义
-
request_sock_queue
-
服务端需要在第三次握手时快速找出第一次握手时留存的request_sock_queue对象,所以定义了一个哈希表
接收队列的申请和初始化
-
计算半连接队列长度
- min(backlog,somaxconn,tcp_max_syn_backlog) + 1再向上取整到2的N次幂,最小不能小于16
-
内存申请
- 申请的只是一个指针(哈希表),真正的半连接用的request_sock对象是在握手过程中分配的,计算完哈希值后挂到这个哈希表上
-
全连接队列头初始化
sudo sysctl net.ipv4.tcp_max_syn_backlog
深入理解Connect
int fd = socket(AF_INET, SOCK_STREAM, 0);
connect(fd, ...);
connect调用链展开
- 根据用户fd查找内核中的socket对象
- 设置socket状态 SS_UNCONNECTED ->TCP_SYN_SENT
- 动态选择端口
- 根据sk中的信息,构建一个syn报文并发送出去
选择可用端口
- 根据目的IP和端口等信息动态生成一个随机数
- 判断是否绑定过端口(客户端指定端口)
- 获取本地端口配置(net.ipv4.ip_local_port_rang),遍历查找;默认32678~61000
- 查看是否保留端口(ip_local_reserved_ports)(不希望内核调用的端口,可以写在里面)
- 查找和遍历已经使用的端口的哈希表
- 已经被使用的,通过check_established继续检查是否可用
- 未使用直接用,并添加到哈希表
- 遍历完所有端口没有可用的就会报错“Cannot assign requested address”
端口被使用怎么办
- 四元组唯一则可用
- 客户端单机最大连接数
发起SYN请求
- 申请并设置skb
- 添加到发送队列sk_write_queue
- tcp_transmit_skb实际发出syn
- 启动重传定时器;重传时间3.10版本是1秒
如果可用端口比较少,大概率需要循环很多轮选择端口,这回导致connect系统调用的CPU开销上涨
完整TCP链接建立过程
服务端响应SYN(tcp_rcv_state_process)
- 根据网络包(skb)TCP头信息中的目的IP信息查到当前处于listen状态的socket,进入tcp_v4_do_rcv处理握手过程
- 查找半连接队列,判断半连接队列是否满了
- 满了&未开启tcp_syncookies,则直接丢弃包
- 满了&开启tcp_syncookies,继续往下走
- 在全连接队列满的情况下,如果有young_ack,那么直接丢弃
- 分配request_sock内核对象
- 构造syn+ack包
- 发送syn+ack相应
- 添加到半连接队列,并开启计时器
客户端响应SYNACK(tcp_rcv_state_process)
-
删除发送队列
-
删除定时器
-
修改socket状态。状态变更为ESTABLISHED
-
初始化拥塞控制
-
打开TCP的保活计时器
-
申请和构造ack包并发送
服务端响应ACK
- 在半连接队列中查找request_socket对象
- 创建子socket对象
- 清理半连接队列
- 判断全连接队列是否满了,新的socket添加全连接队列
- 设置连接状态为ESTABLISHED
服务端accept
- 从全连接队列中获取一个头元素(request_socket)返回给用户进程
一条TCP连接建立要消耗多长时间
- 第一类:内核消耗CPU接收、发送或者处理,包括系统调用、软中断和上下文切换,耗时基本都是几微妙左右
- 第二类:网络传输几毫秒到几百毫秒不等
因此一般TCP建立连接的耗时取决于网络延时。
从客户端角度来看,只要ACK包发出了,内核就认为连接成功了,可以开始发送数据了。
异常TCP连接建立情况
connect系统调用耗时失控
-
平时服务器每秒2000QPS,CPU的idle(空闲占比)70%以上。
-
服务器4核,负载3(Load Average),cpu被打到底。
排查发现connect系统调用的CPU大幅上涨,又追查发现根本原因是实发当时可用端口不是特别充足。
为啥端口不充足会导致CPU消耗大幅上涨?
- 大量循环
- 循环内部需要等待锁以及在哈希表中执行多次的搜索,锁是CAS自旋锁,非阻塞,不断的尝试。
- 假设接口范围10000~30000,而且已经用尽了,那么每次发起连接的时候都需要循环2万次才退出
- 正常的connect系统调用耗时22微秒,端口不足耗时2581微秒;100多倍;虽然是微秒,但时间全是CPU时间
第一次握手丢包
- 查看半连接队列是否满了&tcp_syncookies是否开启
- 查看全连接队列是否满了&有young_ack存在(未处理完的半连接请求)
- 客户端发起重试,等不到预期的synack,触发超时重传逻辑。但是重传时间都是以秒计算的,那么意味着即使一次重传成功,也是1秒后的事情了
- 重试次数:net.ipv4.tcp_syn_retries,不是简单的次数比对,转化为时间进行对比。下一次重传时间是前一次的两倍
第三次握手丢包
- 全连接队列满了,直接丢弃
- 第三次握手失败,并不是客户端重试,而是由服务端重发synack
- 客户端发送完ack之后就会认为连接成功了,紧接着推送PSH,其实这个时候连接还没有真的建立起来。
握手异常小结
- 打开syncookies——防止SYN Flood攻击。
- 加大连接队列长度——ss -nlt
- 尽快调用accept——尽早从全连接队列里面取socket
- 尽早拒绝——Redis、MySQL等服务器的内核参数tcp_abort_on_overflow=1.队列满了直接发送reset命令给客户端,告诉客户端不要等待重试了,直接报错“connect reset by peer”。牺牲一个用户,比网站崩了要好。
- 尽量减少tcp连接次数——长连接
sudo sysctl net.ipv4.tcp_syncookies
如何查看是否有连接队列溢出
全连接队列
watch 'netstat -s | grep overflowed'
输出xxx times the listen queue of a socket overflowed;查看xxx次数有没有发生变化
半连接队列溢出判断
- tcp_syncookies打开就不会半连接队列溢出,建议打开
- 因为各种原因不想打开。
netstat -antp|grep SYN_RECV
查看SYN_RECV的数量与计算的队列长度对比。
总结
半/全连接的创建与长度限制、客户端端口的选择、半/全连接队列的添加删除以及重传定时器的启动。