五、深度理解TCP连接建立过程(重点)
1)这一章的目的
深度解析TCP连接的建立过程
2)实际问题(带着问题看书)
1)为什么服务器端程序都需要先listen一下?
2)半连接队列和全连接队列长度如何确定?
3)cannot assign request address 这个报错是怎么回事?怎么解决?
4)一个客户端端口可以同时用在两条连接上吗?
5)服务器半/全连接队伍满了会怎样?
6)新连接的socket内核对象是什么时候建立的?
7)建立一条TCP连接需要消耗多长时间?
8)把服务器部署在北京,给纽约的用户访问可行吗?
9)服务器负载正常,但是CPU被打到底了是怎么回事?
3)流程
(1)深入理解listen(申请和初始化接收队列,包括全连接队列和半连接队列)
- 流程
2.1 listen 系统调用
2.2 协议栈 listen
2.3 接收队列定义
2.4 接收队列申请和初始化
2.5 半连接队列长度计算
- 2.1 listen 系统调用
在 net/socket.c 下找到了 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);
......
}
用户态的 socket 文件描述符只是一个整数而已,内核是没有办法直接用的。所以该函数中第一行代码就是根据用户传入的文件描述符来查找到对应的 socket 内核对象
。再接着获取了系统里的 net.core.somaxconn 内核参数的值,和用户传入的 backlog 比较后取一个最小值传入到下一步中。接着通过调用 sock->ops->listen 进入协议栈的 listen 函数。
- 2.2 协议栈 listen
这里我们需要用到第一节中的 socket 内核对象结构图了,通过它我们可以看出 sock->ops->listen 实际执行的是 inet_listen。
//file: net/ipv4/af_inet.c
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;
}
在这里我们先看一下最底下这行,sk->sk_max_ack_backlog 是全连接队列的最大长度。所以这里我们就知道了一个关键技术点,服务器的全连接队列长度是 listen 时传入的 backlog 和 net.core.somaxconn 之间较小的那个值。
如果你在线上遇到了全连接队列溢出的问题,想加大该队列长度,那么可能需要同时考虑 listen 时传入的 backlog 和 net.core.somaxconn。
再回过头看 inet_csk_listen_start 函数。
//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);
......
}
在函数一开始,将 struct sock 对象强制转换成了 inet_connection_sock,名叫 icsk。这里简单说下为什么可以这么强制转换,这是因为 inet_connection_sock 是包含 sock 的。tcp_sock、inet_connection_sock、inet_sock、sock 是逐层嵌套的关系,类似面向对象里的继承的概念。
对于 TCP 的 socket 来说,sock 对象实际上是一个 tcp_sock。因此 TCP 中的 sock 对象随时可以强制类型转化为 tcp_sock、inet_connection_sock、inet_sock 来使用。
(在接下来的一行 reqsk_queue_alloc 中实际上包含了两件重要的事情。一是接收队列数据结构的定义。二是接收队列的申请和初始化。这两块都比较重要,我们分别在 2.3 节,和 2.4 节介绍。)
- 2.3 接收队列定义
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;
......
};
对于全连接队列来说,在它上面不需要进行复杂的查找工作,accept 的时候只是先进先出地接受就好了。所以全连接队列通过 rskq_accept_head 和 rskq_accept_tail 以链表的形式来管理。
和半连接队列相关的数据对象是 listen_opt,它是 listen_sock 类型的。
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 都是和半连接队列的长度有关。
- 2.4 接收队列申请和初始化
了解了全/半连接队列数据结构以后,让我们再回到 inet_csk_listen_start 函数中。它调用了 reqsk_queue_alloc 来申请和初始化 icsk_accept_queue 这个重要对象。
//file: net/ipv4/inet_connection_sock.c
int inet_csk_listen_start(struct sock *sk, const int nr_table_entries)
{
...
int rc = reqsk_queue_alloc(&icsk->icsk_accept_queue, nr_table_entries);
...
}
在 reqsk_queue_alloc 这个函数中完成了接收队列 request_sock_queue 内核对象的创建和初始化。其中包括内存申请、半连接队列长度的计算、全连接队列头的初始化等等。
//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 = ......
//为 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 指针。这个 listen_sock 就是我们平时经常说的半连接队列。接下来计算半连接队列的长度。计算出来了实际大小以后,开始申请内存。最后将全连接队列头 queue->rskq_accept_head 设置成了 NULL,将半连接队列挂到了接收队列 queue 上。
这里要注意一个细节,
半连接队列上每个元素分配的是一个指针大小
(sizeof(struct request_sock *))。这其实是一个 Hash 表。真正的半连接用的 request_sock 对象是在握手过程中分配,计算完 Hash 值后挂到这个 Hash 表 上。
- 2.5 半连接队列长度计算
在上一小节,我们提到 reqsk_queue_alloc 函数中计算了半连接队列的长度,由于这个有点小复杂,所以我们单独拉一个小节讨论这个。
//file: net/core/request_sock.c
int reqsk_queue_alloc(struct request_sock_queue *queue,
unsigned int nr_table_entries)
{
//计算半连接队列的长度
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);
//为了效率,不记录 nr_table_entries
//而是记录 2 的几次幂等于 nr_table_entries
for (lopt->max_qlen_log = 3;
(1 << lopt->max_qlen_log) < nr_table_entries;
lopt->max_qlen_log++);
......
}
传进来的 nr_table_entries 在最初调用 reqsk_queue_alloc 的地方可以看到,它是内核参数 net.core.somaxconn 和用户调用 listen 时传入的 backlog 二者之间的较小值。在这个 reqsk_queue_alloc 函数里,又将会完成三次的对比和计算。
-
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_entries + 1) 是用来上对齐到 2 的整数幂次的。
-
举例说明
1)举例一
假设:某服务器上内核参数 net.core.somaxconn 为 128, net.ipv4.tcp_max_syn_backlog 为 8192。那么当用户 backlog 传入 5 时,半连接队列到底是多长呢?
1、min (backlog, somaxconn) = min (5, 128) = 5
2、min (5, tcp_max_syn_backlog) = min (5, 8192) = 5
3、max (5, 8) = 8 (保证 nr_table_entries 不能比 8 小)
4、roundup_pow_of_two (8 + 1) = 16 (对齐到 2 的整数幂次)
2)举例二
somaxconn 和 tcp_max_syn_backlog 保持不变,listen 时的 backlog 加大到 512,再算一遍,结果为 256。
1、min (backlog, somaxconn) = min (512, 128) = 128
2、min (128, tcp_max_syn_backlog) = min (128, 8192) = 128
3、max (128, 8) = 128
4、roundup_pow_of_two (128 + 1) = 256
(算到这里,我把半连接队列长度的计算归纳成了一句话,半连接队列的长度是 min(backlog, somaxconn, tcp_max_syn_backlog) + 1 再上取整到 2 的幂次,但最小不能小于16。 )
如果你在线上遇到了
半连接队列溢出
的问题,想加大该队列长度,那么就需要同时考虑somaxconn、backlog、和 tcp_max_syn_backlog
三个内核参数。
- 内核
并没有直接记录半连接队列的长度
为了提升比较性能,内核并没有直接记录半连接队列的长度。而是采用了一种晦涩的方法,只记录其幂次假设队列长度为 16,则记录 max_qlen_log 为 4 (2 的 4 次方等于 16),假设队列长度为 256,则记录 max_qlen_log 为 8 (2 的 8 次方等于 256)。
(2)深入理解connect
- 整体流程
1)connect 发起连接
根据fd查询对应的socket内核对象,并调用对应的inet_stream_connect函数(之前说的是TCP流传输),并开始用inet_hash_connect选择端口
2) 选择可用端口
①根据目的IP和目的端口随机一个数
②根据这个数查找可用的端口
③就算端口用过,但是四元组不完全一致,还是可用的
3)发起 syn 请求(SYNC是作为发起连接的包)
①申请一个 skb,并将其设置为 SYN 包
②添加到发送队列上
③调用 tcp_transmit_skb 将该包发出
④启动一个重传定时器,超时会重发
- 1)connect 发起连接
根据fd查询对应的socket内核对象,据此可以知道接下来 sock->ops->connect
//file: net/socket.c
SYSCALL_DEFINE3(connect, int, fd, struct sockaddr __user *, uservaddr,
int, addrlen)
{
struct socket *sock;
//根据用户 fd 查找内核中的 socket 对象
sock = sockfd_lookup_light(fd, &err, &fput_needed);
//进行 connect
err = sock->ops->connect(sock, (struct sockaddr *)&address, addrlen,
sock->file->f_flags);
...
}
connect其实调用的是 inet_stream_connect 函数。(之前说的是TCP流传输)
//file: ipv4/af_inet.c
int inet_stream_connect(struct socket *sock, ...)
{
...
__inet_stream_connect(sock, uaddr, addr_len, flags);
}
int __inet_stream_connect(struct socket *sock, ...)
{
struct sock *sk = sock->sk;
switch (sock->state) {
case SS_UNCONNECTED:
err = sk->sk_prot->connect(sk, uaddr, addr_len);
sock->state = SS_CONNECTING;
break;
}
...
}
刚创建完毕的 socket 的状态就是 SS_UNCONNECTED,所以在 __inet_stream_connect 中的 switch 判断会进入到 case SS_UNCONNECTED 的处理逻辑中。接下来
①修改sock->state的状态为TCP_SYN_SENT
(表示发送了SYNC包)
②动态选择端口
(上述代码中 sk 取的是 sock 对象。继续回顾第一节中 socket 的内核数据结构图,可以得知 sk->sk_prot->connect 实际上对应的是 tcp_v4_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);
}
-
2) 选择可用端口(找到 inet_hash_connect 的源码)
-
源码
//file:net/ipv4/inet_hashtables.c
int inet_hash_connect(struct inet_timewait_death_row *death_row,
struct sock *sk)
{
return __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_sk_port_offset
//file:net/ipv4/inet_hashtables.c
int __inet_hash_connect(...)
{
//是否绑定过端口
const unsigned short snum = inet_sk(sk)->inet_num;
//获取本地端口配置
inet_get_local_port_range(&low, &high);
remaining = (high - low) + 1;
if (!snum) {
//遍历查找
for (i = 1; i <= remaining; i++) {
port = low + (i + offset) % remaining;
...
}
}
}
在这个函数中首先判断了 inet_sk(sk)->inet_num,如果我们调用过 bind,那么这个函数会选择好端口并设置在 inet_num 上。这个我们后面专门分一小节介绍。这里我们假设没有调用过 bind,所以 snum 为 0。
接着调用 inet_get_local_port_range,这个函数读取的是 net.ipv4.ip_local_port_range 这个内核参数。来读取管理员配置的可用的端口范围。
该参数的默认值是 32768 61000,意味着端口总可用的数量是 61000 - 32768 = 28232 个。如果你觉得这个数字不够用,那就修改你的 net.ipv4.ip_local_port_range 内核参数。
接下来进入到了 for 循环中。其中offset 是我们前面说的,通过 inet_sk_port_offset(sk) 计算出的随机数。那这段循环的作用就是从某个随机数开始,把整个可用端口范围来遍历一遍
。直到找到可用的端口后停止。
端口是靠加上随机数往后遍历找到的
,若用到端口太多,这个会很消耗CPU
- 如何来确定一个端口是否可以使用呢?
①判断是否为保留端口。因为某种原因不希望某些端口被内核使用,那么就把它们写到
ip_local_reserved_ports
这个内核参数中就行了
②如果端口已经被使用。整个系统
中会维护
一个所有使用过的端口的哈希表
,它就是 hinfo->bhash。接下来的代码就会在这里进行查找。如果在哈希表中没有找到,那么说明这个端口是可用的。至此端口就算是找到了。
//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;
}
}
//未使用的话,直接 ok
goto ok;
}
return -EADDRNOTAVAIL;
ok:
...
}
遍历完所有端口都没找到合适的,就返回 -EADDRNOTAVAIL,你在用户程序上看到的就是 Cannot assign requested address 这个错误。
/* Cannot assign requested address */
#define EADDRNOTAVAIL 99
以后再遇到 Cannot assign requested address 错误,你应该想到去查一下 net.ipv4.ip_local_port_range 中设置的可用端口的范围是不是太小
- 一个端口怎么可以被用多次呢?
INET_MATCH 源码如下:
// include/net/inet_hashtables.h
#define INET_MATCH(__sk, __net, __cookie, __saddr, __daddr, __ports, __dif) \
((inet_sk(__sk)->inet_portpair == (__ports)) && \
(inet_sk(__sk)->inet_daddr == (__saddr)) && \
(inet_sk(__sk)->inet_rcv_saddr == (__daddr)) && \
(!(__sk)->sk_bound_dev_if || \
((__sk)->sk_bound_dev_if == (__dif))) && \
net_eq(sock_net(__sk), (__net)))
在 INET_MATCH 中将 __saddr、__daddr、__ports 都进行了比较。当然除了 ip 和端口,INET_MATCH还比较了其它一些东东,所以 TCP 连接还有五元组、七元组之类的说法。为了统一,咱们还沿用四元组的说法。
如果 MATCH,就是说就四元组完全一致的连接,所以这个端口不可用。也返回 -EADDRNOTAVAIL。
总结
如果不 MATCH,哪怕四元组中有一个元素不一样,例如服务器的端口号不一样,那么就 return 0,表示该端口仍然可用于建立新连接。所以一台客户端机最大能建立的连接数并不是 65535。只要 server 足够多,单机发出百万条连接没有任何问题。
- 3)发起 syn 请求(SYNC是作为发起连接的包)
前提:
前面说到已经拿到了可用的端口
①申请一个 skb,并将其设置为 SYN 包
②添加到发送队列上
③调用 tcp_transmit_skb 将该包发出
④启动一个重传定时器,超时会重发
//file: net/ipv4/tcp_ipv4.c
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
{
......
//动态选择一个端口
err = inet_hash_connect(&tcp_death_row, sk);
//函数用来根据 sk 中的信息,构建一个完成的 syn 报文,并将它发送出去。
err = tcp_connect(sk);
}
//file:net/ipv4/tcp_output.c
int tcp_connect(struct sock *sk)
{
//申请并设置 skb
buff = alloc_skb_fclone(MAX_TCP_HEADER + 15, sk->sk_allocation);
tcp_init_nondata_skb(buff, tp->write_seq++, TCPHDR_SYN);
//添加到发送队列 sk_write_queue 上
tcp_connect_queue_skb(sk, buff);
//实际发出 syn
err = tp->fastopen_req ? tcp_send_syn_data(sk, buff) :
tcp_transmit_skb(sk, buff, 1, sk->sk_allocation);
//启动重传定时器
inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
inet_csk(sk)->icsk_rto, TCP_RTO_MAX);
}
(3)完整TCP连接建立过程
- 流程流程
- 服务器 listen 时,计算了全/半连接队列的长度,还申请了相关内存并初始化。
- 客户端 connect 时,把本地 socket 状态设置成了 TCP_SYN_SENT,选则一个可用的端口,发出 SYN 握手请求并启动重传定时器。(客户端一般是通过
connect系统调用
来发出SYN的,这里牵涉到本机的系统调用
和软中断的CPU耗时开销
) - 服务器响应 syn 时,会判断下
接收队列(这里会判断全和半是否)是否满了
,如果半连接队列满的话且未开启tcp_syncookies
,会丢弃该请求。还要判断全连接是否满了,如果全满了且有young_ack
的话,那么同样也是直接丢弃。否则发出 synack,申请 request_sock 添加到半连接队列中,同时启动定时器。(发出SYN/ACK响应。又是CPU耗时开销) - 客户端响应 synack 时,
①客户端响应来自服务器端的 synack 时清除了 connect 时设置的重传定时器,
②把当前 socket 状态设置为 ESTABLISHED
③开启保活计时器后发出第三次握手的 ack 确认。 - 服务器响应 ack 时,把对应半连接对象删除,创建了新的 sock 后加入到全连接队列中,最后将新连接状态设置为 ESTABLISHED。
① 创建子 socket(三次握手完毕,初始化内核sock对象)
②把当前半连接对象删除
③加入到全连接队列中
④将新连接状态设置为 ESTABLISHED - accept 从已经建立好的全连接队列中取出一个fd(这时候就是
五元组
了)返回给用户进程。
-
重试间隔时间
如果握手过程中发生丢包(网络问题,或者是连接队列溢出),内核会等待定时器到期后重试,重试时间间隔在 3.10 版本里分别是1s 2s 4s
…。在一些老版本里,比如 2.6 里,第一次重试时间是3 秒
。最大重试次数分别由tcp_syn_retries
和tcp_synack_retries
控制。
(如果你的线上接口正常都是几十毫秒内返回,但偶尔出现了 1 s、或者 3 s 等这种偶发的响应耗时变长的问题,那么你就要去定位一下看看是不是出现了握手包的超时重传了。) -
详细流程
1)服务器listen前面说过了,这里略掉
2)客户端 connect 时:
在 connect 系统调用中会进入到内核源码的 tcp_v4_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。再通过 inet_hash_connect 来动态地选择一个可用的端口后,进入到 tcp_connect 中。
//file:net/ipv4/tcp_output.c
int tcp_connect(struct sock *sk)
{
tcp_connect_init(sk);
//申请 skb 并构造为一个 SYN 包
......
//添加到发送队列 sk_write_queue 上
tcp_connect_queue_skb(sk, buff);
//实际发出 syn
err = tp->fastopen_req ? tcp_send_syn_data(sk, buff) :
tcp_transmit_skb(sk, buff, 1, sk->sk_allocation);
//启动重传定时器
inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
inet_csk(sk)->icsk_rto, TCP_RTO_MAX);
}
在 tcp_connect 申请和构造 SYN 包,然后将其发出。同时还启动了一个重传定时器,该定时器的作用是等到一定时间后收不到服务器的反馈的时候来开启重传。在 3.10 版本中首次超时时间是 1 s,一些老版本中是 3 s。总结一下,客户端在 connect 的时候,把本地 socket 状态设置成了 TCP_SYN_SENT,选了一个可用的端口,接着发出 SYN 握手请求并启动重传定时器。
3)服务器响应syn
在服务器端,所有的 TCP 包(包括客户端发来的 SYN 握手请求)都经过网卡、软中断,进入到 tcp_v4_rcv。在该函数中根据网络包(skb)TCP 头信息中的目的 IP 信息查到当前在 listen 的 socket。然后继续进入 tcp_v4_do_rcv 处理握手过程。
//file: net/ipv4/tcp_ipv4.c
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
...
//服务器收到第一步握手 SYN 或者第三步 ACK 都会走到这里
if (sk->sk_state == TCP_LISTEN) {
struct sock *nsk = tcp_v4_hnd_req(sk, skb);
}
if (tcp_rcv_state_process(sk, skb, tcp_hdr(skb), skb->len)) {
rsk = sk;
goto reset;
}
}
在 tcp_v4_do_rcv 中判断当前 socket 是 listen 状态后,首先会到 tcp_v4_hnd_req 去查看半连接队列。服务器第一次响应 SYN 的时候,半连接队列里必然是空空如也,所以相当于什么也没干就返回了。
//file:net/ipv4/tcp_ipv4.c
static struct sock *tcp_v4_hnd_req(struct sock *sk, struct sk_buff *skb)
{
// 查找 listen socket 的半连接队列
struct request_sock *req = inet_csk_search_req(sk, &prev, th->source,
iph->saddr, iph->daddr);
...
return sk;
}
在 tcp_rcv_state_process 里根据不同的 socket 状态进行不同的处理。
//file:net/ipv4/tcp_input.c
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb,
const struct tcphdr *th, unsigned int len)
{
switch (sk->sk_state) {
//第一次握手
case TCP_LISTEN:
if (th->syn) { //判断是 SYN 握手包
...
if (icsk->icsk_af_ops->conn_request(sk, skb) < 0)
return 1;
......
}
在这里首先判断半连接队列是否满了,如果满了的话进入 tcp_syn_flood_action 去判断是否开启了 tcp_syncookies 内核参数。如果半队列满,且未开启 tcp_syncookies,那么该握手包将直接被丢弃!!
接着还要判断全连接队列是否满。因为全连接队列满也会导致握手异常的,那干脆就在第一次握手的时候也判断了。如果全连接队列满了,且有 young_ack 的话,那么同样也是直接丢弃。
-
young_ack解释
young_ack
是半连接队列里保持着的一个计数器,记录未处理完的半连接请求的sock数量. -
tcp_syncookies
Linux实现了一种称为SYNcookie的机制,通过net.ipv4.tcp_syncookies控制,设置为1表示开启。简单说SYNcookie就是将连接信息编码在ISN(initialsequencenumber)中返回给客户端,这时server不需要将半连接保存在队列中,而是利用客户端随后发来的ACK带回的ISN还原连接信息,以完成连接的建立,避免了半连接队列被攻击SYN包填满。当Server端开启了syncookies=1,那么SYN半连接队列就没有逻辑上的最大值了,并且/proc/sys/net/ipv4/tcp_max_syn_backlog设置的值也会被忽略。
4)客户端响应 SYNACK
客户端收到服务器端发来的 synack 包的时候,也会进入到 tcp_rcv_state_process 函数中来。不过由于自身 socket 的状态是 TCP_SYN_SENT,所以会进入到另一个不同的分支中去。
//file:net/ipv4/tcp_input.c
//除了 ESTABLISHED 和 TIME_WAIT,其他状态下的 TCP 处理都走这里
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb,
const struct tcphdr *th, unsigned int len)
{
switch (sk->sk_state) {
//服务器收到第一个ACK包
case TCP_LISTEN:
...
//客户端第二次握手处理
case TCP_SYN_SENT:
//处理 synack 包
queued = tcp_rcv_synsent_state_process(sk, skb, th, len);
...
return 0;
}
tcp_rcv_synsent_state_process 是客户端响应 synack 的主要逻辑。
//file:net/ipv4/tcp_input.c
static int tcp_rcv_synsent_state_process(struct sock *sk, struct sk_buff *skb,
const struct tcphdr *th, unsigned int len)
{
...
tcp_ack(sk, skb, FLAG_SLOWPATH);
//连接建立完成
tcp_finish_connect(sk, skb);
if (sk->sk_write_pending ||
icsk->icsk_accept_queue.rskq_defer_accept ||
icsk->icsk_ack.pingpong)
//延迟确认...
else {
tcp_send_ack(sk);
}
}
tcp_ack()->tcp_clean_rtx_queue()
//file: net/ipv4/tcp_input.c
static int tcp_clean_rtx_queue(struct sock *sk, int prior_fackets,
u32 prior_snd_una)
{
//删除发送队列
...
//删除定时器
tcp_rearm_rto(sk);
}
//file: net/ipv4/tcp_input.c
void tcp_finish_connect(struct sock *sk, struct sk_buff *skb)
{
//修改 socket 状态
tcp_set_state(sk, TCP_ESTABLISHED);
//初始化拥塞控制
tcp_init_congestion_control(sk);
...
//保活计时器打开
if (sock_flag(sk, SOCK_KEEPOPEN))
inet_csk_reset_keepalive_timer(sk, keepalive_time_when(tp));
}
客户端修改自己的 socket 状态为 ESTABLISHED,接着打开 TCP 的保活计时器。
//file:net/ipv4/tcp_output.c
void tcp_send_ack(struct sock *sk)
{
//申请和构造 ack 包
buff = alloc_skb(MAX_TCP_HEADER, sk_gfp_atomic(sk, GFP_ATOMIC));
...
//发送出去
tcp_transmit_skb(sk, buff, 0, sk_gfp_atomic(sk, GFP_ATOMIC));
}
在 tcp_send_ack 中构造 ack 包,并把它发送了出去。
- 做的事
①客户端响应来自服务器端的 synack 时清除了 connect 时设置的重传定时器,
②把当前 socket 状态设置为 ESTABLISHED
③开启保活计时器后发出第三次握手的 ack 确认。
5)服务器响应 ACK
① 创建子 socket(三次握手完毕,初始化内核sock对象)
//file:net/ipv4/tcp_ipv4.c
const struct inet_connection_sock_af_ops ipv4_specific = {
......
.conn_request = tcp_v4_conn_request,
.syn_recv_sock = tcp_v4_syn_recv_sock,
//三次握手接近就算是完毕了,这里创建 sock 内核对象
struct sock *tcp_v4_syn_recv_sock(struct sock *sk, struct sk_buff *skb,
struct request_sock *req,
struct dst_entry *dst)
{
//判断接收队列是不是满了
if (sk_acceptq_is_full(sk))
goto exit_overflow;
//创建 sock && 初始化
newsk = tcp_create_openreq_child(sk, req, skb);
②删除在半连接队列的节点(把连接请求块从半连接队列中删除。)
//file: include/net/inet_connection_sock.h
static inline void inet_csk_reqsk_queue_unlink(struct sock *sk, struct request_sock *req,
struct request_sock **prev)
{
reqsk_queue_unlink(&inet_csk(sk)->icsk_accept_queue, req, prev);
}
reqsk_queue_unlink 中把连接请求块从半连接队列中删除。
③添加到全连接队列
//file:net/ipv4/syncookies.c
static inline void inet_csk_reqsk_queue_add(struct sock *sk,
struct request_sock *req,
struct sock *child)
{
reqsk_queue_add(&inet_csk(sk)->icsk_accept_queue, req, sk, child);
}
在 reqsk_queue_add 中将握手成功的 request_sock 对象插入到全连接队列链表的尾部。
//file: include/net/request_sock.h
static inline void reqsk_queue_add(...)
{
req->sk = child;
sk_acceptq_added(parent);
if (queue->rskq_accept_head == NULL)
queue->rskq_accept_head = req;
else
queue->rskq_accept_tail->dl_next = req;
queue->rskq_accept_tail = req;
req->dl_next = NULL;
}
④设置连接为 ESTABLISHED
//file:net/ipv4/tcp_input.c
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb,
const struct tcphdr *th, unsigned int len)
{
...
switch (sk->sk_state) {
//服务端第三次握手处理
case TCP_SYN_RECV:
//改变状态为连接
tcp_set_state(sk, TCP_ESTABLISHED);
...
}
}
6) accept 做的事
//file: net/ipv4/inet_connection_sock.c
struct sock *inet_csk_accept(struct sock *sk, int flags, int *err)
{
//从全连接队列中获取
struct request_sock_queue *queue = &icsk->icsk_accept_queue;
req = reqsk_queue_remove(queue);
newsk = req->sk;
return newsk;
}
//file:include/net/request_sock.h
static inline struct request_sock *reqsk_queue_remove(struct request_sock_queue *queue)
{
struct request_sock *req = queue->rskq_accept_head;
queue->rskq_accept_head = req->dl_next;
if (queue->rskq_accept_head == NULL)
queue->rskq_accept_tail = NULL;
return req;
}
reqsk_queue_remove 这个操作很简单,就是从全连接队列的链表里获取出第一个元素返回就行了。所以,accept 的重点工作就是从已经建立好的全连接队列中取出一个struct request_sock *返回给用户进程
。
(4)异常TCP连接建立情况
1、客户端connect系统调用耗时失控
正常一个系统调用的耗时也就是几个us(微秒)左右。但是在《追踪将服务器CPU耗光的凶手!》一文中一台服务器当时遇到一个状况,某次运维同学转达过来说该服务CPU不够用了,需要扩容。当时的服务器监控如下图:
该服务之前一直每秒抗2000左右的qps,CPU的idel一直有70%+。怎么突然就CPU一下就不够用了呢。而且更奇怪的是CPU被打到谷底的那一段时间,负载却并不高(服务器为4核机器,负载3-4是比较正常的)。 后来经过排查以后发现当TCP客户端TIME_WAIT有30000左右,导致可用端口不是特别充足的时候,connect系统调用的CPU开销直接上涨了100多倍,每次耗时达到了2500us(微秒),达到了毫秒级别。
当遇到这种问题的时候,虽然TCP连接建立耗时只增加了2ms左右,整体TCP连接耗时看起来还可接受。但是这里的问题在于这2ms多都是在消耗CPU的周期,所以问题不小。 解决起来也非常简单,办法很多:
- 方法
①修改内核参数net.ipv4.ip_local_port_range多预留一些端口号
②改用长连接都可以。
2、半/全连接队列满
3、TCP连接耗时实测
一段代码用来在客户端统计每创建一个TCP连接需要消耗多长时间。
<?php
$ip = {服务器ip};
$port = {服务器端口};
$count = 50000;
function buildConnect($ip,$port,$num){
for($i=0;$i<$num;$i++){
$socket = socket_create(AF_INET,SOCK_STREAM,SOL_TCP);
if($socket ==false) {
echo "$ip $port socket_create() 失败的原因是:".socket_strerror(socket_last_error($socket))."\n";
sleep(5);
continue;
}
if(false == socket_connect($socket, $ip, $port)){
echo "$ip $port socket_connect() 失败的原因是:".socket_strerror(socket_last_error($socket))."\n";
sleep(5);
continue;
}
socket_close($socket);
}
}
$t1 = microtime(true);
buildConnect($ip, $port, $count);
echo (($t2-$t1)*1000).'ms';
在测试之前,我们需要本机linux可用的端口数充足,如果不够50000个,最好调整充足。
# echo "5000 65000" /proc/sys/net/ipv4/ip_local_port_range
1)正常情况
注意:无论是客户端还是服务器端都不要选择有线上服务在跑的机器,否则你的测试可能会影响正常用户访问
首先我的客户端位于河北怀来的IDC机房内,服务器选择的是公司广东机房的某台机器。执行ping命令得到的延迟大约是37ms,使用上述脚本建立50000次连接后,得到的连接平均耗时也是37ms。这是因为前面我们说过的,对于客户端来看,第三次的握手只要包发送出去,就认为是握手成功了,所以只需要一次RTT、两次传输耗时。虽然这中间还会有客户端和服务端的系统调用开销、软中断开销,但由于它们的开销正常情况下只有几个us(微秒),所以对总的连接建立延时影响不大。
接下来我换了一台目标服务器,该服务器所在机房位于北京。离怀来有一些距离,但是和广东比起来可要近多了。这一次ping出来的RTT是1.6~1.7ms左右,在客户端统计建立50000次连接后算出每条连接耗时是1.64ms。
再做一次实验,这次选中实验的服务器和客户端直接位于同一个机房内,ping延迟在0.2ms~0.3ms左右。跑了以上脚本以后,实验结果是50000 TCP连接总共消耗了11605ms,平均每次需要0.23ms。
线上架构提示
:这里看到同机房延迟只有零点几ms,但是跨个距离不远的机房,光TCP握手耗时就涨了4倍。如果再要是跨地区到广东,那就是百倍的耗时差距了。线上部署时,理想的方案是将自己服务依赖的各种mysql、redis等服务和自己部署在同一个地区、同一个机房(再变态一点,甚至可以是甚至是同一个机架)。因为这样包括TCP链接建立啥的各种网络包传输都要快很多。要尽可能避免长途跨地区机房的调用情况出现。
2)连接队列溢出
测试完了跨地区、跨机房和跨机器。这次为了快,直接和本机建立连接结果会咋样呢?Ping本机ip或127.0.0.1的延迟大概是0.02ms,本机ip比其它机器RTT肯定要短。我觉得肯定连接会非常快,嗯实验一下。连续建立5W TCP连接,总时间消耗27154ms,平均每次需要0.54ms左右。嗯!?怎么比跨机器还长很多? 有了前面的理论基础,我们应该想到了,由于本机RTT太短,所以瞬间连接建立请求量很大,就会导致全连接队列或者半连接队列被打满的情况。一旦发生队列满,当时撞上的那个连接请求就得需要3秒+的连接建立延时。所以上面的实验结果中,平均耗时看起来比RTT高很多。
在实验的过程中,我使用tcpdump抓包看到了下面的一幕。原来有少部分握手耗时3s+,原因是半连接队列满了导致客户端等待超时后进行了SYN的重传。
我们又重新改成每500个连接,sleep 1秒。嗯好,终于没有卡的了(或者也可以加大连接队列长度)。结论是本机50000次TCP连接在客户端统计总耗时102399 ms,减去sleep的100秒后,平均每个TCP连接消耗0.048ms。比ping延迟略高一些。这是因为当RTT变的足够小的时候,内核CPU耗时开销就会显现出来了,另外TCP连接要比ping的icmp协议更复杂一些,所以比ping延迟略高0.02ms左右比较正常。
- 结论
1)TCP连接建立异常情况下,可能需要好几秒,一个坏处就是会影响用户体验,甚至导致当前用户访问超时都有可能。另外一个坏处是可能会诱发雪崩。所以当你的服务器使用短连接的方式访问数据的时候,一定要学会要监控你的服务器的连接建立是否有异常状态发生。如果有,学会优化掉它。当然你也可以采用本机内存缓存,或者使用连接池来保持长连接,通过这两种方式直接避免掉TCP握手挥手的各种开销也可以。
2)再说正常情况下,TCP建立的延时大约就是两台机器之间的一个RTT耗时,这是避免不了的。但是你可以控制两台机器之间的物理距离来降低这个RTT,比如把你要访问的redis尽可能地部署的离后端接口机器近一点,这样RTT也能从几十ms削减到最低可能零点几ms。
3)最后我们再思考一下,如果我们把服务器部署在北京,给纽约的用户访问可行吗? 前面的我们同机房也好,跨机房也好,电信号传输的耗时基本可以忽略(因为物理距离很近),网络延迟基本上是转发设备占用的耗时。但是如果是跨越了半个地球的话,电信号的传输耗时我们可得算一算了。 北京到纽约的球面距离大概是15000公里,那么抛开设备转发延迟,仅仅光速传播一个来回(RTT是Rround trip time,要跑两次),需要时间 = 15,000,000 *2 / 光速 = 100ms。实际的延迟可能比这个还要大一些,一般都得200ms以上。建立在这个延迟上,要想提供用户能访问的秒级服务就很困难了。所以对于海外用户,最好都要在当地建机房或者购买海外的服务器。
(5)bind 时端口如何选择(略)
我们之前看到 connect 选择端口之前先判断了 inet_sk(sk)->inet_num 有没有值。如果有的话就直接用这个,而会跳过端口选择过程。
这里的inet_num有没有这个值是它就是对 socket 使用 bind 时设置的
一般非常不推荐在客户端角色下使用 bind。因为这会打乱 connect 里的端口选择过程。
bind 的时候,如果传了端口,那么 bind 就会尝试使用该端口。如果端口号传的是 0 ,那么 bind 有一套独立的选择端口号的逻辑
//file: net/ipv4/af_inet.c
int inet_bind(struct socket *sock, struct sockaddr *uaddr, int addr_len)
{
struct sock *sk = sock->sk;
...
//用户传入的端口号
snum = ntohs(addr->sin_port);
//不允许绑定 1024 以下的端口
if (snum && snum < PROT_SOCK &&
!ns_capable(net->user_ns, CAP_NET_BIND_SERVICE))
goto out;
//尝试确定端口号
if (sk->sk_prot->get_port(sk, snum)) {
inet->inet_saddr = inet->inet_rcv_saddr = 0;
err = -EADDRINUSE;
goto out_release_sock;
}
根据第一节中的 socket 内核对象,能找到 sk->sk_prot->get_port 实际调用的是 inet_csk_get_port。该函数来尝试确定端口号,如果尝试失败,返回 EADDRINUSE。你的应用程序将会显示一条错误信息 “Address already in use”
。
#define EADDRINUSE 226 /* Address already in use */
看一下如果用户没有传入端口(传入的为 0),bind 是怎么选择端口的。
//file: net/ipv4/inet_connection_sock.c
int inet_csk_get_port(struct sock *sk, unsigned short snum)
{
...
if (!snum) {
inet_get_local_port_range(&low, &high);
remaining = (high - low) + 1;
smallest_rover = rover = net_random() % remaining + low;
do {
if (inet_is_reserved_local_port(rover))
goto next_nolock;
head = &hashinfo->bhash[inet_bhashfn(net, rover,
hashinfo->bhash_size)];
inet_bind_bucket_for_each(tb, &head->chain)
// 冲突检测
if (!inet_csk(sk)->icsk_af_ops->bind_conflict(sk, tb, false)) {
snum = rover;
goto tb_found;
}
} while (--remaining > 0);
}
}
这段逻辑和 connect 很像,通过 net_random 来从 net.ipv4.ip_local_port_range 指定的端口范围内一个随机位置开始遍历。也会跳开 ip_local_reserved_ports 保留端口配置。通过 inet_csk(sk)->icsk_af_ops->bind_conflict 进行冲突检测。
inet_csk_bind_conflict 这个函数整体比较复杂,不过我们只需要了解一点就好,该函数和 connect 中端口选择逻辑不同的是,并不会到 ESTABLISH 的哈希表进行可用检测,只在 bind 状态的 socket 里查。所以默认情况下,只要端口用过一次就不会再次使用。
4)本章总结(回答之前的问题)
-
1)为什么服务器端程序都需要先listen一下?
内核在相应listen调用的时候是创建了半连接、全连接两个队列
,这两个队列是三次握手中很重要的数据结构
,有了他们服务器才能正常响应来自客户端的三次握手。 -
2)半连接队列和全连接队列长度如何确定?
(1)服务器在listen的时候确定好了半连接队列和全连接队列的长度
(2)对于半连接队列
:最大长度是min(backlog , somaxconn , tcp_max_sync_backlog) + 1再上取整2的N次幂,但最小不能小于16。若需要就需要加大半连接队列长度,那么需要考虑backlog
、somaxconn
和tcp_max_syn_backlog
(3)对于全连接队列
: 最大长度是listen时传入的backlog和net.core.somaxconn之间较小的那个值。如果需要加大全连接队列长度,那么调整backlog
和net.core.somaxconn
-
3)cannot assign request address 这个报错是怎么回事?怎么解决?
(1)一条TCP由四元组构成:Server IP 、 Server Port 、Client IP 、Client Port。在连接建立前,前面三个元素是确定了的,只有Client Port是需要动态选择的。
(2)客户端在connect发起的时候自动选择端口号。具体的选择过程就是随机地从ip_local_port_range选择一个位置开始循环判断,跳过ip_local_reserved_ports里设置要规避的端口,然后挨个判断是否可用。如果没找到可用端口,会报错cannot assign request address
(3)方法:
vi /etc/sysctl.conf
修改可用端口范围
net.ipv4.ip_local_port_range = 5000 65000
设置最大TIME_WAIT数量
net.ipv4.tcp_max_tw_bucket = 10000
设置生效
sysctl -p
-
4)一个客户端端口可以同时用在两条连接上吗?
结论:在保证四元组不相同
的情况下,一个端口完全可以用在两条、甚至更多条的连接上(connect调用的时候如果发现用过,会判断新连接和老连接的四元组是否完全一致) -
5)服务器半/全连接队伍满了会怎样?(
第一次握手和第三次握手的时候判断
)
(1)服务器第一次握手的时候,会对半连接队列和全连接队列满的判断。
①如果半连接队列满了,且未开启tcp_syncookies
,那么该握手包直接被丢弃
②如果全连接队列满了,且有young_ack
(表示刚刚有SYN到底),那么同样的也是直接丢弃
③服务器响应第三次握手的时候,还会再次判断全连接队列是否满,满了就丢弃握手请求
(2)无论哪种丢弃,收不到预期的握手或响应包,重传定时器会在最短1秒后发起重试。这样接口响应的耗时最少就得1秒起步。如果重试也没把握成功,很有可能报超时。 -
6)新连接的socket内核对象是什么时候建立的?
内核其实在第三次握手完毕的时候就把sock对象创建好了。在用户进程调用accept的时候,直接把对象取出来,再包装一个socket对象就返回了。
accept从全连接队列取出一个节点,给这个节点分配一个fd,返回fd(全连接队列如果没有节点,就进入阻塞状态)
//file: include linux/net.h
struct sock
{
...
struct sock* sk;
};
-
7)建立一条TCP连接需要消耗多长时间?
(1)一般网络RTT根据服务器物理距离的不同大约是再零点几秒、几十毫秒之间。这个时间要比CPU本地的系统调用时间短得多。所以正常条件下
,再客户端或是服务器看来,都基本约等于一个RTT
。
(2)但是如果一旦出现了丢包,无论哪种原因,需要重传定时器介入的话,耗时最少1秒了(老版本的机器要3秒) -
8)把服务器部署在北京,给纽约的用户访问可行吗?
正常情况建立一条TCP连接耗时是双端网络一次RTT时间。北京到纽约距离15000千米,抛开设备转发延迟,两边以光速传播来回一个RTT:15000 000 x 2 /光速 = 100ms,实际延迟都需要200ms以上 -
9)服务器负载正常,但是CPU被打到底了是怎么回事?
(1)在端口不足
的情况下,connect系统调用的内部循环需要全部执行完毕才能判断有没有端口可用。如果发出的连接请求特别频繁,connect就会消耗大量的CPU。
(2)当时服务器上进程不是很多,每个进程都在疯狂消耗CPU,这时CPU就会被消耗光,就会出现服务器负载不高但很忙的情况