深入理解Linux网络笔记(六):深度理解TCP连接建立过程

本文为《深入理解Linux网络》学习笔记,使用的Linux源码版本是3.10,网卡驱动默认采用的都是Intel的igb网卡驱动

Linux源码在线阅读:https://elixir.bootlin.com/linux/v3.10/source

5、深度理解TCP连接建立过程

1)、深入理解listen

在服务端程序里,在开始接收请求之前都需要先执行listen系统调用

1)listen系统调用

可以在net/socket.c下找到listen系统调用的源码

// net/socket.c
SYSCALL_DEFINE2(listen, int, fd, int, backlog)
{...// 根据fd查找socket内核对象sock = sockfd_lookup_light(fd, &err, &fput_needed);if (sock) {// 获取内核参数net.core.somaxconnsomaxconn = sock_net(sock->sk)->core.sysctl_somaxconn;if ((unsigned int)backlog > somaxconn)backlog = somaxconn;// 调用协议栈注册的listen函数err = security_socket_listen(sock, backlog);...}return err;
}

用户态的socket文件描述符只是一个整数而已,内核是没有办法直接用的。所以该函数中第一行代码就是根据用户传入的文件描述符来查找对应的socket内核对象

再接着获取了系统里的net.core.somaxconn内核参数的值,和用户传入的backlog比较后取一个最小值传入下一步

所以,虽然listen允许我们传入backlog(该值和半连接队列、全连接队列都有关系),但是如果用户传入的值比net.core.somaxconn还大的话是不会起作用的

接着通过调用sock->ops->listen进入协议栈的listen函数

2)协议栈listen

sock->ops->listen指针指的是inet_listen函数

// 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函数源码如下:

// 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是接收队列// 1.接收队列数据结构的定义// 2.接收队列的申请和初始化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中实际上包含了两件重要的事情

  1. 接收队列数据结构的定义
  2. 接收队列的申请和初始化
3)接收队列定义

icsk->icsk_accept_queue定义在inet_connection_sock下,是一个request_sock_queue类型的对象,是内核用来接收客户端请求的主要数据结构。我们平时说的全连接队列、半连接队列全都是在这个数据结构里实现的,如下图所示:

// include/net/inet_connection_sock.h
struct inet_connection_sock {struct inet_sock	  icsk_inet;struct request_sock_queue icsk_accept_queue;...
};

request_sock_queue的定义如下:

// 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类型的

// include/net/request_sock.h
struct listen_sock {u8			max_qlen_log;...u32			nr_table_entries;struct request_sock	*syn_table[0];
};

因为服务端需要在第三次握手时快速地查找出来第一次握手时留存的request_sock对象,所以其实是用了一个哈希表来管理,就是struct request_sock *syn_table[0]。max_qlen_log和nr_table_entries都和半连接队列的长度有关

4)接收队列申请和初始化

了解了全/半连接队列数据结构以后,再回到inet_csk_listen_start函数中。它调用了reqsk_queue_alloc来申请和初始化icsk_accept_queue这个重要对象

// 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内核对象的创建和初始化。其中包括内存申请、半连接队列长度的计算、全连接队列头的初始化,等等

// 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);elselopt = 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 *)。这其实是一个哈希表。真正的半连接用的request_sock对象是在握手过程中分配的,计算完哈希值后挂到这个哈希表上

5)半连接队列长度计算

reqsk_queue_alloc函数计算了半连接队列的长度

// 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的N次幂等于nr_table_entriesfor (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的整数次幂的

下面通过两个实际的案例计算一下

假设:某服务器上内核参数net.core.somaxconn为128,net.ipv4.tcp_max_sync_backlog为8192。那么当用户backlog传入5时,半连接队列到底是多长呢?

和代码一样,计算分为四步,最终结果为16

  1. min(backlog, somaxconn)=min(5, 128)=5
  2. min(5, tcp_max_sync_backlog)=min(5, 8192)=5
  3. max(5, 8)=8
  4. roundup_pow_of_two(8+1)=16

somaxconn和tcp_max_sync_backlog保持不变,listen时的backlog加大到512。再算一遍,结果为256

  1. min(backlog, somaxconn)=min(512, 128)=128
  2. min(128, tcp_max_sync_backlog)=min(128, 8192)=128
  3. max(128, 8)=128
  4. roundup_pow_of_two(128+1)=256

