一、相关实际问题
1.为什么服务端程序都需要先listen一下
2.半连接队列和全连接队列长度如何确定
3.“Cannot assign requested address”这个报错是怎么回事
4.一个客户端端口可以同时用在两条连接上吗
5.服务端半/全连接队列满了会怎么样
6.新连接的soket内核对象是什么时候建立的
7.建立一条TCP连接需要消耗多长时间
8.服务器负载很正常,但是CPU被打到底了时怎么回事
二、深入理解listen
1)listen系统调用
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作比较后取一个最小值传入下一步。
所以虽然listen允许我们传入backlog(该值和半连接队列、全连接队列都有关系),但是会受到内核参数的限制。
接着通过调用sock->ops->listen进入协议栈的listen函数。
1.文件描述表:进程级别。一个 Linux 进程启动后,会在内核空间中创建一个 PCB 控制块,PCB 内部有一个文件描述符表,记录着当前进程所有可用的文件描述符,也即当前进程所有打开的文件。
2.打开文件表:系统级别。内核对所有打开文件维护的一个描述表格,将表格中的每一项称为打开文件句柄。它存储了一个打开文件的所有相关信息,例如当前文件的偏移量,访问模式,状态等等。
3.inode:系统级别。文件系统中的每个文件都有自己的i-node信息,它包含文件类型,访问权限,文件属性等等。
fdtable对应用户已打开文件表,或者说文件描述符表,是进程私有的。它的成员fd是file指针数组的指针,其中数组的索引就是文件描述符,而数组元素就是file指针,或者说已打开文件句柄。一个struct file的实例代表一个打开的文件,当一个用户进程成功打开文件时,会创建次结构体,并包含调用者应用程序的文件访问属性,例如文件数据的偏移量、访问模式和特殊标志等。此对象映射到调用者的文件描述符表,作为调用者应用程序对文件的句柄。
通常数组的第一个元素(索引为0)是进程的标准输入文件,数组的第二个元素(索引为1)是进程的标准输出文件,数组的第三个元素(索引为2)是进程的标准错误文件。查看进程允许打开的最大文件句柄数:ulimit -n;设置进程能打开的最大文件句柄数:ulimit -n xxx。
以上说法是在linux中的概念,而在windows中句柄的概念对应的是linux中文件描述符的概念,都是一个非负的整数。
2)协议栈listen
上文提到系统调用最后会通过sock->ops->listen进入协议栈的listen函数,对于AF_INET而言,指向的是inet_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和系统参数之间较小的那个值。所以如果再线上遇到了全连接队列溢出的问题,想加大该队列的长度,那么可能需要将它们都设置得更大。
回过头来看inet_csk_listen_start函数
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时接收队列 // 接收队列内核对象的申请和初始化 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来使用即子类转换为父类。
struct tcp_sock { /* inet_connection_sock has to be the first member of tcp_sock */ struct inet_connection_sock inet_conn; u16 tcp_header_len; /* Bytes of tcp header to send */ u16 xmit_size_goal_segs; /* Goal for segmenting output packets */ ... }; struct inet_connection_sock { /* inet_sock has to be the first member! */ struct inet_sock icsk_inet; struct request_sock_queue icsk_accept_queue; struct inet_bind_bucket *icsk_bind_hash; ... }; struct inet_sock { /* sk and pinet6 has to be the first two members of inet_sock */ struct sock sk; #if IS_ENABLED(CONFIG_IPV6) struct ipv6_pinfo *pinet6; #endif ... }; struct socket { socket_state state; ... struct sock *sk; const struct proto_ops *ops; };
也可以由sock强制转换为tcp_sock,因为在套接字创建的时候,就是以struct tcp_sock作为大小进行分配的。也就是内核中的每个sock都是tcp_sock类型,而struct tcp_sock正好是最大的那个结构体,不会出现越界访问的情况。
在接下来的一行reqsk_queue_alloc中实际上包含了两件重要的事情。一是接收队列数据结构的定义,二是接收队列的申请和初始化。
3)接收队列定义
icsk->icsk_accept_queue定义在inet_connection_sock下,是一个request_sock_queue类型的对象,是内核用来接收客户端请求的主要数据结构。我们平时说的全连接队列、半连接队列全都是在这个数据结构里实现的。
我们来看具体的代码。
struct inet_connection_sock { struct inet_sock icsk_inet; struct request_sock_queue icsk_accept_queue; ...... } struct request_sock_queue { // 全连接队列 struct request_sock *rskq_accept_head; struct request_sock *rskq_accept_tail; // 半连接队列 struct listen_sock *listen_opt; ...... } struct listen_sock { u8 max_qlen_log; u32 nr_table_entires; ...... struct request_sock *syn_table[0]; }
对于全连接队列来说,在它上面不需要进行复杂的查找工作,accept处理的时候只是先进先出地接受就好了。所以全连接队列通过rskq_accept_head和rskq_accept_tail以链表的形式来管理。
和半连接队列相关联的数据对象是listen_opt,它是listen_sock类型的。因为服务端需要在第三次握手时快速地查找出来第一次握手时留存的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这个接收队列。
在reqsk_queue_alloc这个函数中完成了接收队列request_sock_queue内核对象的创建和初始化。其中包括内存申请、半连接队列长度的计算、全连接队列头的初始化等等。
int reqsk_queue_alloc(struct request_sock_queue *queue, unsigend int nr_table_entries) { size_t lopt_size - sizeof(struct listen_sock); struct listen_sock *lopt; // 计算半连接队列的长度 nt_table_entries = min_t(u32, nr_table_entries, sysctl_max_syn_backlog); nr_table_entries = ...... // 为listen神奇对象申请内存,这里包括了半连接队列 lopt_size += nr_table_entries * sizeof(sturct 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就是我们平时经常说的半连接队列。接下来计算半连接队列的长度,计算出来实际大小后进行内存的申请。最后将全连接队列呕吐设置成了NULL,将半连接队列挂到了接收队列queue上。
半连接队列上每个元素分配的是一个指针大小,实际指向的request_sock的内存还未分配。这其实是一个哈希表,真正的半连接用的request_sock对象是在握手的过程中分配的,计算完哈希值后挂到这个哈希表上。
5)半连接队列长度计算
reqsk_queue_alloc函数中计算了半连接队列的长度,因为有些复杂所以没有在前面展开,这里深入一下。
int reqsk_queue_alloc(struct request_sock_queue *queue, unsigend 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_entries for(kopt->max_qlen_log = 3; (1 << lopt->max_qlen_log) < nr_table_entries; lopt->max_qlen_log++); ...... }
传进来的nr_table_entries在最初是用户传入的backlog和内核参数net.core.somaxconn二者之间的较小值。而在这个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的整数次幂
总的来说半连接队列的长度是min(backlog, somaxconn, tcp_max_syn_backlog)+1再向上取整到2的N次幂,但最小不能小于16。
最后为了提升比较性能,内核并没有直接记录半连接队列的长度,而是采用了一种晦涩的方法,只记录其N次幂。即如果队列长度为16,则记录max_qlen_log为4,只需要直到它是为了提升性能的即可。
6)小结
listen的主要工作其实就是申请和初始化接收队列,包括全连接队列和半连接队列。其中全连接队列是一个链表,而半连接队列由于需要快速地查找,所以使用的是一个哈希表。这两个队列是三次握手中很重要的两个数据结构,有了它们服务端才能正常相应来自客户端的三次握手。所以服务端都需要先调用listen才行。
同时我们也知道了去内核时如何确定全连接队列和半连接队列的长度。
1.全连接队列:min(backlog, net.core.somaxconn)
2.半连接队列:max(min(backlog, net.core.somaxconn, tcp_max_syn_backlog) + 1向上取整到2的幂次, 16)