nginx event loop 通过 accept_mutex 控制监听权归属,确保任意时刻仅一个 worker 注册监听 socket,其余跳过 epoll_wait 唤醒;配合 ngx_accept_disabled 实现负载感知的抢锁调度,并在支持 so_reuseport 的内核中推荐关闭该锁以交由内核分发。

Nginx 的 Event Loop 本身不直接“解决”惊群效应,而是配合进程模型与内核机制,通过控制监听权归属来规避惊群。核心思路不是靠事件循环逻辑去拦截唤醒,而是让同一时刻仅有一个 worker 进程真正监听 accept 事件——其余进程虽在 epoll_wait 上等待,但对监听 socket 不设 interest(即不注册 EPOLLIN),自然不会被唤醒。
Event Loop 中的关键控制点:accept_mutex 与锁竞争
Nginx 的每个 worker 进程运行一个独立的 event loop(基于 epoll/kqueue),其主循环调用 ngx_process_events_and_timers()。在这个循环中,是否将监听 socket 加入本进程的 epoll 实例,取决于能否获取 accept_mutex 共享锁:
-
若获取成功(
ngx_trylock_accept_mutex()返回 OK):- 将所有监听 socket 的 fd 通过
epoll_ctl(EPOLL_CTL_ADD)注册到本 worker 的 epoll 实例; - 后续
epoll_wait()就能收到新连接事件,执行accept(); - 同时设置
ngx_accept_mutex_held = 1,进入“持有锁状态”。
- 将所有监听 socket 的 fd 通过
-
若获取失败:
- 立即将监听 socket 从本 worker 的 epoll 实例中移除(
epoll_ctl(EPOLL_CTL_DEL)); - 即使之前没加过,也执行移除(幂等);
- 此后该 worker 的
epoll_wait()不会因新连接而返回 —— 它不再监听该 socket,也就不会被“惊”。
- 立即将监听 socket 从本 worker 的 epoll 实例中移除(
这个机制确保:任意时刻最多只有一个 worker 的 epoll 实例里包含监听 socket,因此内核触发新连接事件时,只唤醒那个已注册的 worker,其他 worker 因未监听而完全不受影响。
配合负载均衡的动态调节:ngx_accept_disabled
Event Loop 在尝试抢锁前,还会检查全局变量 ngx_accept_disabled:
ngx_accept_disabled = ngx_cycle->connection_n / 8 - ngx_cycle->free_connection_n;
它表示当前 worker 剩余可用连接数是否低于总容量的 1/8。若为正,说明该 worker 已接近满载,则主动跳过抢锁逻辑,避免把新连接压向已繁忙的进程。这既是防惊群,也是粗粒度负载均衡。
为什么不能只靠内核?Event Loop 要额外干预
虽然 Linux 2.6+ 内核已解决 accept() 级惊群(通过 WQ_FLAG_EXCLUSIVE),且 4.5+ 支持 EPOLLEXCLUSIVE,但 Nginx 仍需自己控制,原因有二:
-
跨平台兼容性:BSD/macOS 等系统无
SO_REUSEPORT或EPOLLEXCLUSIVE,需统一逻辑; - 连接数均衡更可控:内核调度是随机或哈希,而 Nginx 可结合剩余连接数、空闲时间等做更精细的 worker 选择。
现代推荐方案:SO_REUSEPORT + accept_mutex off
若运行在 Linux 3.9+,更优解是绕过 Event Loop 的锁竞争,改用内核原生分发:
events {
use epoll;
accept_mutex off; # 关闭用户态锁
}
http {
server {
listen 80 reuseport; # 内核直接分发新连接
}
}
此时每个 worker 自行 bind() + listen() 同一端口(借助 SO_REUSEPORT),内核在三次握手完成时就按 CPU 负载/哈希将连接直接分给某个 worker 的 listening socket —— epoll_wait() 仅响应本 worker 独占的连接,彻底消除惊群。
不复杂但容易忽略:Event Loop 的作用不是“处理惊群”,而是“不让惊群发生”。它把惊群问题,转化成了一个轻量级的、带退避的共享锁竞争问题。











