01 事件驱动模型:epoll 与 Master-Worker 进程架构
摘要
Nginx 能以数个进程处理数十万并发连接,其根本原因不在于硬件性能,而在于它彻底摒弃了”一连接一线程”的传统并发模型,转而采用事件驱动 + 非阻塞 I/O 的架构。本文从 Linux I/O 多路复用的演进(select → poll → epoll)讲起,深入剖析 epoll 的边缘触发与水平触发模式、epoll 红黑树与就绪链表的内核数据结构,再到 Nginx Master-Worker 进程模型的设计哲学——为什么要多进程而非多线程、Worker 如何绑定 CPU 核心、Master 进程如何通过信号管理 Worker 的优雅重启。理解这一切,是后续所有 Nginx 调优与故障排查的认知基础。
第 1 章 并发模型的演进:为什么”一连接一线程”走不远
1.1 传统 Web 服务器的并发困境
在 Nginx 诞生之前(2004 年),Apache 是 Web 服务器的绝对统治者。Apache 的默认工作模式是 Prefork MPM——每个 HTTP 请求由一个独立的进程(或线程)处理,进程与请求一一对应。
这个模型在互联网流量较小的年代运转良好,但随着并发量攀升,它的问题暴露无遗:
问题一:内存消耗线性增长
每个 Apache Prefork 进程需要加载完整的 HTTP 处理代码、mod_php 模块、SSL 上下文等,一个进程的内存占用轻易超过 10MB。当并发连接达到 1 万时,仅进程内存就需要 100GB——这在 2004 年是天文数字。
问题二:操作系统调度开销
Linux 内核的进程调度器(CFS 调度器)在进程数量很少时开销可以忽略。但当进程数量增长到数千时,仅上下文切换(Context Switch) 的代价就变得不可忽视:CPU 需要保存当前进程的寄存器状态、刷新 TLB(Translation Lookaside Buffer)、加载新进程的内存映射。在一台 4 核服务器上同时运行 10000 个进程,大部分 CPU 时间都消耗在调度本身,而非真正的业务处理。
问题三:I/O 等待中的资源浪费
HTTP 请求的处理过程中,大量时间花在 I/O 等待上——等待客户端发送请求数据(网络 I/O)、等待磁盘读取静态文件(磁盘 I/O)、等待后端数据库响应(网络 I/O)。在这些等待期间,进程/线程完全阻塞,CPU 资源被白白浪费。
这三个问题在 2002 年催生了著名的 “C10K 问题”(如何用一台服务器同时处理 10000 个并发连接),它成为推动新一代 Web 服务器设计范式变革的导火索。
1.2 C10K 问题的本质
理解 C10K 问题的关键是区分两个概念:并发连接数和活跃连接数。
在互联网场景中,大多数 HTTP 连接在大多数时刻是空闲的——客户端已经建立了 TCP 连接,但正在等待用户操作、或者处于 HTTP Keep-Alive 的空闲期、或者正在接收前一个响应的数据。真正在某一毫秒内有数据需要读写的连接,可能只占全部连接的 1%。
这意味着:用 10000 个进程处理 10000 个并发连接,其中 9900 个进程都在无所事事地阻塞等待——这是对操作系统资源的极大浪费。
正确的解决思路是:用少量的进程/线程,同时监控大量的连接,哪个连接有数据了就处理哪个。这就是 I/O 多路复用(I/O Multiplexing)的核心思想。
第 2 章 I/O 多路复用的三代演进
2.1 第一代:select——功能完整,但有硬性上限
select 是 POSIX 标准定义的 I/O 多路复用接口,在 20 世纪 80 年代随 BSD Unix 诞生,Linux 从最早期的版本就支持:
// select 系统调用接口
int select(int nfds, // 监听的最大 fd 编号 + 1
fd_set *readfds, // 监听可读事件的 fd 集合
fd_set *writefds, // 监听可写事件的 fd 集合
fd_set *exceptfds, // 监听异常事件的 fd 集合
struct timeval *timeout); // 超时时间(NULL = 永久阻塞)fd_set 在内核中是一个位图(Bitmap),每个 bit 代表一个文件描述符(fd)是否在监听集合中。select 的工作流程:
- 应用程序将感兴趣的 fd 设置到
readfds/writefds位图中 - 调用
select,将位图从用户态复制到内核态 - 内核遍历位图中的所有 fd,检查是否有就绪事件(O(n) 遍历)
- 有就绪事件时,将就绪的位图从内核态复制回用户态
- 应用程序再次遍历位图,找出哪些 fd 就绪(O(n) 遍历)
select 的致命缺陷有两个:
缺陷一:fd 数量硬上限
fd_set 位图的大小在编译时由宏 FD_SETSIZE 决定,通常为 1024。这意味着单个 select 调用最多监听 1024 个 fd。这个限制来自于位图的固定大小设计,虽然可以修改 FD_SETSIZE 重新编译内核,但实际部署中极少有人这样做。
缺陷二:O(n) 遍历的双重代价
每次调用 select,都要将整个 fd 集合从用户态复制到内核态(一次内存拷贝),内核遍历所有 fd(O(n)),再将结果复制回用户态(一次内存拷贝)。应用程序收到结果后,还需要再次遍历找出就绪的 fd。当监听的 fd 数量为 N 时,单次 select 调用的时间复杂度是 O(N),而在 99% 的时刻只有极少数 fd 就绪——做了大量无效的遍历。
2.2 第二代:poll——突破数量限制,但问题本质未变
poll 在 1997 年进入 Linux,它用链表替代了位图,突破了 1024 的 fd 数量限制:
struct pollfd {
int fd; // 文件描述符
short events; // 感兴趣的事件(POLLIN/POLLOUT/POLLERR)
short revents; // 返回的就绪事件(内核填充)
};
int poll(struct pollfd *fds, // pollfd 数组
nfds_t nfds, // 数组长度
int timeout); // 超时(毫秒)poll 用 pollfd 结构体数组替代位图,理论上可以监听任意数量的 fd(受系统 fd 总数限制)。但 poll 只解决了 fd 数量的问题,没有解决 O(n) 遍历 和 用户态/内核态数据复制 的问题——每次调用 poll 仍然要将整个 pollfd 数组复制到内核,内核仍然要遍历所有 fd 检查就绪状态。
在监听 10000 个连接时,每次 poll 调用要复制 10000 个 pollfd 结构体,内核要遍历 10000 个 fd,即使只有 10 个 fd 有数据——这 9990 次遍历都是无效的浪费。
2.3 第三代:epoll——O(1) 就绪通知,Linux 高并发的基石
epoll 在 2002 年随 Linux 2.5.44 内核引入,彻底解决了 select/poll 的根本问题。它的设计思路转变是革命性的:
select/poll 的思路:每次调用时,应用程序把所有感兴趣的 fd 告诉内核,内核检查一遍,告诉应用程序谁就绪了。
epoll 的思路:应用程序事先把感兴趣的 fd 注册到内核中,内核维护一个长期有效的兴趣集合。当某个 fd 就绪时,内核主动把它放入就绪链表。应用程序调用 epoll_wait 时,只需从就绪链表取结果——时间复杂度与监听的 fd 总数无关,只与就绪 fd 的数量有关。
epoll 的三个核心接口:
// 1. 创建 epoll 实例,在内核中分配相关数据结构
// 返回一个 epfd(epoll file descriptor)
int epoll_create1(int flags); // flags = 0 或 EPOLL_CLOEXEC
// 2. 对 epoll 实例进行操作(增/改/删 监听的 fd)
// op: EPOLL_CTL_ADD / EPOLL_CTL_MOD / EPOLL_CTL_DEL
// event: 感兴趣的事件类型
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 3. 等待就绪事件
// events: 用于接收就绪事件的数组(只需足够大,不需要包含全部 fd)
// maxevents: events 数组的大小
// timeout: 超时(毫秒),-1 = 永久等待
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);epoll 的内核数据结构包含两个关键部分:
红黑树(rb-tree):存储所有通过 epoll_ctl(EPOLL_CTL_ADD) 注册的 fd。红黑树保证了增删查的时间复杂度为 O(log N)。当应用程序调用 epoll_ctl 注册一个新 fd 时,内核将其插入红黑树;删除时从红黑树中移除。
就绪链表(ready list):存储当前已就绪(有事件发生)的 fd。当 fd 上有事件发生时(如网卡驱动收到数据包,调用回调函数),内核将该 fd 对应的节点加入就绪链表。epoll_wait 调用时,内核直接将就绪链表中的节点返回给用户态——时间复杂度 O(1) 相对于就绪 fd 数量,与注册 fd 总数无关。
核心概念:epoll 为什么快
select/poll每次调用都要重新告诉内核”我关注哪些 fd”(O(n) 复制 + O(n) 遍历)。epoll 的epoll_ctl是一次性注册,内核长期维护兴趣集合;epoll_wait只拿就绪的 fd,没有任何遍历。两者的本质差异在于:前者把”找出就绪 fd”的工作放在每次epoll_wait调用时,后者在 fd 就绪时由内核主动通知(回调驱动),epoll_wait只是取结果。
2.4 epoll 的触发模式:LT vs ET
epoll 支持两种触发模式,这是 Nginx 工程师必须理解的关键细节:
水平触发(Level Triggered,LT)——默认模式
只要 fd 的内核缓冲区中还有数据未被读完,每次调用 epoll_wait 都会将该 fd 报告为就绪。形象地说:只要”水位”(缓冲区中的数据量)高于零,就持续触发。
场景:服务器收到 1000 字节,应用程序每次只读 200 字节
LT 模式下:
第 1 次 epoll_wait → 返回 fd 就绪(缓冲区有 1000 字节)
读 200 字节 → 缓冲区剩 800 字节
第 2 次 epoll_wait → 仍然返回 fd 就绪(缓冲区有 800 字节)
读 200 字节 → 缓冲区剩 600 字节
... 直到缓冲区清空
LT 的优势:编程简单,不会因为读不完而丢失事件;缺点:如果应用程序忘记读数据,下次 epoll_wait 仍会返回该 fd,可能导致”忙等”(Busy-wait)。
边缘触发(Edge Triggered,ET)——高性能模式(需要设置 EPOLLET 标志)
只在 fd 的状态发生变化时触发一次——从”无数据”变为”有数据”时触发,之后不再重复报告(无论缓冲区是否还有数据)。形象地说:只在”水位”上涨的那一刻触发。
场景:服务器收到 1000 字节,应用程序每次只读 200 字节
ET 模式下:
第 1 次 epoll_wait → 返回 fd 就绪(状态从无数据变为有数据)
读 200 字节 → 缓冲区剩 800 字节
第 2 次 epoll_wait → !! 不再返回该 fd(状态没有变化)!!
→ 剩余 800 字节永远无法被读取 → 连接处于错误状态
这意味着:使用 ET 模式时,应用程序必须在每次触发后循环读取,直到收到 EAGAIN(缓冲区已空),否则剩余数据会被”遗忘”。
// ET 模式的正确读取姿势:循环读直到 EAGAIN
while (1) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n == -1) {
if (errno == EAGAIN) {
// 缓冲区已空,正常退出循环
break;
}
// 真正的错误
handle_error();
break;
}
if (n == 0) {
// 连接已关闭
close(fd);
break;
}
// 处理读到的数据
process(buf, n);
}ET 的优势:内核只需要在状态变化时通知一次,减少了内核和应用层的交互次数,理论上性能更高;缺点:必须配合非阻塞 fd 使用,否则最后一次 read 会永久阻塞(缓冲区空了但没有 EAGAIN)。
Nginx 的选择:Nginx 默认使用 ET(边缘触发)模式,并将所有 socket 设置为 非阻塞(O_NONBLOCK),通过事件循环正确处理 EAGAIN。
第 3 章 Nginx 的 Master-Worker 进程模型
3.1 为什么是多进程而非多线程
Nginx 选择多进程模型(一个 Master + 多个 Worker),而非多线程模型,这个设计决策在 2004 年颇具争议。理解这个决策,需要理解进程与线程的本质差异在 Nginx 场景中的意义。
线程模型的吸引力:线程共享进程的地址空间,线程间通信(共享内存变量)比进程间通信(管道、信号、共享内存 IPC)更简单。
线程模型的致命问题:Nginx 大量使用了非线程安全的第三方库(如 OpenSSL 的早期版本、某些 DNS 解析库)。如果在多线程环境下使用这些库,需要加锁保护,引入了锁竞争的性能开销,以及死锁的风险。更严重的是:任何一个线程崩溃(如第三方模块的 bug),会导致整个进程崩溃,影响所有并发连接。
多进程模型的优势:
- 隔离性:每个 Worker 进程独立运行,一个 Worker 崩溃不影响其他 Worker(Master 会自动重启它)
- 无锁:每个 Worker 独立处理连接,不需要在连接处理的关键路径上加锁(当然某些全局资源如共享内存仍需要锁,但这是少数情况)
- 非线程安全库:可以安全使用任何库,不需要考虑多线程重入问题
多进程模型的代价:进程切换比线程切换代价更大;进程间数据共享需要通过 IPC(进程间通信) 机制(Nginx 使用共享内存)。但 Nginx 的设计使 Worker 进程极少需要切换(事件驱动减少了阻塞),进程切换代价的影响微乎其微。
3.2 Nginx 进程架构全貌
graph TD subgraph OS["操作系统"] subgraph NGINX["Nginx 进程树"] MASTER["Master 进程</br>(PID 1234)</br>监听信号:HUP/USR1/USR2/QUIT/TERM"] W1["Worker 进程 1</br>(PID 1235)</br>绑定 CPU Core 0"] W2["Worker 进程 2</br>(PID 1236)</br>绑定 CPU Core 1"] W3["Worker 进程 3</br>(PID 1237)</br>绑定 CPU Core 2"] W4["Worker 进程 4</br>(PID 1238)</br>绑定 CPU Core 3"] CACHE["Cache Manager 进程</br>(可选,管理 proxy_cache)"] LOADER["Cache Loader 进程</br>(可选,启动时加载缓存索引)"] end SOCKET["监听 Socket</br>(80/443 端口)"] SHM["共享内存</br>(连接计数、限流计数器等)"] end MASTER --> W1 MASTER --> W2 MASTER --> W3 MASTER --> W4 MASTER --> CACHE MASTER --> LOADER SOCKET -.->|"accept_mutex 竞争"| W1 SOCKET -.->|"accept_mutex 竞争"| W2 SOCKET -.->|"accept_mutex 竞争"| W3 SOCKET -.->|"accept_mutex 竞争"| W4 W1 <-->|"读写"| SHM W2 <-->|"读写"| SHM classDef master fill:#ff79c6,stroke:#bd93f9,color:#282a36 classDef worker fill:#6272a4,stroke:#bd93f9,color:#f8f8f2 classDef infra fill:#50fa7b,stroke:#bd93f9,color:#282a36 class MASTER master class W1,W2,W3,W4,CACHE,LOADER worker class SOCKET,SHM infra
3.3 Master 进程的职责
Master 进程是整个 Nginx 的管理者,它不处理任何客户端请求,只做以下事情:
职责一:读取并验证配置文件
Nginx 启动时,Master 读取 nginx.conf,验证配置语法正确性,初始化各模块。这个过程完全在 Master 进程中完成,Worker 进程启动时继承已初始化好的状态。
职责二:创建和管理 Worker 进程
Master 使用 fork() 系统调用创建 Worker 进程。每个 Worker 进程是 Master 的完整拷贝(fork 时的 Copy-on-Write 语义),继承了监听 socket 的文件描述符(这是 Worker 能够 accept() 新连接的基础)。
Master 持续监控所有 Worker 进程的状态(通过 waitpid() 和信号)。如果某个 Worker 意外退出(崩溃或被 Kill),Master 立即 fork() 一个新的 Worker 替代它。
职责三:响应管理信号,实现优雅控制
Master 通过监听 Unix 信号响应管理操作:
| 信号 | 效果 | 使用场景 |
|---|---|---|
SIGHUP | 重新加载配置文件(优雅重载) | nginx -s reload 内部发送 |
SIGUSR1 | 重新打开日志文件 | 日志切割(logrotate)后通知 Nginx |
SIGUSR2 | 热升级 Nginx 可执行文件(第一步) | 升级 Nginx 二进制而不中断服务 |
SIGWINCH | 优雅关闭 Worker 进程(热升级第二步) | 配合 SIGUSR2 完成热升级 |
SIGQUIT | 优雅停止(等待所有请求完成后关闭) | nginx -s quit |
SIGTERM | 立即停止(强制) | nginx -s stop |
最重要的是 SIGHUP(配置热重载)的执行流程,这是 Nginx 零停机更新配置的关键机制,值得详细讲解:
nginx -s reload 的完整执行流程:
1. nginx 工具进程读取 pid 文件,找到 Master 的 PID
2. 向 Master 发送 SIGHUP 信号
3. Master 收到 SIGHUP:
a. 重新读取 nginx.conf(新配置)
b. 验证新配置(如果有语法错误,打印错误日志,不继续)
c. 用新配置 fork() 一批新的 Worker 进程
(新 Worker 使用新配置,开始接受新连接)
d. 向所有旧 Worker 进程发送 SIGQUIT 信号
(旧 Worker 停止接受新连接,等待当前处理中的请求完成后退出)
4. 旧 Worker 处理完所有进行中的请求后,自然退出
5. Master 通过 waitpid() 收到旧 Worker 退出信号,完成清理
结果:整个过程中,服务没有中断——旧 Worker 继续服务旧连接,
新 Worker 处理新连接,两者同时存在的时间窗口极短。
生产避坑
nginx -s reload不会在配置有语法错误时回滚——它只是不应用新配置,仍然使用旧配置继续运行。但如果nginx.conf中include了某个文件,而这个文件的内容不是 Nginx 配置语法,而是空文件或乱码,reload会打印[emerg]错误但不中断当前服务。真正危险的是:在nginx.conf被写入了合法语法但业务逻辑错误的配置时(如错误的upstream地址),reload会成功,新 Worker 会用错误配置处理新连接,此时生产告警才会触发。建议在每次reload前先用nginx -t验证配置,即使nginx -t通过,也应该在低流量时段执行reload,并监控错误率。
3.4 Worker 进程的事件循环
Worker 进程是实际处理客户端请求的执行体。每个 Worker 进程运行一个单线程事件循环:
Worker 进程的伪代码(简化):
// 初始化 epoll 实例
epfd = epoll_create1(EPOLL_CLOEXEC)
// 将监听 socket 注册到 epoll(等待新连接)
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, {EPOLLIN | EPOLLET})
while (true) {
// 1. 等待事件(阻塞,直到有事件就绪或超时)
n_ready = epoll_wait(epfd, events, MAX_EVENTS, timeout_ms)
for (i = 0; i < n_ready; i++) {
fd = events[i].data.fd
if (fd == listen_fd) {
// 2a. 新连接到达
client_fd = accept(listen_fd, ...)
set_nonblocking(client_fd)
// 将客户端 fd 注册到 epoll
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, {EPOLLIN | EPOLLET})
} else if (events[i].events & EPOLLIN) {
// 2b. 已有连接上有数据可读
read_request(fd) // 读取 HTTP 请求头/体
} else if (events[i].events & EPOLLOUT) {
// 2c. 已有连接可写(发送响应)
write_response(fd)
} else if (events[i].events & (EPOLLHUP | EPOLLERR)) {
// 2d. 连接关闭或错误
close(fd)
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL)
}
}
// 3. 处理定时器(检查超时连接)
process_timers()
// 4. 处理延迟任务(如 post action)
process_posted_events()
}
整个事件循环是单线程运行的。这意味着:
- 没有线程切换开销
- 没有数据竞争问题(单线程天然无竞争)
- 但也意味着:任何阻塞操作都会冻结整个 Worker
这就是为什么 Nginx 对所有网络 I/O 使用非阻塞模式,对磁盘 I/O 使用特殊的线程池(aio threads 指令),以及为什么不能在 Nginx 配置中随意调用会阻塞的操作(如某些第三方模块的同步 DNS 解析)。
3.5 Worker 进程数量与 CPU 亲和性
Worker 进程数量(worker_processes)
最优配置是 worker_processes auto——Nginx 会自动检测 CPU 核心数,为每个物理核心创建一个 Worker。这是因为 Nginx Worker 是 CPU 密集型的(TLS 握手、响应压缩、正则匹配都消耗 CPU),Worker 数量超过 CPU 核数会引入不必要的上下文切换开销。
worker_processes auto; # 推荐:等于 CPU 核数
# 等价于(4 核 CPU):
worker_processes 4;如果 Nginx 主要做反向代理(CPU 轻负载),且后端响应时间较长(Worker 会因等待后端而有短暂的空闲),可以适当设置为 CPU 核数的 2 倍,以提高 CPU 利用率。但这需要结合实际监控数据决定,而非盲目增大。
CPU 亲和性(worker_cpu_affinity)
# 将 Worker 进程绑定到特定 CPU 核心
# 4 核 CPU,4 个 Worker:
worker_cpu_affinity 0001 0010 0100 1000;
# 等价的简化写法(自 Nginx 1.9.0 起支持):
worker_cpu_affinity auto;将 Worker 绑定到固定 CPU 核心的好处是:提高 CPU 缓存命中率。操作系统调度器在没有绑定的情况下,可能将同一个 Worker 进程在不同 CPU 核心之间迁移,导致 L1/L2 缓存失效(因为每个核心有自己的缓存)。绑定后,Worker 始终在同一核心运行,热数据(连接状态、HTTP 解析状态机、频繁访问的内存结构)始终在该核心的缓存中,减少缓存 Miss。
3.6 accept_mutex:多 Worker 如何公平接受新连接
当多个 Worker 进程同时监听同一个监听 socket 时(通过 fork() 继承),新连接到来时所有 Worker 都会被唤醒(因为监听 socket 就绪),但只有一个 Worker 能成功 accept(),其余 Worker 白白被唤醒后立即再次休眠——这就是著名的 “惊群问题”(Thundering Herd)。
Nginx 通过两个机制解决这个问题:
方案一:accept_mutex(互斥锁,传统方案,Nginx 默认开启)
accept_mutex on; # 默认 on(Nginx < 1.11.3 的默认行为)
accept_mutex_delay 500ms; # 获取锁失败后等待时间accept_mutex 是一个基于共享内存的互斥锁,在任一时刻只有一个 Worker 进程持有 accept 锁,只有持锁的 Worker 才会将监听 socket 加入到 epoll 的监听集合中——其他 Worker 的 epoll 不监听监听 socket,自然不会被新连接唤醒。
accept_mutex 的工作流程:
1. Worker A 尝试获取 accept_mutex → 成功
2. Worker A 将 listen_fd 加入 epoll
3. 新连接到来 → 只有 Worker A 的 epoll 被唤醒
4. Worker A accept() 新连接
5. Worker A 完成 accept 后,释放 accept_mutex
6. Worker B 尝试获取 accept_mutex → 成功(进入下一轮)
方案二:SO_REUSEPORT(内核级负载均衡,Linux 3.9+ 支持的现代方案)
listen 80 reuseport; # 开启 SO_REUSEPORT
# 同时建议关闭 accept_mutex(reuseport 模式下 accept_mutex 无必要)
accept_mutex off;SO_REUSEPORT 允许多个 socket 绑定同一端口。每个 Worker 进程创建独立的监听 socket 绑定到 80 端口,内核在接收到新连接时,使用哈希算法(基于四元组:源 IP、源端口、目标 IP、目标端口)将连接分配到某一个监听 socket,只唤醒对应的 Worker。
SO_REUSEPORT 的优势:
- 完全在内核层面解决惊群问题,无需用户态锁
- 内核负载均衡比
accept_mutex轮流接受更均匀 - 在高并发新连接场景下,性能优于
accept_mutex
设计哲学:内核能做的,不交给用户态
SO_REUSEPORT体现了 Nginx 的进化趋势——将原本在用户态用锁解决的问题,下沉到内核层面解决,换取更低的延迟和更高的吞吐量。但SO_REUSEPORT要求 Linux 3.9+(2013 年发布),在早于此版本的内核上仍然需要accept_mutex。
第 4 章 连接处理的完整生命周期
4.1 从 TCP 握手到 HTTP 响应
理解了 epoll 和 Master-Worker 架构之后,我们来串联一次完整的 HTTP 请求处理流程:
1. TCP 三次握手(操作系统内核完成)
Client → SYN → Nginx 监听 socket
Nginx 监听 socket → SYN-ACK → Client
Client → ACK → Nginx
完成后,新连接进入监听 socket 的 accept 队列(内核维护)
2. epoll 通知 Worker(listen_fd 可读)
内核将监听 socket 对应的节点加入 epoll 就绪链表
Worker 的 epoll_wait() 返回,events 中包含 listen_fd 的 EPOLLIN 事件
3. Worker 调用 accept() 取出新连接
accept(listen_fd) → 返回客户端 fd(如 fd=7)
将 fd=7 设置为非阻塞(O_NONBLOCK)
将 fd=7 注册到 epoll(监听 EPOLLIN | EPOLLET)
4. 读取 HTTP 请求(fd=7 可读时)
客户端发送 HTTP Request 数据
内核将 fd=7 加入 epoll 就绪链表
Worker 的 epoll_wait() 返回,读取请求数据
HTTP 请求解析(Method、URI、Headers、Body)
5. 处理请求(第 03 篇详细讲解的 11 个 Phase)
查找匹配的 location
执行 rewrite 规则
检查 access 控制
读取文件 / 转发到 upstream
6. 发送 HTTP 响应
将响应写入内核发送缓冲区
如果缓冲区满 → fd=7 不可写 → 注册 EPOLLOUT 事件等待可写
缓冲区有空间时 → epoll 通知 EPOLLOUT → 继续写
7. 连接关闭 / Keep-Alive
HTTP/1.1 默认 Keep-Alive → fd=7 保持注册在 epoll 中,等待下一个请求
连接超时 / 客户端主动关闭 → EPOLLHUP / EPOLLRDHUP 事件 → 关闭 fd
4.2 定时器:事件驱动下的超时管理
事件驱动模型中,没有”等待 N 秒后做某事”的概念——事件循环是单线程的,不能在某个操作上阻塞 N 秒。Nginx 通过定时器(Timer) 机制处理各种超时:
Nginx 的定时器基于红黑树实现(与 epoll 的内部红黑树不同,这是 Nginx 用户态维护的定时器红黑树):
- 树的节点是定时器事件,按过期时间排序
- 事件循环每次调用
epoll_wait时,将 timeout 设为”最近的定时器还有多久过期” epoll_wait返回后,检查红黑树中是否有已过期的节点,依次处理
这样,连接超时、keepalive_timeout、proxy_read_timeout 等所有超时都通过这个定时器树管理,既不需要额外的线程,也不会阻塞事件循环。
第 5 章 生产配置指南
5.1 Worker 相关关键配置
# nginx.conf 顶层配置
# Worker 进程数量:等于 CPU 核数(CPU 密集型场景)
worker_processes auto;
# CPU 亲和性:自动绑定(Linux 系统推荐开启)
worker_cpu_affinity auto;
# 每个 Worker 的最大并发连接数
# 受系统 ulimit -n 的文件描述符数量限制
worker_connections 65535;
# Worker 每次事件循环可以接受的最大新连接数
# 防止某个 Worker 在一次 epoll_wait 中接受太多连接,导致其他 Worker 饥饿
multi_accept on; # on = 一次接受所有排队连接;off = 每次只接受一个
# 使用 epoll 事件驱动(Linux 默认,无需显式配置;其他平台可能是 kqueue/select)
events {
use epoll;
worker_connections 65535;
multi_accept on;
accept_mutex off; # 使用 reuseport 时关闭
}
# 监听时使用 reuseport(Linux 3.9+,高并发新连接场景推荐)
http {
server {
listen 80 reuseport;
listen 443 ssl reuseport;
}
}5.2 Worker 进程限制
# Worker 进程可打开的最大文件描述符数
# 必须 >= worker_connections * 2(每个连接需要两个 fd:客户端 fd + upstream fd)
worker_rlimit_nofile 131070; # 通常设为 worker_connections 的 2 倍
# Worker 进程可以使用的最大临时文件大小(影响缓存和临时缓冲区)
worker_rlimit_core unlimited; # 允许 core dump(生产环境调试时有用)生产避坑
worker_rlimit_nofile设置的是每个 Worker 进程的 fd 上限,它不能超过系统级别的/proc/sys/fs/file-max(系统全局 fd 总数限制)。常见的错误是只修改了worker_rlimit_nofile,但忘记同步修改/etc/security/limits.conf中 nginx 用户的nofile限制,导致实际生效的 fd 上限仍然是系统默认的 1024 或 65536,Nginx 在高并发时报too many open files错误。完整的配置链路:/etc/security/limits.conf→nginx.conf worker_rlimit_nofile→worker_connections,三者必须协调一致。
小结
本文从 C10K 问题的本质出发,梳理了 I/O 多路复用的三代演进,深入剖析了 epoll 的红黑树+就绪链表数据结构以及 LT/ET 两种触发模式的本质差异,并详细讲解了 Nginx Master-Worker 进程模型的设计动机:
- 为什么选多进程而非多线程:隔离性 + 非线程安全库的兼容性 + 无锁关键路径
- Master 进程的信号管理:SIGHUP 实现的零停机配置热重载机制
- Worker 事件循环:单线程 + epoll ET 模式 + 非阻塞 fd 的组合是高并发的基础
- 惊群问题的两种解法:传统的 accept_mutex 锁 vs 现代的 SO_REUSEPORT 内核分发
- 定时器管理:用户态红黑树实现非阻塞的超时管理
第 02 篇进入 Nginx 的配置体系:nginx.conf 的 Block 嵌套结构、指令的继承与覆盖规则、变量的延迟求值机制——理解配置体系是读懂任何 Nginx 配置文件的前提。
参考资料
思考题
- Nginx 的 Worker 进程使用 epoll 事件循环处理数千并发连接。如果某个请求处理逻辑中有阻塞操作(如访问磁盘上的大文件),Worker 会被阻塞影响该 Worker 上所有其他连接。Nginx 的线程池(
aio threads)如何解决?在什么场景下你需要启用线程池?- Nginx 的优雅重启(
nginx -s reload)中新旧 Worker 同时运行。SO_REUSEPORT如何在多 Worker 之间分配新连接?与传统的accept_mutex相比,SO_REUSEPORT在连接分发的均匀性和延迟方面有什么改善?- Nginx 单个 Worker 能处理数万并发连接——远超 Apache 的 per-connection-thread 模型。但 Nginx 不适合 CPU 密集型任务。在什么场景下 Nginx + 后端应用服务器比纯 Nginx Lua 处理更合适?OpenResty 的
cosocket非阻塞模型能否弥补 Nginx 在复杂业务逻辑处理上的不足?