计网-深入理解三次握手的实现原理
三次握手的内部实现原理
不同于八股中简单的对三次握手的流程的介绍, 本文会从在Linux中使用socket建立TCP连接完成的工作的角度深度剖析三次握手
参考:
- 深入理解Linux网络: 修炼底层内功,掌握高性能原理 (张彦飞)
使用Socket通信的过程
1 | // 客户端的核心代码 |
1 | // 服务端的核心代码 |
socket函数的作用
从开发者的角度我们调用socket函数, 创建一个socket, 然后返回一个句柄用于访问和操作我们这个创建的socket. 从内核的角度来看, 调用这个函数会在内核内部创建一系列的socket相关的内核对象
从socket
的系统调用出发, 创建socket的主要位置是sock_create
, 而sock_create
又到了__scok_create
在__scok_create
中, 首先调用sock_alloc
来分配一个struct
sock内核对象, 接着获取协议族的操作函数表, 并调用其create方法, 对于AF_INET协议族来说, 执行到的是inet_create
方法
在inet_create
方法中, 根据SOCK_STREAM超找到对于TCP定义的操作方法的实现集合inet_stream_ops和tcp_prot, 并将它们分别设置到socket->ops和sock->sk_prot上.
再往下在sock_init_data
中, 将sock的sk_data_ready函数指针进行了初始化, 设置为默认的sock_def_readable
在软中断上收到数据包时会通过sk_data_ready函数指针 (实际上被设置成了sock_def_readable())来唤醒sock上等待的进程
现在一个tcp对象, 确切地说是AF_INET协议族下的SOCK_STREAM对象就算创建完毕了. 花费了一次socket系统调用的开销
socket小结
socket系统调用完成的工作有
- 创建struct sock等一系列内核对象
- 找到协议族的操作函数表, 初始化操作函数
- 对sock_init_data中的sk_data_ready函数指针进行初始化, 这里是sock_def_readable()
开销是一次系统调用
bind函数的作用
简单来说就是让操作系统将一个特定的socket和一个IP:port绑定起来
做的工作有
- 将要绑定的IP地址设置到socket的inet->inet_rcv_saddr成员上
- 将要绑定的端口设置到socket的inet->inet_sport成员上
listen函数的作用
listen函数的主要作用就是申请和初始化连接队列, 包括全连接队列和半连接队列. 其中全连接队列是一个链表, 而半连接队列为了快速查找使用的是哈希表.
listen系统调用
首先到listen系统调用, 在这一步主要是
- 通过句柄拿到socket内核对象
- 获取内核参数somaxconn(if backlog > somaxconn than backlog = somaxconn)
- 接下来通过sock->ops->listen进入到协议栈的listen函数
协议栈listen
因为是TCP, AF_INET类型的socket对象, sock->ops->listen指向的是inet_listen函数
- 如果状态不是LISTEN, 执行inet_csk_listen_start()函数
- 然后设置全连接队列的长度是backlog, 也就是服务端的全连接队列的长度是min(backlog, net.core.somaxconn)
再看到inet_csk_listen_start()函数
- 将sock强转成功inet_connection_sock(叫icsk)
- 调用reqsk_queue_alloc(&icsk->icsk_accept_queue, nr_table_entries), 接收队列内核对象的申请和初始化
这里能强转成功的原因是这些sock是逐层嵌套的关系
对于TCP的socket来说, sock对象实际上是一个tcp_sock. 因此可以随便强转成其中的某个数据结构
reqsk_queue_alloc包含了两件很重要的事情, 接受队列数据结构的定义和接收队列的申请和初始化
接收队列的定义
这里的接收队列并不是socket接收数据的rcv队列, 是指一个包含了全连接队列和半连接队列的数据结构
icsk->icsk_accept_queue定义在inet_connection_sock下面, 是一个request_sock_queue类型的对象, 是内核用来实现客户端请求的主要数据结构. 我们平时说的全连接队列和半连接队列都是在这个数据结构中实现的
1 | struct inet_connetcion_sock { |
1 | struct request_sock_queue { |
对于全连接队列, 因为不需要复杂的查找工作, accept处理的时候, 只需要先进先出就好, 所有使用链式队列就好
而半连接队列相关的数据结构是listen_opt, 是listen_sock类型的
1 | struct listen_sock { |
因为服务端需要在第三次握手的时候快速地查找出来第一次握手时留存的reques_sock, 所以使用了哈希表来管理
接收队列申请和初始化
回到inet_csk_listen_start函数中. 调用了reqsk_queue_alloc来申请和初始化icsk_accept_queue这个对象
- 首先计算出来半连接队列的长度
- 为listen_sock对象申请内存, 这里包含了半连接队列
- 全连接队列头初始化, 设置成NULL
- 将半连接队列挂到了接收队列queue上
半连接队列长度计算
在nr_table_entries在最初调用reqsk_queue_alloc计算, 值是net.core.somaxconn和用户调用listen的时候传入的backlog二者之间的最小值
- min_t(u32, nr_table_entries, sysctl_max_syn_backlog)和sysctl_max_syn_backlog内核对象再取了一次最小值
- max_t(u32, nr_table_entries, 8), 保证nr_table_entries不会小于8. 防止新手传入的一个太小的值无法建立连接
- roundup_pow_of_two(nr_table_entris + 1)用来向上对齐到2的整数次幂
所以最后, 半连接队列的长度是min(backlog, net.core.somaxconn, tcp_max_syn_backlog) + 1再向上取整到2的N次幂, 但是最小不能小于16(也就是前面的min计算出来的值不能小于8)
同时为了提升比较性能, 内核并没有直接记录半连接队列的长度, 而是记录的N次幂
listen小结
对于全连接队列, min(backlog, net.core.somaxconn)
半连接队列的长度是min(backlog, net.core.somaxconn, net.ipv4.tcp_max_syn_backlog) + 1再向上取整到2的N次幂, 但是最小不能小于16(也就是前面的min计算出来的值不能小于8)
也就是如果我们要调整半连接队列的长度, 要同时考虑这三个参数
connect函数的作用
connect调用链展开
首先就是和listen一样的步骤, 调用connect(fd, ...)
系统调用, 在系统调用内部首先使用sockfd_lookup_light(fd, ...)
来获取内核中对应的socket对象
对于AF_INET类型的socket内核对象来说, sock->ops->connect指针指向的是inet_stream_connect()
函数. 然后会进入到__inet_stream_connect()
刚创建的socket状态时SS_UNCONNECTED, 会在__inet_stream_connect()
进入到case SS_UNCONNECTED的处理逻辑中. 取出socket中的sock对象, 然后执行sock中的sk->sk_prot->connect指向的tcp_v4_connect()
在tcp_v4_connect()
中, 设置socket的状态为TCP_SYN_SENT, 调用inet_hash_connnect(…, sk)动态地选择一个端口, 然后调用tcp_connect(sk)来构建发送一个syn报文
选择可用的端口
接下来就是看到inet_hash_connect()是怎么动态地选择出来一个可用的端口的, inet_hash_connect()会直接调用__inet_hash_connect(death_row, sk, inet_sk_port_offset(sk), __inet_check_established, __inet_hash_nolisten)
其中有两个重要的参数
inet_sk_port_offset(sk)
: 这个函数是根据连接目的IP和端口等信息生成的一个随机数__inet_check_established
: 是检查是否和现有ESTABLISH状态的连接冲突的时候使用的函数
接下来, 我们进入到__inet_hash_connect()
判断这个socket是不是bind过, 如果调用过, 相当于已经手动选定了客户端的端口了, 就不需要动态地获取端口了. 如果没有调用过, 则snum为0, 我们进入到遍历查找出来可用的端口
接着从内核中获取本地端口配置, remaining = high - low - 1
从遍历所有的端口查找可用的端口
if (!snum) { // 遍历查找 for (i = 1; i <= remaining; i++) { port = low + (i + offset) % remaining; // 查看是否是保留端口, 是则continue跳过 //查找和遍历已经使用的端口的哈希链表 // 如果端口已经使用过了, 进一步调用check_established()检查端口是否可用 } }
在循环内部
- 判断inet_is_reserved_local_port, 判断要选择的端口是否在net_ipv4.ip_local_reserved_ports中, 在的话就不能用
- 整个系统中会维护所有已经被使用过的端口的哈希表, hinfo->bhash. 代码会在这个哈希表中查找要选择的端口有没有被使用过, 如果没有找到, 说明这个端口是可用的. 这个时候通过net_bind_bucklet_create申请一个inet_bind_bucket来记录端口已经使用了
- 遍历完所有的端口都没有找到可用的端口, 则会返回-EADDRNOTAVAIL(Error ADDRess NOT AVAILable), 在用户程序的视角上看就是Cannot assign requested address这个错误
如果端口已经被使用过了
我们如果在哈希表bhash中发现了这个端口已经使用过了, 会进一步进入到check_established 继续检查是否可用, 如果这个函数返回了0, 说明这个端口还能接着用
为什么使用过了还能接着使用?
我们只需要保证四元组是不一样的就行, 所以即使saddr和sport都是一样的, 只要daddr或者dport有一个不一样就行
check_established由调用方传入, 实际使用的是__inet_check_establied
在这个函数中会找到inet_ehash_bucket中这个端口对应的hash bucket, 然后遍历看看有没有四元组都一样的, 一样的话就报错. 其中inet_ehash_bucket是所有的ESTABLISH状态的socket组成的哈希表. 遍历这个哈希表, 然后使用INET_MATCH宏来判断是否可用
发起syn请求
回到tcp_v4_connect, 这个时候已经完成了获取一个可用端口了, 接下来就进入到tcp_connect(sk)
- 申请一个skb, 并将其设置成SYN包
- 添加到发送队列上
- 调用tcp_transmit_skb将该包发出去
- 启动一个重传定时器, 超时重传
connect小结
客户端在执行connect函数的时候, 把本地socket状态设置成TCP_SYN_SENT, 然后选一个可用的端口, 接着发出SYN握手请求并启动重传定时器.
搞清楚了TCP连接中客户端的端口会在两个位置确定
- 如果在connect之前调用了bind, 如果bind的不是0, 则会使用bind中指定的端口号
- 如果没有调用过bind(bind的端口号是0也会自动选择), 则会在connect的时候, 随机地从ip_local_port_range选择一个位置开始循环判断, 如果端口号查找失败, 则会报错 “Cannot assign requested address”
- 如果你不想某个端口号被使用到, 则把他们写入到ip_local_reserved_ports这个内核参数中就行了
完整的TCP连接的建立过程
客户端connect
客户端在执行connect函数的时候, 把本地socket状态设置成TCP_SYN_SENT, 然后选一个可用的端口, 接着发出SYN握手请求并启动重传定时器.
第一次重传超时时间一般是1s, 老版本的Linux可能是3s
服务端响应SYN
所有的TCP包, 都经过了网卡, 软中断, 进入到tcp_v4_rcv
函数. 该函数根据网络包(skb) TCP头信息中的目的IP查到当前处于listen状态的socket, 然后继续进入到tcp_v4_do_rcv
处理握手过程
tcp_v4_do_rcv()
- 如果socket的状态是TCP_LISTEN, 会进入到tcp_v4_hnd_req查看半连接队列. 因为是第一次握手, 所以半连接队列中是空的, 相当于什么都处理.
- 在
tcp_rcv_state_process
里根据不同的socket状态进行不同的处理(第一步握手的SYN和第三步握手的ACK就是在这里区分开来)
tcp_rcv_state_process在sk状态是TCP_LISTEN状态并且包是syn握手包的时候, 进入到icsk_af_ops_conn_request = tcp_v4_conn_request
函数, 服务端响应SYN主要处理逻辑都在里面
tcp_v4_conn_request()
- 判断半连接队列是否已经满了, 如果满了, 进入到tcp_syn_flood_action判断有没有开启tcp_syncookies内核参数. 如果队列满, 并且没有开启tcp_syncookies, 握手包直接丢弃
- 判断全连接队列是不是满了, 如果满了, 且young_ack的数量 > 1, 同样直接丢弃
young_ack是半连接队列中保持的一个计数器, 记录的是刚有SYN到达, 没有被SYN_ACK重传定时器重传过SYN_ACK. 同时也没有完成过三次握手的sock数量
- 创建request_sock, 构造synack响应包, 通过
ip_build_and_send_pkt
发送响应包, 添加到半连接队列, 并开启定时器
这一步的主要工作就是, 判断接收队列是不是满了, 满了的话, 可能会丢弃该请求, 否则发送出去synack, 申请request_sock添加到半连接队列中, 同时启动定时器
客户端响应SYNACK
客户端收到SYNACK包的时候, 同样会进入到tcp_rcv_state_process函数. 因为自身的状态是TCP_SYN_SENT, 所以会进入到另一个不同的分支
tcp_rcv_synsent_state_process()
是客户端响应synack的主要逻辑
- 调用tcp_finish_connect标记该socket连接建立完成, 状态变成ESTABLISH, 初始化拥塞控制, 打开TCP保活计时器
- 调用tcp_send_ack(), 申请和构造ack包, 发送出去
- 调用tcp_clean_rtx_queue(), 删除发送队列, 删除connect时设置的重传定时器
客户端响应来自服务端的synack时清除了connect时创建的重传定时器, 把当前socket状态设置成ESTABLESHED, 开启保活定时器后发出第三次握手的请求
服务端响应ACK
反直觉的是, 这里服务端监听socket的状态仍然是TCP_LISTEN, 所以仍然会进入到tcp_v4_hnd_req
中, 不过因为半连接队列不是空了, 所以执行的逻辑会发生变化
inet_csk_search_req
负责在半连接队列中找到现在的TCP请求对应的半连接request_sock对象, 然后进入到tcp_check_req
tcp_check_req()
- 调用icsk_af_ops->syn_recv_sock =
tcp_v4_syn_recv_sock
函数创建子socket - 调用
inet_csk_reqsk_queue_unlink
清理半连接队列 - 将子
inet_csk_reqsk_queue_add
将子socket对应的request sock添加到全连接队列
创建子socket
tcp_v4_syn_recv_sock()
- 判断接收队列是不是满了, 如果满了, goto exit_overflow修改一下计数器就将请求丢弃
- 创建sock并初始化
删除半连接队列
inet_csk_reqsk_queue_unlink()
reqsk_queue_unlink()
函数将连接请求从半连接队列中删除
添加全连接队列
inet_csk_reqsk_queue_add()
- 在
reqsk_queue_add
中将握手成功的request_sock对象插入到全连接队列链表的尾部
设置状态为RESTABLISHED
第三次握手的时候进入到tcp_rcv_state_process的路径不一样, 是通过子socket进来的. 这个时候子socket的状态是TCP_SYN_RECV
在tcp_set_state(sk, TCP_ESTABLISHED), 将连接设置成TCP_ESTABLISHED. 服务端响应第三次握手ACK所作的工作是把当前半连接对象删除, 创建了新的socket后加入到全连接队列, 然后将新连接的状态设置为ESTABLISHED
服务端accept
inet_csk_accept中调用reqsk_queue_remove从全连接队列中获取一个头元素并返回.
accept重点工作就是从已经建立好的全连接队列中取出来一个返回给用户进程