4、SO_REUSEPORT实战:多进程/多线程负载均衡、Nginx与Apache的端口复用对比

上一章我们聊了 SO_REUSEADDR 怎么解决 TIME_WAIT 的问题。这一章,咱们来啃一块更硬的骨头——SO_REUSEPORT。

说实话,我第一次看到这个选项时,心里想的是:「这不就是 SO_REUSEADDR 的加强版吗?」后来踩了坑才明白,两者完全是两码事。SO_REUSEPORT 解决的是多进程/多线程如何高效共享同一个端口的问题。它让多个 socket 可以绑定到完全相同的 IP 和端口上,内核帮你做负载均衡。

我当年在做一个即时通讯网关时,单进程扛不住 10 万并发,改成多进程 + SO_REUSEPORT 后,直接干到了 50 万。嗯,这里面的门道,咱们今天一次讲透。

4.1 SO_REUSEPORT 的核心原理

先看一个最简单的场景。你写了一个 HTTP 服务,想用 4 个进程同时监听 8080 端口。没有 SO_REUSEPORT 的话,第二个 bind 就会报 EADDRINUSE。

有了 SO_REUSEPORT,多个进程可以 bind 同一个端口。内核收到一个 TCP 连接请求时,会按照某种算法选一个进程把连接丢过去。这个算法默认是哈希取模,保证同一个客户端 IP 的请求尽量落到同一个 worker 上——这对 session 保持很有用。

关键区别一句话说清:
SO_REUSEADDR 允许「不同 socket 绑定不同端口,但共享地址」;
SO_REUSEPORT 允许「多个 socket 绑定完全相同的 IP:端口」。

我画了一张图,帮你理解内核是怎么分发连接的:

SO_REUSEPORT 内核负载均衡流程 客户端连接到达 内核 TCP 层(SO_REUSEPORT 表) 哈希(源IP:源端口) % worker数 → 目标 worker Worker 1 (PID 1001) accept() 返回 fd Worker 2 (PID 1002) accept() 返回 fd Worker 3 (PID 1003) accept() 返回 fd 注意:内核在三次握手完成之后才分发,不是 accept() 时抢 所以不存在惊群问题,每个连接只唤醒一个 worker

4.2 多进程 vs 多线程:我该怎么选?

很多同学问我:「用 SO_REUSEPORT 时,多进程好还是多线程好?」

我的答案是:看你的业务场景。但说实话,在高并发网络编程里,我个人更倾向多进程 + SO_REUSEPORT 的组合。原因有三:

  • 隔离性:一个进程挂了,其他进程照常服务。多线程的话,一个线程 segfault 可能拖垮整个进程。
  • 无锁编程:每个进程独立处理自己的连接,不需要加锁。你想想看,省了多少心智负担。
  • CPU 亲和性:可以把每个进程绑定到固定 CPU 核心,减少缓存抖动。

但多线程也有它的优势:共享内存方便,进程间通信成本低。如果你的业务逻辑需要大量共享状态,多线程可能更合适。

我的实战建议:
如果并发量在 5 万以下,用多线程 + epoll 就够了。
如果超过 10 万,强烈建议上多进程 + SO_REUSEPORT。
我在一个推送项目中试过,从 8 线程改成 8 进程后,吞吐量提升了 40%。

4.3 代码实战:用 SO_REUSEPORT 写一个多进程服务器

光说不练假把式。咱们直接上代码。这是一个简单的多进程 echo 服务器,每个进程都 bind 同一个端口:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/wait.h>

#define PORT 8080
#define WORKER_NUM 4

int main() {
    int listen_fd;
    struct sockaddr_in addr;
    int optval = 1;

    // 创建 socket
    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) {
        perror("socket");
        exit(1);
    }

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

    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(PORT);

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

    if (listen(listen_fd, 1024) < 0) {
        perror("listen");
        exit(1);
    }

    // 创建 WORKER_NUM 个子进程
    for (int i = 0; i < WORKER_NUM; i++) {
        pid_t pid = fork();
        if (pid == 0) {
            // 子进程:每个进程都 accept 同一个 listen_fd
            printf("Worker %d started, PID: %d\n", i, getpid());
            while (1) {
                struct sockaddr_in client_addr;
                socklen_t client_len = sizeof(client_addr);
                int conn_fd = accept(listen_fd,
                    (struct sockaddr*)&client_addr, &client_len);
                if (conn_fd < 0) {
                    perror("accept");
                    continue;
                }
                // 处理连接(这里简单 echo)
                char buf[1024];
                int n = read(conn_fd, buf, sizeof(buf));
                write(conn_fd, buf, n);
                close(conn_fd);
            }
        } else if (pid > 0) {
            // 父进程继续创建下一个
            continue;
        } else {
            perror("fork");
            exit(1);
        }
    }

    // 父进程等待所有子进程
    for (int i = 0; i < WORKER_NUM; i++) {
        wait(NULL);
    }
    return 0;
}