半连接队列的长度是min(backlog, somaxconn, tcp_max_sync_backlog)+1再向上取整到2的N次幂,但最小不能小于16

如果在线上遇到了半连接队列溢出的问题,想加大该队列长度,那么就需要同时考虑somaxconn、backlog和tcp_max_sync_backlog三个内核参数

为了提升性能,内核并没有直接记录半连接队列的长度。而是采用了一种晦涩的方法,只记录其N次幂。假设队列长度为16,则记录max_qlen_log为4(2的4次方等于16),假设队列长度为256,则记录max_qlen_log为8(2的8次方等于256)

6)listen过程小结

listen最主要的工作就是申请和初始化接收队列,包括全连接队列和半连接队列。其中全连接队列是一个链表,而半连接队列由于需要快速地查找,所以使用的是一个哈希表。全/半两个队列是三次握手中很重要的两个数据结构,有了它们服务端才能正常响应来自客户端的三次握手。所以服务端都需要调用listen才行

全连接队列的长度:对于全连接队列来说,其最大长度是listen时传入的backlog和net.core.somaxconn之间较小的那个值。如果需要加大全连接队列长度,那么就要调整backlog和somaxconn

半连接队列的长度:对于半连接队列来说,其最大长度是min(backlog, somaxconn, tcp_max_sync_backlog)+1再向上取整到2的N次幂,但最小不能小于16。如果需要加大半连接队列长度,那么需要一并考虑backlog、somaxconn和tcp_max_sync_backlog这三个参数

2)、深入理解connect

客户端在发起连接的时候,创建一个socket,然后瞄准服务端调用connect就可以了

int main() {fd = socket(AF_INET, SOCK_STREAM, 0);connect(fd, ...);...
}

socket函数执行完毕后,从用户层视角看到返回了一个文件描述符fd。但在内核中其实是一套内核对象组合,包含file、socket、sock等多个相关内核对象构成,每个内核对象还定义了ops操作函数集合

接下来就进入connect函数的执行过程

1)connect调用链展开

当在客户端机上调用connect函数的时候,事实上会进入内核的系统调用源码中执行

// 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);...// 进行connecterr = sock->ops->connect(sock, (struct sockaddr *)&address, addrlen,sock->file->f_flags);...
}

这段代码首先根据用户传入的fd(文件描述符)来查找对应的socket内核对象。对于AF_INET类型的socket内核对象来说,sock->ops->connect指针指向的是inet_stream_connect函数

// net/ipv4/af_inet.c
int inet_stream_connect(struct socket *sock, struct sockaddr *uaddr,int addr_len, int flags)
{...err = __inet_stream_connect(sock, uaddr, addr_len, flags);...
}int __inet_stream_connect(struct socket *sock, struct sockaddr *uaddr,int addr_len, int flags)
{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的处理逻辑中

上述代码中sk取的是sock对象。对于AF_INET类型的TCP socket来说,sk->sk_prot->connect指针指向的是tcp_v4_connect方法

// net/ipv4/tcp_ipv4.c
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
{...// 设置socket状态为TCP_SYN_SENTtcp_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来动态地选择一个可用的端口

2)选择可用端口
// 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_hash_connect时传入的两个重要参数:

  • inet_sk_port_offset(sk):这个函数根据要连接的目的IP和端口等信息生成一个随机数
  • __inet_check_established:检查是否和现有ESTABLISH状态的连接冲突的时候用的函数

__inet_hash_connect函数比较长,先看前面这一段

// net/ipv4/inet_hashtables.c
int __inet_hash_connect(struct inet_timewait_death_row *death_row,struct sock *sk, u32 port_offset,int (*check_established)(struct inet_timewait_death_row *,struct sock *, __u16, struct inet_timewait_sock **),int (*hash)(struct sock *sk, struct inet_timewait_sock *twp))
{...// 是否绑定过端口const unsigned short snum = inet_sk(sk)->inet_num;...if (!snum) {// 获取本地端口配置inet_get_local_port_range(&low, &high);...// 遍历查找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这个内核参数,来读取管理员配置的可用的端口范围

net.ipv4.ip_local_port_range的默认值为32768 61000,意味着端口总可用的数量是61000-32768=28233个

接下来进入了for循环。其中offset是通过inet_sk_port_offset(sk)计算出的随机数。那这段循环的作用就是从某个随机数开始,把整个可用端口范围遍历一遍。直到找到可用的端口后停止

接下来看看如何确定一个端口是否可用

// net/ipv4/inet_hashtables.c
int __inet_hash_connect(struct inet_timewait_death_row *death_row,struct sock *sk, u32 port_offset,int (*check_established)(struct inet_timewait_death_row *,struct sock *, __u16, struct inet_timewait_sock **),int (*hash)(struct sock *sk, struct inet_timewait_sock *twp))
{...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,net, head, port);...goto ok;...}...return -EADDRNOTAVAIL;ok:...
}

