三次握手的代码总体逻辑如下图
首先在客户端方面:
客户端先建立对应的socket
然后进行connect连接
之后开始3次握手的流程
而对于服务端方面的话
服务端也是先进行socket的建立
然后进行bind, listen操作
最后accept
接下来我们就来详细看具体底层是怎么干的
从实现层次深度理解socket
socket,客户端要创建socket,服务端也要创建socket,客户端服务端通过socket进行通信,那么在linux系统中socket具体十个什么东西呢?
socket在内核实际就是一组内核对象组合(,包含 file、socket、sock等多个相关内核对象构成,每个内核对象还定义了 ops 操作函数集合。由于本节我们还会⽤到这个数据结构图。),一组结构体,如下图 在其中,有
struct file结构,
而在struct file下面有一个struct socket 结构,这个其实就是socket内核数据结构在内核中最上层的封装
而在struct socket中也会包含一些其它对象,如struct sock,struck proto_ops ...,通过定义这些对象, 来给顶层使用
其中,struck proto_ops inet_stream_ops中我们看到当accept,connect,listen过程的具体执行函数
在linux中“一切皆文件”,这体现在struct socket和struct file的关系上,通过struct file对象将很多内容进行抽象,既包扩普通文件又包括struct socket。这是一个非常抽象的概念并非指代linux中的所有内容都在某个具体文件中,是从内核实现上抽象为了文件这种概念。
socket在内核实现就是一组内核对象,就是通过这些内核对象封装了操作方法,给应用层暴露出来。
理解bind底层工作原理
bind函数发生在服务器端,那么在进行bind函数的时候,底层究竟发生了什么? 服务器端代码如下:
服务器在创建socket,拿到对应的socket句柄之后,就会进行bind操作
通过注释可以看到其bind操作主要是对IP和端口号进行绑定
为了更好的理解bind函数,我们看下面图片示例
服务器端在listen状态时,其内部有多个socket,每个socket对应着其的IP和端口号
而bind的过程,就是确定每个socket的对应IP和端口号
bind的核心执行代码是下面这段
将要绑定的IP地址设置到了socket的inet->inet_rcv_saddr成员中
将要绑定的端口设置到了socket的inet->inet_sport成员中
其实就是内核对象上做了2次的赋值操作
理解listen 系统调用底层原理
整体流程
在服务器端,一旦bind操作完成后,就会调用listen操作。 首先我们来看下3此握手的操作 三次握手中有两次是客户端发送的,服务器端用于接收,只有第二次握手才是服务器端发送的 那么服务器端就需要区分,客户端发来的握手,那次是第一次,那次是第二次。 这就需要服务器端进行记录是不是来过第一次握手,从而进行判定
当客户端发起第一次握手请求时,服务端会先第一次握手请求放入半连接队列中
当客户端发起第三次握手时,服务端就可以通过半连接队列 (hashMap结构) 判断这个请求是否是第3次握手
半连接队列记录客户端的连接是否来过
而全连接队列中存储的就是完完全全进行过的3次握手的连接,这时候连接已经建立起来了
连接完全建立后,内核进程需要通知用户进程将其取走,但是又没法当连接建立后就立马能够让用户进程取走,所以就需要全连接队列先将其存储起来,然后通知用户队列来取
所以在3次握手的过程中,有半连接队列和全连接队列这两个非常重要的数据结构,listen的作用就是初始化这两个结构。 这两个结构在socket中如下图
而在进行listen的过程中,内核会执行到resk_queue_alloc函数中去
源码分析
上述的内容其实已经从一个整体上讲解了listen的作用,接下来我们看一下相关的源码以跟高的去理解listen 在进行listen时,
listen系统调用
我么首先会进入下面这个函数
//file: net/socket.c
SYSCALL_DEFINE2(listen, int, fd, int, backlog)
{
//根据 fd 查找 socket 内核对象
sock = sockfd_lookup_light(fd, &err, &fput_needed);
if (sock) {
//获取内核参数 net.core.somaxconn
somaxconn = sock_net(sock->sk)->core.sysctl_somaxconn;
if ((unsigned int)backlog > somaxconn)
backlog = somaxconn;
//调⽤协议栈注册的 listen 函数
err = sock->ops->listen(sock, backlog);
......
}
在第一行的
sock = sockfd_lookup_light(fd, &err, &fput_needed);
中,由于⽤户态的 socket ⽂件描述符只是⼀个整数⽽已,内核是没有办法直接⽤的,所以需要先根据用户传入的文件描述符来查找对应的socket内核对象同时获取了系统⾥的
net.core.somaxconn
内核参数的值,和⽤户传⼊的backlog
⽐较后取⼀个最⼩值传⼊到下⼀步中注意这个传入的比较值,对于后面的全连接队列,半连接队列初始化是有作用的
同时这里也可以得出一点,用户如果传入一个很大的值比
net.core.somaxconn
大,是不会起任何作用的然后
sock->ops->listen(sock, backlog);
进入协议栈的listen函数
协议栈listen
在整体的socket内核结构图中,我们可以看到,listen指向的是 inet_listen
函数,即sock->ops->listen(C语言结构体语法)指向的是 sock->ops->listen
函数
函数如下
int inet_listen(struct socket *sock, int backlog)
{
//还不是 listen 状态(尚未 listen 过)
if (old_state != TCP_LISTEN) {
//开始监听
err = inet_csk_listen_start(sk, backlog);
}
//设置全连接队列⻓度
sk->sk_max_ack_backlog = backlog;
}
我们发现这个函数十分简单,在最底下这一行表明 服务器的全连接队列⻓度是 listen 时传⼊的 backlog 和 net.core.somaxconn 之间较⼩的那个值
, 然后就是其中最重要的 inet_csk_listen_start(sk, backlog)
函数
inet_csk_listen_start(sk, backlog)
函数如下
//file: net/ipv4/inet_connection_sock.c
int inet_csk_listen_start(struct sock *sk, const int nr_table_entries)
{
struct inet_connection_sock *icsk = inet_csk(sk);
//icsk->icsk_accept_queue 是接收队列,详情⻅ 2.3 节
//接收队列内核对象的申请和初始化,详情⻅ 2.4节
int rc = reqsk_queue_alloc(&icsk->icsk_accept_queue, nr_table_entries);
......
}
这个里面有关全连接队列和半连接队列的初始化逻辑就在其中的 reqsk_queue_alloc(&icsk->icsk_accept_queue, nr_table_entries);
之中
半连接/全连接队列初始化
队列结构
我们现在知道全连接队列和半连接队列的初始化逻辑就在其中的 reqsk_queue_alloc(&icsk->icsk_accept_queue, nr_table_entries);
,在具体的看这个函数之前,我们先来看看这个请求参数是什么样子的。 icsk->icsk_accept_queue
定义在 inet_connection_sock 下,是⼀个 request_sock_queue 类型的对象。是内核⽤来接收客户端请求的主要数据结构。我们平时说的全连接队列、半连接队列全部都是在这个数据结构⾥实现的, 如下图
其对应的源码大致如下
//file: include/net/inet_connection_sock.h
struct inet_connection_sock {
/* inet_sock has to be the first member! */
struct inet_sock icsk_inet;
struct request_sock_queue icsk_accept_queue;
......
}
其中对应的 到 request_sock_queue
源码如下
//file: include/net/request_sock.h
struct request_sock_queue {
//全连接队列
struct request_sock *rskq_accept_head;
struct request_sock *rskq_accept_tail;
//半连接队列
struct listen_sock *listen_opt;
......
};
由上知道
全连接队列是一个链表,他不需要进行复杂的查找工作,只需要先进先出就可以了
而半连接队列
listen_sock
如下
//file: include/net/request_sock.h
struct listen_sock {
u8 max_qlen_log;
u32 nr_table_entries;
......
struct request_sock *syn_table[0];
};
因为服务器端需要在第三次握⼿时快速地查找出来第⼀次握⼿时留存的 request_sock 对象,所以其实是⽤了⼀个hash 表来管理,就是 struct request_sock *syn_table[0]
。max_qlen_log
和 nr_table_entries
都是和半连接队列的⻓度有关。
队列初始化
在大致了解了全连接队列,半连接队列的结构后,我们现在来看下初始化的源码 reqsk_queue_alloc(&icsk->icsk_accept_queue, nr_table_entries);
, 如下
//file: net/core/request_sock.c
int reqsk_queue_alloc(struct request_sock_queue *queue,
unsigned int nr_table_entries)
{
size_t lopt_size = sizeof(struct listen_sock);
struct listen_sock *lopt;
//计算半连接队列的⻓度
nr_table_entries = min_t(u32, nr_table_entries, sysctl_max_syn_backlog);
nr_table_entries = max_t(u32, nr_table_entries, 8);
nr_table_entries = roundup_pow_of_two(nr_table_entries + 1);
//为 listen_sock 对象申请内存,这⾥包含了半连接队列
lopt_size += nr_table_entries * sizeof(struct request_sock *);
if (lopt_size > PAGE_SIZE)
lopt = vzalloc(lopt_size);
else
lopt = kzalloc(lopt_size, GFP_KERNEL);
//全连接队列头初始化
queue->rskq_accept_head = NULL;
//半连接队列设置
lopt->nr_table_entries = nr_table_entries;
queue->listen_opt = lopt;
......
}
其中
首先定义了一个个 struct listen_sock 指针,代表半连接队列,
接着计算半连接队列长度
传进来的 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 ⼩,这是⽤来避免新⼿⽤户传⼊⼀个太小的值导致⽆法建⽴连接使⽤的,至少为8
roundup_pow_of_two(nr_table_entries + 1) 是⽤来上对⻬到 2 的整数幂次的
即最终,半连接队列的⻓度是 min(backlog, somaxconn,tcp_max_syn_backlog) + 1 再上取整到 2 的幂次,但最小不能小于16。
最后总结下
全连接队列来说,其最大长度是 listen 时传⼊的 backlog 和 net.core.somaxconn 之间较⼩的那个值。如果需要加⼤全连接队列⻓度,那么就是调整 backlog 和 somaxconn
半连接队列来说,其最大长度是 min(backlog, somaxconn,tcp_max_syn_backlog) + 1 再上取整到 2 的幂次,但最⼩不能小于16
理解connect系统调用原理
当服务器端listen执行完成后,服务器端就进入了监听状态,这个时候我们再来看看客户端的connect(向服务端建立连接)。如下图
当客户端调用connect时,三次握手就会开始 在客户端发起第一次握手请求SYN时,其SYN的包结构如下
这里面connect的焦点就在源端口上,其最核心的源码如下
进入tcp_v4_connect函数中
内核会在其中动态选择一个端口,这个端口号如何选出来的呢?如下图
获取本地端口配置,拿到允许使用的端口范围
for循环遍历找到没有使用的端口号(很简单粗暴)
这里有一个问题:我们客户端在connect中选择了一个端口号,假设是50000,向服务器端建立连接,那么50000这个端口号下次还能不能再用呢?即客户端一个端口是否只能用一次? 这个答案是否,端口号可以用多次。客户端和服务端建立的连接在逻辑上是由四元组 (源IP,源端口,目的IP,目的端口) 来确定的,客户端有自己的IP和端口号,服务器端也有自己的IP和端口号,只要下一次建立的连接让这个4元组任意一个元素变化了,那就可以由同一个端口建立连接,如目标服务器换了另一台,目的IP就变了,连接就不同。如果连接的是一个确定的服务器和端口,那么客户端的端口就只能用一次了。
由于connect的源码总体逻辑比较简单,这里就不再贴详细的代码,只简述其逻辑过程
和listen一样,首先根据用户传入的的 fd(文件描述符)来查询对应的 socket 内核对象
然后判断这个内核对象的状态是不是connect,不是,则调用相关的connect函数,最终进入
net_stream_connect 函数
然后动态选择端口
inet_hash_connect函数->__inet_hash_connect函数
//file: net/ipv4/tcp_ipv4.c
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
{
//设置 socket 状态为 TCP_SYN_SENT
tcp_set_state(sk, TCP_SYN_SENT);
//动态选择⼀个端⼝
err = inet_hash_connect(&tcp_death_row, sk);
//函数⽤来根据 sk 中的信息,构建⼀个完成的 syn 报⽂,并将它发送出去。
err = tcp_connect(sk);
}
注意这里可以看到socket 状态为 TCP_SYN_SENT和发送SYN报文与选择端口的先后顺序
接下来我们来看选择端口的详细过程
//file:net/ipv4/inet_hashtables.c
int __inet_hash_connect(...)
{
for (i = 1; i <= remaining; i++) {
port = low + (i + offset) % remaining;
//查看是否是保留端⼝,是则跳过
if (inet_is_reserved_local_port(port))
continue;
// 查找和遍历已经使⽤的端⼝的哈希链表
head = &hinfo->bhash[inet_bhashfn(net, port,hinfo->bhash_size)];
inet_bind_bucket_for_each(tb, &head->chain) {
//如果端⼝已经被使⽤
if (net_eq(ib_net(tb), net) &&tb->port == port) {
//通过 check_established 继续检查是否可⽤
if (!check_established(death_row, sk,port, &tw))
goto ok;
}
}
//未使⽤的话
tb = inet_bind_bucket_create(hinfo->bind_bucket_cachep, ...);
......
goto ok;
}
return -EADDRNOTAVAIL;
ok:
...
}
首先判断端口是不是在
inet_is_reserved_local_port
这个我们不希望使用的短裤oui范围内,在的话就不能用然后判断端口号是否在系统中维护的⼀个所有使用过的端口的哈希表
hinfo->bhash
中,不在则可用在的话判断是否是新的四元组 (源IP,源端口,目标IP,目标端口)
深度理解三次握手在内核中的实现
在前面经过bind,listen,connect后,我们来串一下整体的流程,如下图
从图中来看,服务器在收到来自客户端的第3次握手时,内核会创建一个新的socket
在accept之前,3次握手在内核中已经完成
但是三次也仅仅只是在内核中完成,在用户态中明还并没有
因此只有再调用accept之后,3次握手在用户态中才算完成
参考资料
深入理解Linux网络