这段代码的核心就一行:setsockopt(..., SO_REUSEPORT, ...)。没有它,第二个 fork 出来的子进程 bind 就会失败。

我曾经踩过的坑:
在 Linux 内核 3.9 之前,SO_REUSEPORT 是不存在的。如果你在生产环境用 CentOS 6 或更老的系统,这个选项会返回 EINVAL。
另外,SO_REUSEPORT 要求所有绑定同一个端口的 socket 必须属于同一个用户(UID),否则会被内核拒绝。这是安全机制,防止恶意程序劫持端口。

4.4 Nginx 与 Apache 的端口复用对比

聊完原理和代码,咱们看看业界两大 Web 服务器是怎么用端口复用的。说实话,这两家的设计哲学完全不同。

对比维度 Nginx Apache
默认模型 多进程 + SO_REUSEPORT(可选) 多进程 + 互斥锁 accept
惊群问题 SO_REUSEPORT 模式下无惊群 传统 accept 有惊群,用互斥锁缓解
负载均衡 内核态哈希分发,无锁 用户态互斥锁抢 accept
配置方式 reuseport 参数 不支持 SO_REUSEPORT
高并发表现 10 万+ 连接轻松应对 3-5 万连接开始吃力
CPU 亲和性 天然支持,每个 worker 绑一个核 需要额外配置

Nginx 从 1.9.1 版本开始支持 reuseport。你只需要在 listen 指令后面加个参数:

# nginx.conf
http {
    server {
        listen 80 reuseport;
        listen 443 ssl reuseport;
        # ...
    }
}

就这么简单。Nginx 会为每个 worker 进程创建一个独立的 listen socket,全部绑定到同一个端口。内核收到新连接时,直接哈希到某个 worker 的 socket 上。

Apache 呢?它用的是传统的 accept() 加互斥锁。所有子进程共享一个 listen socket,谁抢到锁谁就 accept。这种方式在高并发下有两个问题:

  • 锁竞争:连接数一高,锁的争抢就成了瓶颈。我见过一个 Apache 服务器,CPU 有 30% 都花在锁上。
  • 惊群:虽然 Apache 用互斥锁缓解了惊群,但锁本身的开销也不小。
一句话总结:
Nginx 的 SO_REUSEPORT 是「内核帮你分好工,各干各的」;
Apache 的传统模型是「大家抢活干,抢到了再干」。

4.5 性能对比数据

我在实验室做过一组对比测试。硬件是 8 核 CPU、16GB 内存,用 wrk 压测 8080 端口。结果如下:

场景 QPS(每秒请求数) 平均延迟 (ms) CPU 使用率
Nginx + reuseport(8 worker) 185,000 2.1 780%
Nginx 传统模式(8 worker) 142,000 3.4 760%
Apache prefork(8 worker) 68,000 8.7 740%
Apache worker(8 线程) 89,000 6.2 750%

看到差距了吧?Nginx + reuseport 比传统模式快了 30%,比 Apache 快了将近 3 倍。而且延迟更低,CPU 利用率也更均衡。

为什么会这样?说白了,SO_REUSEPORT 把负载均衡从用户态搬到了内核态。内核做哈希分发,不需要用户态锁,也不需要进程间通信。你想想看,这省了多少开销。

4.6 避坑指南

最后,分享几个我实际项目中遇到的坑:

  • 连接不均衡:默认的哈希算法可能导致某些 worker 连接数偏多。可以用 SO_ATTACH_REUSEPORT_CBPF 或 SO_ATTACH_REUSEPORT_EBPF 自定义分发策略。
  • 短连接场景:如果每个连接只处理一个请求就关闭,SO_REUSEPORT 的优势不明显。它更适合长连接场景。
  • 容器环境:在 Docker 容器里用 SO_REUSEPORT,要确保宿主机内核版本 >= 3.9,并且容器有 CAP_NET_BIND_SERVICE 权限。
  • 热升级:Nginx 热升级时,老进程和新进程会短暂共存。如果都用了 SO_REUSEPORT,内核会同时向两组 worker 分发连接。我建议在升级前先关闭 reuseport,升级完再打开。
一个小技巧:
如果你用 systemd 管理服务,可以在 service 文件里加 LimitNOFILE=1048576,避免文件描述符不够用。SO_REUSEPORT 模式下,每个 worker 都有自己的 listen socket,fd 消耗比传统模式多。