(最近在外地,没有自己的PC,只能周末腹泻式更新)
Listen系统调用
昨天介绍了bind
系统调用,下一步就是listen
系统调用了。它的参数很简单,只有两个。第一个是int fd
,读者想必已经非常熟悉了,就是我们socket所对应的文件描述符;而第二个参数则是代表了最大连接请求等待队列的长度。也就是在listen
完成之后,如果同一时间有多台主机向服务器发起连接请求,那么服务器允许的最大队列长度。同样地,以上介绍读者都可以通过man listen
命令自行查阅,以证明我不是在乱说。
接着,让我们使用同样的方法,搜索"SYSCALL_DEFINE.\(listen"
来找到listen
函数的入口,如下所示。其中sockfd_lookup_light
已经在上一集中介绍过了,其作用是给定一个fd
,找到其所对应的struct socket
对象。
紧接着,如果我们给定的backlog
超过了系统所允许的上限,那么以系统的参数为准。这里可以稍微进行一下扩展,somaxconn
代表了socket max connection,这是一个内核参数。我们有两种办法可以修改这个参数。
- 我们可以直接修改对应的文件,例如修改为2048,
echo 2048 > /proc/sys/net/core/somaxconn
。这样的修改是临时的,当系统重启之后就会失效。 - 我们可以在
/etc/sysctl.conf
文件中进行修改,加上或者修改现有配置为net.core.somaxconn = 2048
,重启后生效。或者使用命令sysctl -w net.core.somaxconn=2048 >> /etc/sysctl.conf
,更改会立即并永久生效,无需重启。
1 | int __sys_listen(int fd, int backlog) |
inet_listen的实现
通过和之前一样的方法,找到inetsw_array
中TCP的数组,listen中的sock->ops->listen
对应了inet_stream_ops->listen
,即inet_listen
。可以看见其的内部实现也相对简单,首先我们判断这个socket是否是未连接状态(sock->state != SS_UNCONNECTED
),并且它是不是有连接的socket(sock->type != SOCK_STREAM
)。如果已经连接或者socket类型就不对,那么自然是退出。
然后判断struct sock sk
当前的状态,如果已经是TCP_CLOSE
或者TCP_LISTEN
那么也是错误的状态,需要退出。这里有一个小细节,那就是代码中使用了TCPF_CLOSE
和TCPF_LISTEN
的位运算来判断,其中F代表Flag,这样做可以加速运算。跳转到include/net/tcp_states.h
可以看见TCPF_XXX的定义就是(1 << TCP_XXX)。例如TCPF_ESTABLISHED = (1 << TCP_ESTABLISHED)
。
1 | enum { |
之后的if判断中大部分是和tcp_fastopen相关的实现,这一段可以暂时跳过,核心业务逻辑代码其实只有一行inet_csk_listen_start
。
1 | int inet_listen(struct socket *sock, int backlog) |
inet_csk_listen_start的实现
可以看见这里再次用到了icsk和inet,也就是sk的上层封装。这个函数的前半段主要进行一些初始化操作,设置了等待队列。紧接着,最关键的一行是inet_sk_state_store(sk, TCP_LISTEN)
,它将我们socket的状态设置为TCP_LISTEN
,代表我们的socket已经允许接受外部的连接。
这时候,我们再次尝试获取端口号,如果成功,那么就listen成功。否则,我们重新将sk的状态设置为TCP_CLOSE
并且返回错误。额外的,可以看注释说明的,这里其实有一个竞争窗口,如果有其他进程也在访问get_port
会发生什么事?其实我们不用担心,因为在get_port
当中调用了spin_lock_bh
自旋锁,又或者其他协议的get_port
有义务保证其自身的线程安全。
这里可以还引出一个疑问,即我们已经在bind
中调用过inet_csk_get_port
了,为什么这里还需要再使用一次get_port
?原因其实很简单,linux中允许端口复用。假设我们设置允许端口复用,此时,另一个进程也以允许重用的方式bind
了相同的端口,并且以更快的速度完成了listen
。那么此时,这个端口就被完全占用直到其被释放。此时,尽管我们之前bind
成功,我们也无法完成listen
。具体的逻辑可以查看inet_csk_bind_conflict
函数,它被定义在net/ipv4/inet_connection_sock.c
中,由inet_csk_get_port
调用。
假设我们成功完成了get_port
,下一步我们将会将我们sk放入到TCP协议的全局hash表中。这里的hash表和bind中的不同,让我们看看sk->sk_prot->hash
函数的定义(也就是inet_hash
)和bind
中使用的哈希表的区别。
1 | int inet_csk_listen_start(struct sock *sk, int backlog) |
inet_hash的实现
inet_hash
的主要逻辑实现在__inet_hash
当中。可以看见和bind中不同,bind中将数据放入到哈希表发生在get_port
函数中,即inet_csk_get_port
,将数据写入bhash
。而inet_hash
主要将数据写入listening_hash
。更进一步地,进入到inet_hash2
函数的实现中,另一部分的数据写入了lhash2
哈希表中。
1 | static void inet_hash2(struct inet_hashinfo *h, struct sock *sk) |
我们可以直接查看struct inet_hashinfo
的定义。可以看见这个哈希表主要由三部分构成,分别是已经建立连接状态的socket的哈希表,已经bind的,和已经进入listen状态的。那么此时我们又要提出另外一个问题,为什么我们需要两个listening哈希表呢?
1 | struct inet_hashinfo { |
这其实是有历史原因的,在过去,Linux的listening哈希表只使用端口号来做hash。如下面代码,如果没有配置CONFIG_NET_NS
,即网络命名空间,哈希函数的结果就只取决于端口号。inet_sk_listen_hashfn
在__inet_hash
中被调用。在过去,这个行为就还好,无非是监听不同IP地址的相同端口时会出现哈希碰撞。但是在加入端口重用之后,哈希碰撞的问题变得严重,哈希表常常退化成一个链表。这样一来,为了性能引入了lhash2
。同时,为了兼容性,保留了listening_hash
。可以看见在第二版实现中,同时计算了地址和端口,加强了哈希随机性,使得哈希桶的占用情况更加均匀。
1 | // 第一版listening hashtable的哈希函数 |
listen系统调用小结
总的来说,listen
的实现相当简单。首先还是通过fd
来找到对应的socket。在拿到socket后,将其设置为TCP_LISTEN
状态,尝试再次占用它的端口确保没有任何竞争状态发生,并且调用hash函数将其写入到listening hashtable当中。其中我们还涉及到了listening hashtable的一些历史遗留问题。如果尝试再次获取端口失败,则将socket重置为TCP_CLOSE
状态,并且返回错误。
参考资料
lhash2的历史背景:()[https://segmentfault.com/a/1190000020536287]