首先调用inet_is_reserved_local_port,判断要选择的端口是否在inet.ipv4.ip_local_reserved_ports中,在的话就不能用

整个系统中会维护一个所有使用过的端口的哈希表,它就是hinfo->bhash。接下来的代码就会在这里查找端口。如果在哈希表中没有找到,那么说明这个端口是可用的。至此端口就算是找到了。这个时候通过inet_bind_bucket_create申请一个inet_bind_bucket来记录端口已经使用了,并用哈希表的形式都管理了起来

遍历完所有端口都没找到合适的,就返回-EADDRNOTAVAIL,在用户程序上看到的就是Cannot assign requested address这个错误

当遇到Cannot assign requested address错误,应该去查一下net.ipv4.ip_local_port_range中设置的可用端口的范围是不是太小了

3)端口被使用过怎么办
// net/ipv4/inet_hashtables.c
int __inet_hash_connect(struct inet_timewait_death_row *death_row,struct sock *sk, u32 port_offset,int (*check_established)(struct inet_timewait_death_row *,struct sock *, __u16, struct inet_timewait_sock **),int (*hash)(struct sock *sk, struct inet_timewait_sock *twp))
{...for (i = 1; i <= remaining; i++) {port = low + (i + offset) % remaining;...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;...}}...}...
}

port在bhash中如果已经存在,就表示有其他的连接使用过该端口了。请注意,如果check_established返回0,该端口仍然可以接着使用

一个端口怎么可以被用多次呢?

回忆一下四元组的概念,两对四元组中只要任意一个元素不同,都算是两条不同的连接。以下的两条TCP连接完全可以同时存在(假设192.168.1.101是客户端,192.168.1.100是服务端)

  • 连接1:192.168.1.101 5000 192.168.1.100 8090
  • 连接2:192.168.1.101 5000 192.168.1.100 8091

check_established作用就是检测现有的TCP连接中是否四元组和要建立的连接四元素完全一致。如果不完全一致,那么该端口仍然可用

这个check_established是由调用方传入的,实际上使用的是__inet_check_established,源码如下:

// net/ipv4/inet_hashtables.c
static int __inet_check_established(struct inet_timewait_death_row *death_row,struct sock *sk, __u16 lport,struct inet_timewait_sock **twp)
{...// 找到哈希桶struct inet_ehash_bucket *head = inet_ehash_bucket(hinfo, hash);...// 便利看看有没有四元组一样的,一样的话就报错sk_nulls_for_each(sk2, node, &head->twchain) {if (sk2->sk_hash != hash)continue;if (likely(INET_TW_MATCH(sk2, net, acookie,saddr, daddr, ports, dif))) {tw = inet_twsk(sk2);if (twsk_unique(sk, sk2, twp))goto unique;elsegoto not_unique;}}...
unique:// 要用了,记录,返回0(成功)return 0;not_unique:...return -EADDRNOTAVAIL;
}

该函数首先找到inet_ehash_bucket,这个和bhash类似,只不过这是所有ESTABLISH状态的socket组成的哈希表。然后遍历这个哈希表,使用INET_TW_MATCH来判断是否可用

INET_TW_MATCH源码如下:

// include/net/inet_hashtables.h
#define INET_TW_MATCH(__sk, __net, __cookie, __saddr, __daddr, __ports, __dif) \((inet_twsk(__sk)->tw_portpair == (__ports))	&&		\(inet_twsk(__sk)->tw_daddr	== (__saddr))	&&		\(inet_twsk(__sk)->tw_rcv_saddr	== (__daddr))	&&		\(!(__sk)->sk_bound_dev_if	||				\((__sk)->sk_bound_dev_if == (__dif))) 	&&		\net_eq(sock_net(__sk), (__net)))

在INET_TW_MATCH中将__saddr__daddr__ports都进行了比较。当然除了IP和端口,INET_TW_MATCH还比较了其他一些项目

