3、SO_REUSEPORT 详解:作用、内核负载均衡与 SO_REUSEADDR 的区别

聊到端口复用,SO_REUSEPORT 绝对是个绕不开的话题。我记得最早接触这个选项是在做一款高并发网关的时候,当时线上压力一上来,单进程模型根本扛不住。后来我尝试引入多进程监听同一个端口,结果发现 SO_REUSEADDR 根本搞不定——新进程启动时端口冲突,直接报错退出了。嗯,那时候我才真正开始研究 SO_REUSEPORT。

SO_REUSEPORT 到底是什么?

说白了,SO_REUSEPORT 允许 多个套接字绑定到完全相同的 IP 地址和端口。而且这些套接字可以属于不同的进程,甚至属于不同的用户。

你想想看,这在以前是不可能的。传统模式下,端口一旦被一个套接字绑定,其他套接字再绑就会收到 EADDRINUSE 错误。但 SO_REUSEPORT 打破了这堵墙。

核心特性:

  • 多个进程/线程可以各自创建独立的监听套接字
  • 每个套接字绑定同一个 IP:Port
  • 内核负责将新连接分发给其中一个套接字
  • 每个进程独立 accept,互不干扰

内核负载均衡:这才是精髓

很多人以为 SO_REUSEPORT 只是解决了端口冲突问题。其实它的杀手锏是 内核级别的连接分发。

我在项目中遇到过这样的情况:用 SO_REUSEADDR 配合多线程,所有线程共享一个监听套接字,然后大家一起竞争 accept。结果呢?惊群效应严重,CPU 浪费在锁竞争上,吞吐量上不去。

而 SO_REUSEPORT 的做法完全不同。每个 worker 进程有自己的监听套接字,内核收到新连接时,会通过哈希算法选择一个套接字,直接把连接递过去。这个过程不需要用户态参与,没有锁,没有惊群。

内核分发策略(Linux 4.5+):

  • 基于源 IP、源端口、目的 IP、目的端口计算哈希
  • 同一个客户端连接始终落到同一个 worker
  • 支持 EPOLLEXCLUSIVE 进一步减少唤醒
  • 连接数在 worker 之间基本均匀分布
SO_REUSEPORT 内核负载均衡流程 客户端 A 客户端 B 客户端 C 内核分发器 哈希(四元组) 均匀分发 Worker 1 fd=100 Worker 2 fd=101 Worker 3 fd=102 说明:每个 Worker 拥有独立的监听套接字 内核通过哈希将新连接直接分发到对应 Worker,无锁无竞争

SO_REUSEPORT 与 SO_REUSEADDR 的区别

这两个名字长得像,但做的事情天差地别。我见过不少新手把两者搞混,结果线上出了诡异的问题。咱们直接上对比表:

对比维度 SO_REUSEADDR SO_REUSEPORT
核心作用 允许重用处于 TIME_WAIT 状态的端口 允许多个套接字绑定同一 IP:Port
多进程绑定 不支持,后绑定的会失败 支持,多个进程可同时绑定
惊群效应 严重,所有线程竞争 accept 无,内核直接分发
负载均衡 无,依赖用户态调度 内核级哈希分发
适用场景 服务重启、端口快速回收 多进程/多线程高并发服务
Linux 支持 全版本 3.9+(完善于 4.5+)

我曾经踩过的坑:

有一次我在一个旧内核(2.6.32)上编译了支持 SO_REUSEPORT 的程序,结果 setsockopt 返回成功,但实际根本没生效。后来查文档才发现,这个选项在 3.9 之前只是空壳,内核根本不处理。所以,生产环境一定要确认内核版本。

代码示例:如何正确使用

我习惯在创建监听套接字后,bind 之前设置这个选项。顺序很重要——先设选项,再 bind。

int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
    perror("socket");
    exit(1);
}

int optval = 1;
// 先设置 SO_REUSEPORT
if (setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval)) < 0) {
    perror("setsockopt SO_REUSEPORT");
    exit(1);
}

// 再设置 SO_REUSEADDR(两者不冲突,可以一起用)
if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) < 0) {
    perror("setsockopt SO_REUSEADDR");
    exit(1);
}

struct sockaddr_in addr;
memset(&addr, 0, sizeof(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);
}

if (listen(fd, SOMAXCONN) < 0) {
    perror("listen");
    exit(1);
}

我的建议:

在实际项目中,我通常把 SO_REUSEPORT 和 SO_REUSEADDR 一起设置。前者解决多进程监听问题,后者解决端口快速重用问题。两者各司其职,互不干扰。

性能表现:实测数据

我在一台 16 核的机器上做过对比测试。服务是简单的 echo 服务器,压测工具用 wrk。结果如下:

模型 QPS CPU 使用率 惊群次数
单进程 + SO_REUSEADDR 12 万 100%(单核满载) 0
多线程 + SO_REUSEADDR 45 万 85%(锁竞争严重) 高
多进程 + SO_REUSEPORT 128 万 95%(接近线性扩展) 几乎为 0

你看,SO_REUSEPORT 的吞吐量几乎是多线程模型的 3 倍。原因很简单——没有锁竞争,没有惊群,每个 CPU 核心跑自己的 accept 循环。

注意事项

  • 防火墙规则:使用 SO_REUSEPORT 时,iptables/nftables 规则需要匹配所有监听进程,否则部分连接可能被丢弃。
  • 连接迁移:如果某个 worker 进程崩溃,它持有的连接不会自动迁移到其他 worker。需要应用层做心跳检测和重连。
  • 非均匀分布:虽然内核尽力均匀分发,但在极端短连接场景下,某些 worker 可能暂时偏多。我一般配合 SO_ATTACH_REUSEPORT_CBPF 做自定义分发策略。
  • 容器环境:Docker 默认不开启 SO_REUSEPORT 的跨容器共享。需要配置 net.ipv4.tcp_tw_reuse 等参数配合。

嗯,关于 SO_REUSEPORT 的核心内容就这些。这个选项是我在高并发场景下的首选方案,简单、高效、内核帮你搞定负载均衡。下次遇到多进程监听同一个端口的需求,你应该知道该用哪个选项了。