3. SO_REUSEPORT 内核实现:Linux 3.9+ 特性、内核负载均衡机制、与 SO_REUSEADDR 的区别

聊到端口复用,很多人的第一反应就是 SO_REUSEADDR。但说实话,真正让多进程/多线程网络服务性能起飞的关键,是 SO_REUSEPORT。这个特性在 Linux 3.9 才被合入主线,我当年升级内核时还踩过坑——旧内核编译选项没开,结果程序启动直接报错。

这一节,我们深入内核视角,看看 SO_REUSEPORT 到底是怎么工作的,以及它和 SO_REUSEADDR 到底有什么本质区别。

3.1 从内核视角看 SO_REUSEPORT

先明确一点:SO_REUSEPORT 允许 多个进程或线程 绑定到 完全相同的 IP 地址和端口。这在 Linux 3.9 之前是做不到的——你 bind 同一个端口,第二个进程一定会收到 EADDRINUSE。

内核是怎么实现的?简单说,它在 inet_hash 和 __inet_lookup_listen 这两个关键路径上做了改造。每个 SO_REUSEPORT socket 会被加入一个特殊的 reuseport group(复用组),内核在查找监听 socket 时,不再只返回第一个匹配项,而是从整个组里选一个。

核心数据结构: struct sock_reuseport。每个组维护一个 socket 数组,内核通过 reuseport_select_sock() 函数做选择。

3.2 内核负载均衡机制:不只是轮询

很多人以为内核就是简单的轮询(round-robin),其实没那么简单。Linux 3.9 最初用的是 基于源 IP 和源端口的哈希,后来 4.5 版本引入了 BPF (Berkeley Packet Filter) 分流,允许用户自定义选择逻辑。

我画了一张流程图,帮你理清内核的处理路径:

SO_REUSEPORT 内核数据包分发流程 数据包到达 __inet_lookup_listen() 是否 SO_REUSEPORT? 直接返回 reuseport_select_sock() 选择策略 ① 哈希取模(默认) ② BPF 分流(4.5+) 交付给选中的 socket 注意 哈希取模基于 (源IP + 源端口) 计算 同一连接始终落在同一 socket

看到没?默认的哈希取模策略保证了 同一个连接(相同的源 IP + 源端口)始终落在同一个 socket 上。这很重要——如果乱跳,TCP 连接状态就乱了。

我的经验: 在 4.5 之前的内核上,如果某个 worker 进程挂了,内核不会自动重哈希。这意味着已建立的连接不受影响,但新连接会继续哈希到已挂的 socket 上,直到你重启整个进程组。4.5 之后可以用 BPF 做更精细的控制,比如按 CPU 亲和性分发。

3.3 SO_REUSEPORT vs SO_REUSEADDR:本质区别

这两个名字太像了,我见过不少同事搞混。其实它们的定位完全不同。我列个表,一目了然:

对比维度 SO_REUSEADDR SO_REUSEPORT
引入时间 BSD 早期,Linux 一直支持 Linux 3.9(2013年)
核心目的 允许绑定 TIME_WAIT 状态的端口 允许多个进程绑定同一端口
绑定条件 后绑定的必须设置该选项,且 IP 不能完全相同 所有绑定的进程都必须设置该选项
负载均衡 无,谁先 bind 谁先得 内核级哈希/BPF 分发
适用场景 快速重启服务、多 IP 绑定 多进程/多线程高性能服务
安全性 任意用户可劫持端口(不安全) 仅同 UID 进程可共享(安全)

说白了,SO_REUSEADDR 解决的是 端口被占用后能不能再用 的问题,而 SO_REUSEPORT 解决的是 多个进程能不能同时用 的问题。

3.4 避坑指南:我曾经踩过的雷

讲几个真实案例,都是我在生产环境里遇到过的:

  • 坑一:旧内核不支持。 我曾经在 CentOS 6 上编译 Nginx 开启了 SO_REUSEPORT,结果启动时报 Protocol not available。查了半天才发现内核版本是 2.6.32。嗯,升级到 3.9+ 才解决。
  • 坑二:防火墙规则冲突。 有个同事在 iptables 里配了 --dport 匹配,结果 SO_REUSEPORT 的包被 NFQUEUE 模块截胡了。内核的负载均衡是在 inet_lookup 阶段做的,如果包在更早的 netfilter 阶段被拦截,reuseport 就失效了。
  • 坑三:惊群效应。 虽然 SO_REUSEPORT 能缓解惊群,但并不是完全消除。在极端高并发下,多个 worker 同时被唤醒的情况仍然存在。我建议配合 EPOLLEXCLUSIVE 使用(Linux 4.5+),效果更好。

安全警告: SO_REUSEADDR 没有 UID 检查。恶意进程可以绑定你的端口,劫持流量。而 SO_REUSEPORT 要求所有绑定进程的 UID 必须相同,否则返回 EPERM。生产环境优先用 SO_REUSEPORT。

3.5 代码示例:如何正确使用

最后给一段实际可用的代码片段。注意每个 worker 进程都要设置 SO_REUSEPORT:

// 伪代码:多进程端口复用
int fd = socket(AF_INET, SOCK_STREAM, 0);
int optval = 1;

// 关键:设置 SO_REUSEPORT
if (setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, 
               &optval, sizeof(optval)) < 0) {
    perror("setsockopt SO_REUSEPORT");
    exit(1);
}

struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(8080);

if (bind(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
    perror("bind");
    exit(1);
}

listen(fd, SOMAXCONN);

// 每个 worker 进程 fork 后直接 accept
// 内核会自动做负载均衡
for (int i = 0; i < NUM_WORKERS; i++) {
    if (fork() == 0) {
        handle_connections(fd);
        exit(0);
    }
}

注意:所有 worker 必须在 fork 之前 设置好 SO_REUSEPORT 并完成 bind。如果先 fork 再设置,子进程的 socket 不会加入同一个 reuseport group。

我个人习惯在 bind 之后检查一下 getsockopt 确认选项生效,虽然大多数情况下没问题,但多一步检查总归安心。