如果匹配,就是四元组完全一致的连接,所以这个端口不可用,也返回-EADDRNOTAVAIL

如果不匹配,哪怕四元组中有一个元素不一样,例如服务端的端口号不一样,那么就返回0,表示该端口仍然可用于建立新连接

所以一台客户端机最大能建立的连接数并不是65535。只要服务端足够多,单机发出百万条连接没有任何问题

4)发起syn请求

再回到tcp_v4_connect

// net/ipv4/tcp_ipv4.c
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
{...// 设置socket状态为TCP_SYN_SENTtcp_set_state(sk, TCP_SYN_SENT);// 动态选择一个端口err = inet_hash_connect(&tcp_death_row, sk);...// 函数用来根据sk中的信息,构建一个syn报文,并将它发送出去err = tcp_connect(sk);...
}

这时inet_hash_connect已经返回了一个可用端口,接下来就进入tcp_connect

// net/ipv4/tcp_output.c
int tcp_connect(struct sock *sk)
{...// 申请并设置skbbuff = alloc_skb_fclone(MAX_TCP_HEADER + 15, sk->sk_allocation);...tcp_init_nondata_skb(buff, tp->write_seq++, TCPHDR_SYN);...// 添加到发送队列sk_write_queuetcp_connect_queue_skb(sk, buff);...// 实际发出synerr = 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);return 0;
}

tcp_connect做了这么几件事:

  • 申请一个skb,并将其设置为SYN包
  • 添加到发送队列上
  • 调用tcp_transmit_skb将该包发出
  • 启动一个重传定时器,超时会重发

该定时器的作用是等到一定时间后收不到服务端的反馈的时候来开启重传。首次超时时间是在TCP_TIMEOUT_INIT宏中定义的,该值在Linux 3.10版本中是1秒

// net/ipv4/tcp_output.c
void tcp_connect_init(struct sock *sk)
{...// 初始化为TCP_TIMEOUT_INITinet_csk(sk)->icsk_rto = TCP_TIMEOUT_INIT;...
}

TCP_TIMEOUT_INIT在include/net/tcp.h中被定义成了1秒

// include/net/tcp.h
#define TCP_TIMEOUT_INIT ((unsigned)(1*HZ))
5)connect小结

客户端在执行connect函数的时候,把本地socket状态设置成了TCP_SYN_SENT,选了一个可用的端口,接着发出SYN握手请求并启动重传定时器

TCP连接中客户端的端口会在两个位置确定

第一个位置,在connect的时候,会随机地从ip_local_port_range选择一个位置开始循环判断。找到可用端口后,发出syn握手包。如果端口查找失败,会报错Cannot assign requested address。这个时候应该首先想到去检查一下服务器上的net.ipv4.ip_local_port_range参数,是不是可以再放得多一些

如果因为某些原因不希望某些端口被用到,那么把它们写到inet.ipv4.ip_local_reserved_ports参数中就行了,内核在选择的时候会跳过这些端口

另外还要注意一个端口是可以被用于多条TCP连接

这里选择端口都是从ip_local_port_range范围中的某一个随机位置开始循环的。如果可用端很充足,则能快一些找到可用端口,那循环很快就能退出。假设实际中ip_local_port_range中的端口快被用光了,这时候内核就大概率要把循环多执行很多轮才能找到可用端口,这会导致connect系统调用的CPU开销上涨

如果在connect之前使用了bind,将会使得connect系统调用时的端口选择方式无效。转而使用bind时确定的端口。调用bind时如果传入了端口号,会尝试首先使用该端口号,如果传入了0,也会自动选择一个。但默认情况下一个端口只会被使用一次。所以对于客户端角色的socket,不建议使用bind

3)、完整TCP连接建立过程

在基于TCP的服务开发中,三次握手的主要流程如下图所示:

服务端核心逻辑时创建socket绑定端口,listen监听,最后accept接收客户端的请求

// 服务端核心代码
int main() {int fd = socket(AF_INET, SOCK_STREAM, 0);bind(fd, ...);listen(fd, 128);accept(fd, ...);
}

客户端的核心逻辑是创建socket,然后调用connect连接服务端

// 客户端核心代码
int main() {fd = socket(AF_INET, SOCK_STREAM, 0);connect(fd, ...);...
}
1)客户端connect

客户端通过调用connect来发起连接。在connect系统调用中会进入内核源码的tcp_v4_connect

