Linux端口重用EUSEPORT 新特性,提升服务器端性能的利器!为了解决什么问题而产生?
先考大家一个小问题,如果你的服务器上已经有个进程在 listen 6000 这个端口号了。那么该服务器上其它进程是否还能 bind 和 listen 该端口呢?
我相信一定会有一部分同学会答说是不能的。因为很多人都遇到过“Address already in use”这个错误。而这个错误产生的原因就是端口已经被占用。
但其实在 Linux 3.9 以上的内核版本里,是允许多个进程绑定到同一个端口号上。这就是我们今天要说的 REUSEPORT 新特性。
本文中我们将阐述 REUSEPORT 是为了解决什么问题而产生的。如果有多个进程复用同一个端口,当用户请求到达时内核是如何选一个进程进行响应的。学习完本文,你将深刻掌握这一提升服务器端性能的利器!
一、 REUSEPORT 要解决的问题
我觉得理解一个技术点的很重要的前提是要弄明白这个问题产生的背景,弄明白了背景再理解起技术点来就会容易许多。
关于 REUSEPORT 特性产生的背景其实在 linux 的 commit 中提供的足够详细了(参见:https://github.com/torvalds/linux/commit/da5e36308d9f7151845018369148201a5d28b46d)。
我就在这个 commit 中的信息的基础上给大家展开了说一说。
大家有过服务器端开发的经验的同学都知道,一般一个服务是固定监听某一个端口的。比如 Nginx 服务一般固定监听 80 或 8080,Mysql 服务固定监听 3306 等等。
在网民数量还不够多,终端设备也还没有爆炸的年代里,一直是在使用的是端口不可重复被监听的模式。但是到了 2010 年之后,Web 互联网已经发展到了一个高潮,移动端的设备也开始迎来了大发展。这个时候端口不能重用的性能瓶颈就暴露出来了。
应对海量流量的主要措施就是应用多进程模型。在端口不可被重复 bind 和 listen 的年代里,提供海量服务的多进程 Server 提供一般是采用如下两种进程模型来工作。
第一种是专门搞一个或多个进程服务 accept 新连接,接收请求,然后将请求转给其它的 worker 进程来处理。
这种多进程模型有两个问题,首先第一个是 dispatcher 进程并不处理任务,需要转交给 worker 进程来处理和响应。这会导致一次额外的进程上下文切换的开销。第二个问题是如果流量特别大的时候 dispatcher 进程很容易成为制约整个服务 qps 提升的瓶颈。
还有另一种多进程模型是多个进程复用一个 listen 状态的 socket,多个进程同时从一个 socket 中 accept 请求来处理。Nginx 就采用的是这种模型。
这种进程模型解决了第一个模型的问题。但是又带来了新的问题。当 socket 收到一条连接的时候,不能把所有的 worker 进程都招呼起来。需要用锁来保证唯一性,这样就会有锁竞争的问题。
二、REUSEPORT 的诞生
为了更高效地让多个用户态的进程接收和响应客户端的请求。Linux 在 2013 年的 3.9 版本中提供了 REUSEPORT 新特性。
内核详细Commit代码参见 https://github.com/torvalds/linux/commit/da5e36308d9f7151845018369148201a5d28b46d 和 https://github.com/torvalds/linux/commit/055dc21a1d1d219608cd4baac7d0683fb2cbbe8a
该特性允许同一机器上的多个进程同时创建不同的 socket 来 bind 和 listen 在相同的端口上。然后在内核层面实现多个用户进程的负载均衡。
我们来看下内核是如何支持 reuseport 这个特性的。
2.1 SO_REUSEPORT 设置
想给自己的服务开启 REUSEPORT 很简单,就是给自己 server 里 listen 用的 socket 上加这么一句。(这里以 c 为 demo,其它语言可能会有差异,但基本上差不多)
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, ...);
这行代码在内核中对应的处理步骤就是把内核 socket 的 sk_reuseport 字段设置为相应的值,开启的话是 1。
//file: net/core/sock.c
int sock_setsockopt(struct socket *sock, int level, int optname,
char __user *optval, unsigned int optlen)
{
...
switch (optname) {
...
case SO_REUSEPORT:
sk->sk_reuseport = valbool;
...
}
}
2.2 bind 时的处理
内核在 inet_bind 时会调用到 inet_csk_get_port 函数。我们来看看 bind 时对 reuseport 的处理过程。来看源码:
//file: net/ipv4/inet_connection_sock.c
int inet_csk_get_port(struct sock *sk, unsigned short snum)
{
...
//在绑定表(bhash)中查找,
head = &hashinfo->bhash[inet_bhashfn(net, snum,
hashinfo->bhash_size)];
inet_bind_bucket_for_each(tb, &head->chain)
//找到了,在一个命名空间下而且端口号一致,表示该端口已经绑定
if (net_eq(ib_net(tb), net) && tb->port == snum)
goto tb_found;
...
}
内核通过拉链哈希表的方式来管理所有的 bind 的 socket。其中 inet_bhashfn 是计算哈希值的函数。
//file: net/ipv4/inet_connection_sock.c
int inet_csk_get_port(struct sock *sk, unsigned short snum)
{
...
if (((tb->fastreuse > 0 &&
sk->sk_reuse && sk->sk_state != TCP_LISTEN) ||
(tb->fastreuseport > 0 &&
sk->sk_reuseport && uid_eq(tb->fastuid, uid))) &&
smallest_size == -1) {
goto success;
} else {
//绑定冲突
......
}
我们看 tb->fastreuseport > 0 和 sk->sk_reuseport 这两个条件。
这两个条件的意思是已经 bind 的 socket 和正在 bind 的 socket 都开启了 SO_REUSEPORT 特性。符合条件的话,将会跳转到 success 进行绑定成功的处理。也就是说,这个端口可以重复绑定使用!
uid_eq(tb->fastuid, uid) 这个条件目的是安全性,必须要求相同的用户进程下的 socket 才可以复用端口。避免跨用户启动相同端口来窃取另外用户服务上的流量。
2.3 accept 响应新连接
当有多个进程都 bind 和 listen 了同一个端口的时候。有客户端连接请求到来的时候就涉及到选择哪个 socket(进程)进行处理的问题。我们再简单看一下,响应连接时的处理过程。
内核仍然是通过 hash + 拉链的方式来保存所有的 listen 状态的 socket。