Linux中TCP三次握手的实现
前言
网上三次握手八股文一大堆,我“为了面试”也去看了看,刚好那时候接触Linux比较多,突然想到TCP三次握手在Linux内核中是如何去实现的呢
?是不是会有不同?然后我就开始了漫长的百度(ps: 我比较菜,还不能拿着Linux上千个源码文件去怼)、源码之路。终于弄清了Linux中TCP三次握手的大致过程。
TCP三次握手
我那边都知道TCP建立连接前都需要客户端发送一个SYN包,服务端响应一个SYN + ACK包,客户端响应一个ACK包,这是三次握手的基本流程。
但如果让我们去实现三次握手,应该怎么去设计呢?
Linux中的实现
我们以网络编程中的相应函数为切入点
Linux中三次握手的主要函数有socket()、bind()、listen()、connect()、recv()、accept()
socket()
socket 在内核里并不是一个内核对象。而是包含 file、socket、sock 等多个相关内核对象构成,每个内核对象还定义了 ops 操作函数集合。
1 |
|
bind()
调用bind()函数绑定端口,会使connect()时选择端口方式无效。不推荐在客户端中使用bind(),这会打乱connect的端口选择过程。(但某些情况下可能会bind(),比如说在服务端和客户端进行协商使用什么端口来调用)
流程:
判断用户传入的端口是否小于1024
调用inet_csk_get_port来判断传入端口是否被占用,如果被占用就返回EADDRINUSE。这个方法不会到 ESTABLISH 的哈希表进行可用检测,只在 bind 状态的 socket 里查。所以默认情况下,只要端口用过一次就不会再次使用。
1 |
|
listen()
主要是申请和初始化接收队列,包括全连接队列和半连接队列
全连接队列长度:用户传入的 backlog 和 net.core.somaxconn 之间较小的那个值
半连接队列长度:min(backlog, somaxconn, tcp_max_syn_backlog) + 1 再上取整到 2 的幂次,但最小不能小于16。
流程:
- 根据文件描述符fd查找socket内核对象
- 获取内核参数net.core.somaxconn,与用户传入的backlog相比较,取最小的
- 调用sock->ops->listen(sock, backlog)函数,实际是inet_listen
- 判断是否是listen状态
- 如果不是listen状态,开始监听(接收队列的创建、初始化。内存申请、半连接队列长度的计算、全连接队列头的初始化等)。1. 计算半连接队列长度(与sysctl_max_syn_backlog取 一次最小值;保证不能比8小;向上对齐到2的整数次幂)。2. 申请内存。3. 全队列头初始化,设置为null。4. 将半连接队列挂载到接收队列上。
- 设置全连接队列的长度(backlog与net.core.somaxconn之间较小的哪个值)
1 |
|
connect()
流程:
- 进入内核系统根据用户传入的fd(文件描述符)来查询对应的socket内核对象
- 调用该sock对象的sock->ops->connect方法(inet_stream_connect)
- 根据sock的状态来进入不同的处理逻辑。第一次connect,sock的状态都是unconnect,所以会调sk->sk_prot->connect方法(tcp_v4_connect)
- 将socket状态设置为TCP-SYN-SENT
- 动态选择一个端口(首先判断是否bind()了一个端口,如果没有,就根据目标地址和端口等信息生成一个随机数,然后在本地端口范围中通过这个随机数逐渐增加遍历,如果是本地配置的保留端口就跳过,如果不是就遍历已经使用的端口的哈希链表(hinfo->bhash),判断是否已经被使用。如果该端口已经被使用并且
TCP连接中的四元组与当前建立的四元组完全一致
,就不可使用。继续遍历,找到了就返回端口,没找到就提示”Address already in use”) - 进行tcp连接。(首先申请并设置skb,然后添加到发送队列sk_write_queue上,进行发送,启动重传定时器)
1 |
|
判断四元组
两对四元组中只要任意一个元素不同,都算是两条不同的连接。以下的两条 TCP 连接完全可以同时存在(假设 192.168.1.101 是客户端,192.168.1.100 是服务端)
- 连接1:192.168.0.1 5000 192.168.0.2 8090
- 连接2:192.168.0.1 5000 192.168.0.2 8091
check_established 作用就是检测现有的 TCP 连接中是否四元组和要建立的连接四元素完全一致。如果不完全一致,那么该端口仍然可用
1 |
|
所以一台客户端机最大能建立的连接数并不是 65535。只要 server 足够多,单机发出百万条连接没有任何问题。
recv()
服务器响应SYN
主要工作是判断下接收队列是否满了,满的话可能会丢弃该请求,否则发出 synack。申请 request_sock 添加到半连接队列中,同时启动定时器。
1 |
|
客户端响应SYN + ACK
清除了 connect 时设置的重传定时器,把当前 socket 状态设置为 ESTABLISHED,开启保活计时器后发出第三次握手的 ack 确认。
1 |
|
服务端响应ACK
把当前半连接对象删除,创建了新的 sock 后加入到全连接队列中,最后将新连接状态设置为 ESTABLISHED。
1 |
|
accept()
服务端accept:从已建立好的全连接队列中取第一个返回给用户进程
1 |
|
总结
握手前的准备
- 客户端:通过
socket()
方法获取一个fd文件描述符,通过bind()方法绑定一个端口(可以不调用该方法;如果调用了,首先会判断用户 传入的端口号是否大于1024,然后通过inet_csk_get_port
方法判断该端口是否被占用,如果被占用就返回``EADDRINUSE`。只会在bind状态的socket里面查找,不会去ESTABLISH 的哈希表进行可用检测) - 服务端:通过
socket()
方法获取一个fd文件描述符,通过listen()方法初始化连接队列(全连接队列大小 =min(backlog, net.core.somaxconn)
,半连接队列大小 =min(backlog, somaxconn, tcp_max_syn_backlog) + 1 再向上取整到 2 的幂次,但最小不能小于16
)
第一次握手
- 客户端:根据传入的fd文件描述符,找到对应的socket内核对象。调用
sock->ops->connect
方法(inet_stream_connect
),然后在tcp_v4_connect()
方法中将socket状态设置为TCP_SYN_SENT
,通过_inet_hash_connect()
方法动态选择一个端口号(首先判断是否bind()
了一个端口,如果没有,就根据目标地址和端口等信息生成一个随机数,然后在本地端口范围中通过这个随机数逐渐增加遍历,如果是本地配置的保留端口就跳过,如果不是就遍历已经使用的端口的哈希链表(hinfo->bhash
),判断是否已经被使用,如果该端口已经被使用并且TCP连接中的四元组与当前建立的四元组完全一致,就不能使用,继续遍历。找到了就返回端口,没找到就提示Address already in use
)。通过tcp_connect()
构建skb并添加到发送队列sk_write_queue
上,启动重传定时器,进行发送。
第二次握手
- 服务端:首先会判断半连接队列是否满了,如果满了就进入
syn_flood_warning
去判断是否开启了tcp_syncookies
内核参数。如果队列满,且未开启tcp_syncookies
,那么该握手包将直接被丢弃,然后去判断一下全连接队列是否满了,如果满了并且有young_ack
,直接丢弃,否则发出 synack。申请request_sock
添加到半连接队列中,同时启动定时器。young_ack
(未处理完的半连接请求)是半连接队列里保持着的一个计数器。记录的是刚有SYN到达,没有被SYN_ACK重传定时器重传过SYN_ACK,同时也没有完成过三次握手的sock数量。 - 客户端:清除重传定时器,将当前socket状态设置为ESTABLISHED,开启保活计时器后发出ack确认
第三次握手
- 服务端:找到半连接队列中的request_sock对象,判断全连接队列是否满了,如果满了就修改计数器然后丢弃,没满就创建新的sock对象,将半连接中的连接请求进行删除,添加到全连接队列中,将状态设置为
TCP_ESTABLISHED
。
最后
调用accept()
方法从全连接队列中获取一个返回给用户进程
Linux中TCP三次握手的实现