// net/ipv4/tcp_ipv4.c
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
{...// 设置socket状态为TCP_SYN_SENTtcp_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

// net/ipv4/tcp_output.c
int tcp_connect(struct sock *sk)
{...// 申请并设置skbbuff = alloc_skb_fclone(MAX_TCP_HEADER + 15, sk->sk_allocation);...tcp_init_nondata_skb(buff, tp->write_seq++, TCPHDR_SYN);...// 添加到发送队列sk_write_queuetcp_connect_queue_skb(sk, buff);...// 实际发出synerr = 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);return 0;
}

在tcp_connect申请和构造SYN包,然后将其发出。同时还启动了一个重传定时器,该定时器的作用是等到一定时间后收不到服务端的反馈的时候来开启重传。在Linux 3.10版本中首次超时时间是1秒

总结一下,客户端在调用connect的时候,把本地socket状态设置成了TCP_SYN_SENT,选了一个可用的端口,接着发出SYN握手请求并启动重传定时器

2)服务端响应SYN

在服务端,所有的TCP包(包括客户端发来的SYN握手请求)都经过网卡、软中断,进入tcp_v4_rcv。在该函数中根据网络包(skb)TCP头信息中的目的IP信息查到当前处于listen状态的socket,然后继续进入tcp_v4_do_rcv处理握手过程

// 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的时候,半连接队列里必然空空如也,所以相当于什么也没干就返回了

// 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);...
}

在tcp_rcv_state_process里根据不同的socket状态进行不同的处理

// 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:...// 判断是否为SYN握手包if (th->syn) {...if (icsk->icsk_af_ops->conn_request(sk, skb) < 0)return 1;...}...}...
}

其中conn_request是一个函数指针,指向tcp_v4_conn_request。服务端响应SYN的主要处理逻辑都在这个tcp_v4_conn_request里

// net/ipv4/tcp_ipv4.c
int tcp_v4_conn_request(struct sock *sk, struct sk_buff *skb)
{...// 看看半连接队列是否满了if (inet_csk_reqsk_queue_is_full(sk) && !isn) {want_cookie = tcp_syn_flood_action(sk, skb, "TCP");if (!want_cookie)goto drop;}// 在全连接队列满的情况下,如果有young_ack,那么直接丢弃if (sk_acceptq_is_full(sk) && inet_csk_reqsk_queue_young(sk) > 1) {NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);goto drop;}// 分配request_sock内核对象req = inet_reqsk_alloc(&tcp_request_sock_ops);...// 构造syn+ack包skb_synack = tcp_make_synack(sk, dst, req,fastopen_cookie_present(&valid_foc) ? &valid_foc : NULL);...if (likely(!do_fastopen)) {...// 发送syn+ack响应err = ip_build_and_send_pkt(skb_synack, sk, ireq->loc_addr,ireq->rmt_addr, ireq->opt);...// 添加到半连接队列,并开启计时器inet_csk_reqsk_queue_hash_add(sk, req, TCP_TIMEOUT_INIT);...}...
}

在这里首先判断半连接队列是否满了,如果满了进入tcp_syn_flood_action去判断是否开启了tcp_syncookies内核参数。如果队列满,并未开启tcp_syncookies,那么该握手包将被直接丢弃

接着还要判断全连接队列是否满。因为全连接队列满也会导致握手异常,那干脆就在第一次握手的时候也判断了。如果全连接队列满了,且young_ack数量大于1的话,那么同样也是直接丢弃

young_ack是半连接队列里保持着的一个计数器。记录的是刚有SYN到达,没有被SYN_ACK重传定时器重传过SYN_ACK,同时也没有完成过三次握手的sock数量

接下来是构造synack包,然后通过ip_build_and_send_pkt把它发送出去

最后把当前握手信息添加到半连接队列,并开启计时器。计时器的作用是,如果某个时间内还收不到客户端的第三次握手,服务端会重传synack包

总结一下,服务端响应ack的主要工作是判断接收队列是否满了。满的话可能会丢弃该请求,否则发出synack。申请request_sock添加到半连接队列中,同时启动定时器

3)客户端响应SYNACK

客户端收到服务端发来的synack包的时候,也会进入tcp_rcv_state_process函数。不过由于自身socket的状态TCP_SYN_SENT,所以会进入另一个不同的分支

// 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的主要逻辑

// 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()

// 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);...
}
// 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的保活计时器

// 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确认

