进入【从内核看TCP三次握手】之前,先看两个基础问题【listen的作用是什么】【connect的作用是什么】???
在服务端程序里,在开始接收请求之前都需要执行一下listen系统调用。那么listen到底是干了什么呢?
这里主要是介绍socket里的【全连接队列】和【半连接队列】结构,所以socket里的其他属性结构就不详细展示了(比如:发送队列,等待队列等),可以看到【全连接队列】和【半连接队列】在socket中的结构如下
request_sock对象
】,所以为了查找的高效性,当仁不让的选择了hash表request_sock对象
】不需要进行复杂的查找工作,只是在accept的时候通过先进先出的顺序拿出来即可。所以全连接队列通过rskq_accept_head
和rskq_accept_head
以链表的形式来管理接收队列的申请和初始化
计算半连接队列的长度(比如:hash表里的数组长度),并且为半连接队列分配内存空间(因为半连接队列其实是用一个hash表来表示的)
将全连接队列的【rskq_accept_head】头指针指向null (因为此时队列里还没有元素)
半连接队列的长度如何计算呢?有点复杂,总结起来如下:
min(backlog, somaxconn, tcp_max_syc_backlog) + 1 再向上取整到2的N次幂,但最小不能小于16
】总结:
其实listen的主要作用就是【申请和初始化接收队列(包括全连接队列和半连接队列)】
在client向server端发起连接的时候,只需要创建一个socket并且瞄准server端调用connect方法就可以了,代码非常简单。
int main(){
// 在内核创建一个socket对象,并返回文件描述符给应用层
fd = socket(AF_INET, SOCK_STREAM, 0);
// 连接server端
connect(fd, ...);
.........
}
可以看到,仅需几行代码就可以连接到server端,但是其背后却隐藏着很多技术细节。
tcp_v4_connect
方法(将该socket作为该方法的入参传递到方法内部),执行tcp_v4_connect
方法,在该方法中会做如下几件事儿
TCP_SYN_SENT
】tcp_transmit_skb
将该包发送出去我们知道client如果要和server端建立连接,那么client必然要占用一个端口,那么client是如何选择可用端口的呢?
首先检查有没有调用过bind()函数,如果有,则使用bind()函数里入参指定的端口。比如下面NIO程序示例中,client去连接server的8080端口,但是client却调用了bind()方法指定了端口。所以此时client只能用端口9999去连接server的8080
public static void main(String[] args) throws IOException {
// 创建socket
SocketChannel sc = SocketChannel.open();
// bind了9999端口
sc.bind(new InetSocketAddress("localhost",9999));
// 连接到server的8080端口
sc.connect(new InetSocketAddress("localhost", 8080));
}
client如果没有调用bind方法,则调用inet_sk_port_offset(sk)
方法根据要连接的目的地的IP和端口信息生成一个【随机数 x】。
调用inet_get_local_port_range函数
读取net.ipv4.ip_local_port_range
这个内核参数的值,得到当前机器上的可用端口范围
net.ipv4.ip_local_port_range
这个内核参数的值,以增加可用参数范围然后进入循环,从【随机数x】开始,将机器上的可用端口范围都遍历一遍,直到找到可用的端口为止
ip_local_reserved_ports
】这个内核参数中就行了,内核在选择端口的时候会跳过【ip_local_reserved_ports
】里指定的端口如果遍历完了都找不到,则会抛出异常cannot assign requested address
错误。这里可能会有bug发生
理解了上面的listen和connect的作用后,我们便可以从内核角度整体的来看一下三次握手的过程了!!!
这个流程相比大多数人已经非常熟悉了,就不赘述了!!!
int main(){
// 创建socket对象
int fd = socket(AF_INET, SOCK_STREAM, 0);
// 执行bind
bind(fd, ...);
// 建立socket的半连接队列和全连接队列
listen(fd,128);
// 当有client连接完成三次握手后,将sock对象从全连接队列取出,进行处理
accept(fd, ...);
}
int main(){
// 创建socket对象
fd = socket(AF_INET, SOCK_STREAM, 0);
// 连接到server端
connect(fd, ...);
.....
}
client端和server端的代码就是这么简洁,就这么几行代码就可以创建client端到server端的连接。但是内部的技术细节却有非常多,下面进行详细介绍!!!
一、客户端发起连接SYN(第一次握手)
TCP_SYN_SENT
】二、服务端响应SYN ACK
tcp_v4_rcv
函数,在该函数中通过网络包(skb)TCP头信息中的目的IP信息查到当前处于listen状态的socket,然后进入tcp_v4_do_rcv
函数中处理握手请求tcp_syncookies内核参数
,则该握手包直接被丢弃tcp_syncookies内核参数
,则还是可以继续握手young_ack
数量大于1的话,同样也会丢弃该握手包
young_ack
是半连接队列里保持着的一个计数器,记录的是刚有SYN到达,并且没有被SYN_ACK重传定时器重传过的SYN_ACK,同时没有完成过的三次握手的socket数量syn ack包
】并且发送出去三、客户端处理服务端返回的【SYN ACK】
ESTABLISHED
】四、服务端处理第三次握手ACK
request_sock对象
】新的sock对象
】request_sock对象
】从【半连接队列】里删除ESTABLISHED
】五、服务端accept
我们知道一般三次握手完毕后,server端用户线程会执行accept方法,只有accept方法处理了【全连接队列】里的sock对象,此时client才能和server进行正常通信
accept方法主要的作用就是,从已经建立好连接的【全连接队列】中一个个的取出socket并返回给用户进程(比如在NIO中此时用户进程就可以将该socket注册到某个某个selector上)
上面的【一 ~ 四】步都是内核线程在处理,第五步则是由用户线程从【全连接队列里取走socket】进行处理
如果在【第一次握手】的时候,server端收到client端的SYN包后,如果全连接队列满了或者半连接队列满了,那么该握手请求便会被server端【直接丢弃】,那么client端如何处理?【SYN Flood攻击
便是通过大量的恶意请求来打满半连接队列,进而导致真正的用户连接不可用】
tcp_write_timeout
),控制重传的次数,如果超过该次数还没有重传成功,则连接失败 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JecgjYuG-1667647760949)(images/第一次握手丢包情况.png)]
如果在【第一次握手】时由于各种原因SYN包被server端丢弃,而server端不会反馈给client端。client由于迟迟没有收到server端的响应,client会进行重传,那么如果有大量client进行重传会影响性能吗?
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2SOdnpXJ-1667647760949)(images/握手丢包对接口性能的影响.png)]
假如在【第三次握手】的时候,client端发出ACK包后便将自己的socket状态修改为【ESTABLISHED
】了(client认为此时已经连接成功了,可以发数据了),server端收到client端的ACK包后,此时全连接队列满了,那么server端会【直接丢弃】该ACK包(并且client端不会重试了)。这里存在两个疑问?
疑问一、ack包被server端丢弃后,server端和client端如何处理?
net.ipv4.tcp_synack_retries
控制,默认是5),一旦达到最大重传次数server端还未重传成功,则认为连接建立失败了疑问二、此时client若发送数据给server端,那么能正常被server端收到并响应吗?
如上两个疑问都可以抓包验证!!!
如果client端口不充足,则每次和server端建立连接的时候(connect系统调用),都会过多的执行自旋锁等待与hash查找(循环遍历所有可用范围的端口,直到查找到一个可用端口)。这种情况会引起CPU开销上涨。严重情况下会耗光CPU,影响用户业务逻辑执行。这种情况的解决方案如下:
ip_local_port_range
内核参数来尽量加大可用端口范围tcp_tw_reuse
和tcp_tw_recycle
(不推荐)①、丢包情况分析,如下两种情况都有可能造成第一次握手时server丢包
tcp_syncookies
内核参数值为0这两种情况,在client端视角看来和网络断了没有什么区别,就是发出去的syn包没有任何反馈
②、丢包情况分析,如下情况有可能造成第三次握手时server丢包
③、解决方案:
syncookie
,在Linux系统中可以通过打开tcp_syncookies
内核参数来防止过多的请求打满半连接队列,包括【SYN Flood攻击
】,来解决服务端因半连接队列被打满而发生丢包!!! min(backlog, somaxconn, tcp_max_syc_backlog) + 1 再向上取整到2的N次幂,但最小不能小于16
】,所以可以根据这三个参数来综合调整半连接队列的长度backlog
】和内核参数【net.core.somaxconn
】之间较小的那个值。所以可以根据这两个参数来调整全连接队列的长度tcp_abort_on_overflow
】设置为1。表示如果队列满了,直接发送【reset指令】给客户端。告诉客户端进程不要傻傻的等待了。connection reset by peer
。 min(backlog, somaxconn, tcp_max_syc_backlog) + 1 再向上取整到2的N次幂,但最小不能小于16
】
net.core.somaxconn
】tcp_max_syc_backlog
】所以如果线上问题遇到【半连接队列溢出】,想加大该队列的长度,那么就需要同时考虑【backlog, somaxconn, tcp_max_syc_backlog
】这三个参数。
全连接队列的长度取决于listen时传入的【backlog
】和内核参数【net.core.somaxconn
】之间较小的那个值。
如果需要加大全连接队列的长度,那么就需要调整【backlog和net.core.somaxconn】。
对于全连接队列来说,使用 netstat s
(最好再配合watch命令来动态观察),就可以判断出是否有发生丢包。如果看到xxx times the listen queue of a socket overflowed
中的数值在增长,那么确定就是全连接队列满了。
对于半连接队列来说,要想查看是否溢出比较麻烦,需要自己计算半连接队列的长度(根据上面的公式),然后进行一大堆的对比。但是我们只要保证tcp_syncookies
这个内核参数的值是1就能保证不会有因为半连接队列满了而发生丢包的情况(所以建议开启tcp_syncookies
这个内核参数)
只叙述流程,而不详细叙述细节!!!
TCP_SYN_SENT
,并向server发起syn连接,并启动一个【重传定时器】SYN RCV
,并放入【半连接队列】,然后创建一个SYN ACK
响应包发送给client端,然后启动一个【重传定时器】SYN ACK
包,删除第一次握手时创建的【重传定时器】,修改自己的状态为 ESTABLISHED
(表示client连接建立完成),打开【TCP保活计时器】,并向server回复一个【ACK包】。ESTABLISHED
(表示server端与该client连接建立完成)参考: 《深入理解Linux网络》书籍