4)服务端响应ACK

服务端响应第三次握手的ack时同样会进入tcp_v4_do_rcv

// net/ipv4/tcp_ipv4.c
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{...if (sk->sk_state == TCP_LISTEN) {struct sock *nsk = tcp_v4_hnd_req(sk, skb);...if (nsk != sk) {...if (tcp_child_process(sk, nsk, skb)) {...}return 0;}}...
}

不过由于这已经是第三次握手了,半连接队列里会存在第一次握手时留下的半连接信息,所以tcp_v4_hnd_req的执行逻辑会不太一样

// net/ipv4/tcp_ipv4.c
static struct sock *tcp_v4_hnd_req(struct sock *sk, struct sk_buff *skb)
{...struct request_sock *req = inet_csk_search_req(sk, &prev, th->source,iph->saddr, iph->daddr);if (req)return tcp_check_req(sk, skb, req, prev, false);...
}

inet_csk_search_req负责在半连接队列里进行查找,找到以后返回一个半连接request_sock对象,然后进入tcp_check_req

// net/ipv4/tcp_minisocks.c
struct sock *tcp_check_req(struct sock *sk, struct sk_buff *skb,struct request_sock *req,struct request_sock **prev,bool fastopen)
{...// 创建子socketchild = inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk, skb, req, NULL);...// 清理半连接队列inet_csk_reqsk_queue_unlink(sk, req, prev);inet_csk_reqsk_queue_removed(sk, req);// 添加全连接队列inet_csk_reqsk_queue_add(sk, req, child);return child;...
}

创建子socket

先来详细看看创建子socket的过程,icsk_af_ops->syn_recv_sock是一个指针,它指向的是tcp_v4_syn_recv_sock函数

// 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);...
}

注意,在第三次握手这里又继续判断一次全连接队列是否满了,如果满了修改一下计数器就丢弃了。如果队列不满,那么就申请创建新的sock对象

删除半连接队列

把连接请求块从半连接队列中删除

// 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函数中把连接请求块从半连接队列中删除

添加全连接队列

接着添加新创建的sock对象

// include/net/inet_connection_sock.h
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对象插到全连接队列链表的尾部

// include/net/request_sock.h
static inline void reqsk_queue_add(struct request_sock_queue *queue,struct request_sock *req,struct sock *parent,struct sock *child)
{req->sk = child;sk_acceptq_added(parent);if (queue->rskq_accept_head == NULL)queue->rskq_accept_head = req;elsequeue->rskq_accept_tail->dl_next = req;queue->rskq_accept_tail = req;req->dl_next = NULL;
}

设置连接为ESTABLISHED

第三次握手的时候进入tcp_rcv_state_process的路径有点不太一样,是通过子socket进来的。这时的子socket的状态是

// 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);...}...
}

将连接设置为TCP_ESTABLISHED状态。服务端响应第三次握手ACK所做的工作是把当前半连接对象删除,创建了新的sock后加入全连接队列,最后将新连接状态设置为ESTABLISHED

5)服务端accept
// 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;...
}

reqsk_queue_remove这个操作很简单,就是从全连接队列的链表里获取一个头元素返回就行了

// 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;WARN_ON(req == NULL);queue->rskq_accept_head = req->dl_next;if (queue->rskq_accept_head == NULL)queue->rskq_accept_tail = NULL;return req;
}

所以,accept的重点工作就是从已经建立好的全连接队列中取出一个返回给用户进程

6)连接建立过程总结

三次握手详细过程总结如下图:

一条TCP连接需要消耗多长时间。以上几步操作,可以简单划分为两类:

  • 第一类是内核消耗CPU进行接收、发送或者是处理,包括系统调用、软中断和上下文切换。它们的消耗基本都是几微妙左右
  • 第二类是网络传输,当包被从一台机器上发出以后,中间要经过各式各样的网线,各种交换机路由器。所以网络传输的耗时相比本机的CPU处理,就要高得多了。根据网络远近一般在几毫秒到几百毫秒不等

在正常的TCP连接的建立过程中,一般考虑网络延时即可。一个RTT指的是包从一台服务器到另一台服务器的一个来回的延时时间,所以从全局来看,TCP建立连接的网络耗时大约需要三次传输,再加上少许的双方CPU开销,总共大约比1.5被RTT大一点点。不过从客户端视角来看,只要ACK包发出了,内核就认为连接建立成功,可以开始发送数据了。所以如果在客户端打点统计TCP连接建立耗时,只需两次传输耗时——即1个RTT多一点的时间(对于服务端视角来看同理,从SYN包收到开始算,到收到ACK,中间也是一次RTT耗时)

推荐阅读:

4.1 TCP 三次握手与四次挥手面试题

4.4 TCP 半连接队列和全连接队列

内核参数 tcp_syncookies-- 默认开启tcp_syncookies

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/155969.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

python查找算法_顺序查找

顺序查找&#xff08;Sequential Search&#xff09;是一种简单直观的搜索算法&#xff0c;用于在无序数组中查找特定元素。它的基本思想是逐个遍历数组中的元素&#xff0c;直到找到目标元素或遍历完整个数组。本文将介绍顺序查找的基本原理&#xff0c;并通过Python代码进行详…

【剑指offer|图解|链表】链表的中间结点 + 链表中倒数第k个结点

&#x1f308;个人主页&#xff1a;聆风吟 &#x1f525;系列专栏&#xff1a;数据结构、算法模板 &#x1f516;少年有梦不应止于心动&#xff0c;更要付诸行动。 文章目录 &#x1f4cb;前言一. ⛳️链表的中间结点二. ⛳️链表中倒数第k个结点&#x1f4dd;结语 &#x1f4c…

如何为视频添加旁白,有哪些操作技巧?

简而言之&#xff0c;画外音是视频的旁白&#xff0c;在教程视频中添加旁白可以使视频更加有趣&#xff0c;并向观看者传达更多的信息。 如果您是视频制作人&#xff0c;想要为视频添加旁白&#xff0c;可阅读以下文章&#xff0c;可以帮助您更好地进行配音。 制作配音的技巧…

www.testfire.nets渗透测试报告

www.testfire.nets渗透测试报告 一、测试综述 1.1.测试⽬的 通过实施针对性的渗透测试&#xff0c;发现testfire.net⽹站的安全漏洞&#xff0c;锻炼自己的渗透水平 1.2.测试范围 域名&#xff1a;www.testfire.net IP:65.61.137.117 测试时间&#xff1a; 2023年11月…

AC修炼计划(AtCoder Beginner Contest 329)

传送门&#xff1a;&#xff33;&#xff4b;&#xff59; Inc, Programming Contest 2023&#xff08;AtCoder Beginner Contest 329&#xff09; - AtCoder A&#xff0c;B&#xff0c;C&#xff0c;D 这四道题比较简单&#xff0c;就不多叙述。 E - Stamp 这题是一道比较…

opencv-图像平滑

高斯平滑 高斯平滑即采用高斯卷积核对图像矩阵进行卷积操作。高斯卷积核是一个近似服从高斯分布的矩阵&#xff0c;随着距离中心点的距离增加&#xff0c;其值变小。这样进行平滑处理时&#xff0c;图像矩阵中锚点处像素值权重大&#xff0c;边缘处像素值权重小。 import cv2 …

【封装UI组件库系列】全局样式的定义与重置

封装UI组件库系列第二篇样式​​​​​​​ ​​​​​​&#x1f31f;前言 &#x1f31f;定义全局样式 生成主题色和不同亮度的颜色 ​编辑 中性色及其他变量 &#x1f31f;样式重置 &#x1f31f;总结 ​​​​​​​​​​​​​​&#x1f31f;前言 在前端开发中&…

在c#中如何将多个点位(Point)转换为多边形(Polygon)并装换为shp图层

&#x1f47b;如图&#xff0c;我现在有一组经纬度点位Point&#xff0c;接下来我们将他装换为多边形Polygon格式 &#x1f47b;使用QGIS > 图层 > 添加图层 > 添加分隔文本图层 > 打开这个csv点位文件 &#x1f47b;打开后如左下图&#xff0c;csv文件中的四个点位…

C++ DAY03 类与对象

概述 对象&#xff1a;真实存在的事物 类&#xff1a; 多个对象抽取其共同点形成的概念 静态特征提取出的概念称为成员变量, 又名属性 动态特征提取出的概念称为成员函数, 又名方法 类与对象的关系 在代码中先有类后有对象 一个类可以有多个对象 多个对象可以属于同一个…

shell脚本之条件语句

条件语句 linux测试 test 测试 测试表达式是否成立&#xff08;用echo $? 检测是否正确&#xff09; 语法&#xff1a;test [选项] [文件名] 选项作用-e测试文件是否存在-r查看文件有无读的权限-d测试是否为目录-f测试是否为文件-w测试当前用户有无写的权限-x测试是否有执…

香港科技大学广州|机器人与自主系统学域博士招生宣讲会—同济大学专场!!!(暨全额奖学金政策)

在机器人和自主系统领域实现全球卓越—机器人与自主系统学域 硬核科研实验室&#xff0c;浓厚创新产学研氛围&#xff01; 教授亲临现场&#xff0c;面对面答疑解惑助攻申请&#xff01; 一经录取&#xff0c;享全额奖学金1.5万/月&#xff01; &#x1f559;时间&#xff1a;…

git使用及常用命令

在初入公司中&#xff0c;若使用的是git管理工具&#xff0c;需要做以下步骤&#xff1a; 1&#xff0c;常用命令在&#xff1a; &#xff08;1&#xff09;&#xff0c;git config --global user.name xxx(名字) //若不设置 那么下次提交代码时会报错 其次该设置名字和…

gitlab安装配置及应用

安装 ##安装依赖 yum install -y curl policycoreutils-python openssh-server perl#上传包 rz gitlab-jh-16.5.2-jh.0.el7.x86_64.rpm 安装 yum install gitlab-jh-16.0.3-jh.0.el7.x86_64.rpm 初始化并启动 # 以下两种方法都可以配置访问地址&#xff0c;第一种需要在yum安…

深度学习之二(前馈神经网络--Feedforward Neural Network)

概念 前馈神经网络(Feedforward Neural Network)是一种最基本的神经网络结构,也被称为多层感知器(Multilayer Perceptron,MLP)。它的特点是信息只在网络中单向传播,不会形成环路。每一层神经元的输出都作为下一层神经元的输入,没有反馈回路。 结构: 前馈神经网络通…

小程序中打印机纸张都支持哪些尺寸?

在小程序中添加打印机功能是一项非常实用的功能&#xff0c;它可以让用户方便地将小程序中的内容打印出来。然而&#xff0c;当用户想要打印内容时&#xff0c;他们可能会关心打印纸张支持哪些尺寸。打印机分为四种打印机&#xff1a;小票、标签、发货单和电子面单。下面具体介…

YOLO改进系列之注意力机制(GatherExcite模型介绍)

模型结构 尽管在卷积神经网络&#xff08;CNN&#xff09;中使用自底向上的局部运算符可以很好地匹配自然图像的某些统计信息&#xff0c;但它也可能阻止此类模型捕获上下文的远程特征交互。Hu等人提出了一种简单&#xff0c;轻量级的方法&#xff0c;以在CNN中更好地利用上下…

使用VC++设计程序,进行全局固定阈值分割、自适应阈值分割

图像分割 获取源工程可访问gitee可在此工程的基础上进行学习。 文章目录 图像分割实验内容一、全局固定阈值分割全局固定阈值分割的原理全局固定阈值分割的实验代码全局固定阈值分割的实验现象 二、自适应阈值分割自适应阈值分割的实验原理自适应阈值分割的实验代码自适应阈值…

解决 urllib2 中 CookiesMiddleware 的 cookie 问题

1. 问题背景 在网络爬虫开发中&#xff0c;Cookie 是一项关键的技术&#xff0c;用于跟踪用户的身份和状态。Cookie 是服务器在客户端存储的数据&#xff0c;通常用于维护用户会话和保存用户的登录信息。在爬虫应用中&#xff0c;模拟用户行为和保持 Cookie 状态是必要的&…

51单片机应用

目录 ​编辑 1. C51的数据类型 1.1 C51中的基本数据类型 1.2 特殊功能寄存器类型 2. C51的变量 2.1 存储种类 1. C51的数据类型 C51是一种基于8051架构的单片机&#xff0c;它支持以下基本数据类型&#xff1a; 位&#xff08;Bit&#xff09;&#xff1a;可以表…

超级实用的程序员接单平台,看完少走几年弯路,强推第一个!

”前途光明我看不见&#xff0c;道路曲折我走不完。“ 兜兜转转&#xff0c;心心念念&#xff0c;念念不忘&#xff0c;必有回响。终于找到了....... 网络上好多人都在推荐程序员线上接单&#xff0c;有人说赚得盆满钵满&#xff0c;有的人被坑得破口大骂&#xff0c;还有的人…