Nginx-03 进程模型与事件驱动原理
这一篇是整个系列的原理核心,也是面试被问得最深的部分。搞懂了「Nginx 为什么快」,很多配置项存在的意义自然就明白了。
1. master-worker 多进程模型

启动后的进程长这样:
$ ps -ef | grep nginx
root 1234 1 0 10:00 ? 00:00:00 nginx: master process /usr/sbin/nginx
nginx 1235 1234 0 10:00 ? 00:00:03 nginx: worker process
nginx 1236 1234 0 10:00 ? 00:00:03 nginx: worker process
nginx 1237 1234 0 10:00 ? 00:00:00 nginx: cache manager process
nginx 1238 1234 0 10:00 ? 00:00:00 nginx: cache loader process
1.1 各进程的分工
master 进程(root 权限,不处理任何请求):
- 读取和校验配置文件
- 创建、绑定、监听 socket(
socket→bind→listen) - fork worker 进程,并把 listen fd 继承给它们
- 监控 worker 状态,异常退出时自动重新拉起
- 接收信号(
HUP/USR2/QUIT…)并转发给 worker - 平滑升级时管理新旧二进制的交接
worker 进程(低权限,实际干活):
- 从 listen fd 上
accept连接 - 用 epoll 事件循环处理所有连接的读写
- 执行 HTTP 请求处理的完整流程(第 04 篇讲)
- 互相独立、不共享数据(除了共享内存 zone),一个崩了不影响其他
cache manager:定期检查缓存目录,按 max_size 和 inactive 淘汰过期缓存文件。
cache loader:Nginx 启动后运行一次,把磁盘上已有的缓存文件元信息载入共享内存,加载完就退出。
1.2 为什么用多进程而不是多线程
| 维度 | 多进程 | 多线程 |
|---|---|---|
| 隔离性 | 一个 worker 崩溃不影响其他,master 会重启它 | 一个线程崩溃整个进程挂掉 |
| 数据竞争 | 天然隔离,无需加锁 | 共享地址空间,处处要考虑锁 |
| 编程复杂度 | 简单 | 高 |
| 上下文切换 | 略贵一点 | 便宜 |
| 内存占用 | 略高(但有 COW) | 低 |
Nginx 选多进程是用一点内存换来了极高的稳定性和实现简洁性。而且 worker 数量固定(= CPU 核数),进程切换本来就极少发生,那点开销无所谓。
关键在于:worker 内部是单线程的事件循环,完全没有锁。这一点是性能的基石——高并发下锁竞争往往比 I/O 本身更致命。
1.3 端口是怎么共享的
面试常问:「多个 worker 怎么监听同一个 80 端口?」
答案是:只 listen 了一次,是 master 干的。
master:
fd = socket()
bind(fd, 0.0.0.0:80)
listen(fd)
↓ fork()
worker1 继承 fd worker2 继承 fd worker3 继承 fd
fork 时子进程会复制父进程的文件描述符表,所以所有 worker 拿到的是指向同一个内核 socket 对象的 fd。它们各自在这个 fd 上调 accept,由内核决定把新连接给谁。这也直接引出了惊群问题。
2. 事件驱动与 epoll
2.1 从阻塞模型说起
传统的「一连接一线程」模型:
while (1) {
conn = accept(listen_fd); // 阻塞
pthread_create(handle, conn); // 每个连接一个线程
}
// handle 里:read() 阻塞 → 处理 → write() 阻塞
一万个连接就是一万个线程。每个线程默认 8MB 栈(即使实际用不了那么多,虚拟内存也要占),加上内核调度器要在一万个线程间切换——这就是 C10K 问题。
2.2 Nginx 的做法
一个 worker,一个线程,一个 epoll 实例,管所有连接:
// 简化后的 worker 主循环(ngx_worker_process_cycle → ngx_process_events_and_timers)
for (;;) {
timer = ngx_event_find_timer(); // 算出最近的定时器超时时间
events = epoll_wait(epfd, evlist, n, timer); // 唯一会「阻塞」的地方
for (i = 0; i < events; i++) {
ev = evlist[i].data.ptr;
if (evlist[i].events & EPOLLIN) ev->read_handler(ev);
if (evlist[i].events & EPOLLOUT) ev->write_handler(ev);
}
ngx_event_expire_timers(); // 处理超时的连接
ngx_event_process_posted(); // 处理延后队列
}
核心思想:永远不阻塞在单个连接上。所有 socket 设成非阻塞,read 返回 EAGAIN 就立刻返回事件循环去处理别的连接,等 epoll 通知这个 socket 可读了再回来接着读。
一个 HTTP 请求在 Nginx 里被拆成了一串状态机:
连接可读 → 读请求行 → (EAGAIN,让出) → 读请求头 → 匹配 location
→ 连上游 → (等待,让出) → 上游可读 → 读上游响应 → 写给客户端
→ (写缓冲满,EAGAIN,让出) → 客户端可写 → 继续写 → 完成
每一个「让出」的时刻,这个 worker 都在处理别的连接。所以一个 worker 能同时「进行中」几万个请求,但任意瞬间只在做一件事。
2.3 select / poll / epoll 对比
| select | poll | epoll | |
|---|---|---|---|
| fd 数量上限 | 1024(FD_SETSIZE) |
无硬上限 | 无硬上限 |
| 数据结构 | 位图 | 数组 | 内核红黑树 + 就绪链表 |
| 每次调用是否传全量 fd | 是 | 是 | 否,epoll_ctl 增量注册 |
| 内核查找就绪 fd | O(n) 遍历 | O(n) 遍历 | O(1),回调直接挂就绪链表 |
| 返回后用户态查找 | O(n) 遍历所有 fd | O(n) | O(m),m = 就绪数 |
| 用户/内核态拷贝 | 每次全量拷贝 | 每次全量拷贝 | 只拷贝就绪的 |
epoll 快的本质:fd 集合常驻内核(红黑树),注册时给每个 fd 挂一个回调函数;当网卡数据到达、协议栈处理完后,回调把这个 fd 直接塞进就绪链表。epoll_wait 只需要看就绪链表空不空,完全不需要遍历所有 fd。所以它的开销只和「活跃连接数」有关,和「总连接数」无关。
这正好契合互联网场景:十万个长连接里,同一时刻真正在传数据的可能只有几百个。
2.4 LT 与 ET
- 水平触发 LT(Level Triggered):只要 fd 上还有数据没读完,每次
epoll_wait都会返回它。编程简单,一次没读完下次还会通知。 - 边缘触发 ET(Edge Triggered):只在状态发生变化时通知一次。必须循环
read直到返回EAGAIN,否则剩下的数据就再也收不到通知了。
Nginx 默认用 ET。因为它本来就是循环读到 EAGAIN 的写法,ET 能省掉大量重复的事件通知,epoll_wait 返回的事件数更少。
3. 惊群问题
3.1 问题描述
多个 worker 同时 epoll_wait 在同一个 listen fd 上。一个新连接到来时,内核把所有阻塞在这个 fd 上的进程全部唤醒,但只有一个能 accept 成功,其余的拿到 EAGAIN 后白白被唤醒又睡回去。
这就是惊群(thundering herd)。worker 越多,浪费的 CPU 越多。
注意区分两种惊群:
- accept 惊群:Linux 2.6 起,内核在
accept层面已经解决了(只唤醒一个)。- epoll 惊群:如果 fd 是通过
epoll_wait等待的,内核仍会唤醒所有在这个 fd 上等待的进程。Nginx 面对的是这一种。
3.2 方案一:accept_mutex(老方案)
Nginx 早期用一把跨进程的互斥锁:worker 想 accept 新连接,必须先抢到 accept_mutex,抢到的才把 listen fd 加进自己的 epoll,没抢到的就不管 listen fd。
events {
accept_mutex on;
accept_mutex_delay 500ms; # 抢锁失败后等多久再试
}
Nginx 还在这上面加了负载均衡:worker 当前连接数超过 worker_connections × 7/8 时,它会主动放弃抢锁,把机会让给空闲的 worker。
缺点也明显:
- 抢锁本身有开销,锁是串行化的瓶颈。
accept_mutex_delay默认 500ms,低并发时新连接可能要等半秒才被处理,延迟毛刺很明显。
3.3 方案二:EPOLLEXCLUSIVE(Linux 4.5+)
内核提供了 EPOLLEXCLUSIVE 标志,加了这个标志后,一个事件只唤醒一个等待进程。Nginx 1.11.3+ 会自动使用,accept_mutex 的默认值也从此改成了 off。
3.4 方案三:SO_REUSEPORT(推荐)
这是最彻底的方案。Linux 3.9+ 支持多个 socket 绑定到同一个 IP:Port,内核按四元组哈希把新连接分发到不同的 socket 队列。
server {
listen 80 reuseport; # 只在一个 server 块里写就行
}
开启后每个 worker 都有自己独立的 listen fd 和 accept 队列:
不用 reuseport: 用 reuseport:
一个 listen fd worker1 → listen fd 1 → 队列1
↓ 抢 worker2 → listen fd 2 → 队列2
w1 w2 w3 都被唤醒 worker3 → listen fd 3 → 队列3
内核按 hash 直接投递,零竞争
好处:
- 彻底消灭惊群,无锁。
- 连接在内核层就分好了,负载更均匀。
- 官方压测显示延迟分布明显改善(长尾延迟下降)。
代价:
reload时连接分发会有短暂抖动(新老 worker 各有自己的队列,老 worker 队列里的连接需要处理完)。- 极端情况下如果某个 worker 卡住,投递到它队列的连接会一直等,而不会被别的 worker 接走。
生产建议:Linux 3.9+ 直接开 reuseport,把 accept_mutex 保持 off。
4. 惊群相关的完整配置对照
# 现代内核(Linux 4.5+)的推荐配置
events {
worker_connections 10240;
use epoll;
multi_accept on;
accept_mutex off; # 1.11.3+ 默认就是 off
}
http {
server {
listen 80 reuseport; # 关键
}
}
multi_accept on 的作用:worker 被唤醒后,循环 accept 直到队列空,而不是只 accept 一个就回去 epoll_wait。短连接高并发场景(比如秒杀)能减少系统调用次数。长连接场景意义不大,甚至可能造成 worker 间负载不均。
5. 内存池
Nginx 大量使用 ngx_pool_t 内存池,这是它性能好又不容易内存泄漏的重要原因。
5.1 设计思路
传统写法:每需要一块内存就 malloc,用完 free。问题是:
malloc/free系统调用和加锁有开销- 容易忘记 free,或者异常路径漏 free
- 大量小块分配造成内存碎片
内存池的做法:按生命周期批量分配、一次性释放。
// 简化的结构
typedef struct {
u_char *last; // 当前可分配位置
u_char *end; // 池末尾
ngx_pool_t *next; // 下一块
ngx_uint_t failed; // 分配失败次数
} ngx_pool_data_t;
分配小块内存时,只需要 last += size 移动一下指针(还会做内存对齐),几乎零开销。大块内存(超过池大小)单独 malloc 并挂到 large 链表上。
5.2 三种生命周期的池
| 池 | 创建时机 | 销毁时机 | 大小指令 |
|---|---|---|---|
| connection pool | 连接建立 | 连接关闭 | connection_pool_size(默认 256/512 字节) |
| request pool | 请求开始 | 请求结束 | request_pool_size(默认 4k) |
| cycle pool | 启动/reload | 下次 reload | — |
关键效果:一个 HTTP 请求处理完,直接销毁 request pool,这个请求期间分配的所有内存一次性全部回收。业务代码里根本不用管 free,也就不存在漏 free 的问题。
这也解释了为什么 Nginx 处理长连接上的大量请求时内存不会一直涨——每个请求的内存都随请求结束而回收。
5.3 池还支持清理回调
ngx_pool_cleanup_t *cln = ngx_pool_cleanup_add(pool, 0);
cln->handler = ngx_pool_cleanup_file; // 池销毁时自动关文件
cln->data = file;
打开的文件、共享内存等非内存资源也能挂到池上,池销毁时自动清理。
6. 连接池与 ngx_connection_t
worker 启动时会一次性预分配 worker_connections 个 ngx_connection_t 结构,用一个空闲链表串起来:
c = ngx_cycle->free_connections; // 从空闲链表头取
ngx_cycle->free_connections = c->data;
ngx_cycle->free_connection_n--;
取连接是 O(1),不需要动态分配。这就是为什么 worker_connections 会实打实地占内存——设成 100 万,启动时就直接分配 100 万个结构体。
每个 ngx_connection_t 大约 200~300 字节,加上关联的读写事件结构(ngx_event_t,各约 100 字节),一个连接的固定开销在 500 字节左右。10240 个连接约 5MB,这就是 Nginx 内存占用低的原因。
排查
worker_connections are not enough报错时要记住:反向代理场景一个请求占 2 个连接(客户端侧 + 上游侧)。
7. reload 的完整流程
理解了进程模型,reload 就很清晰了:
1. 你执行 nginx -s reload
→ 实际是新起一个 nginx 进程,读 pid 文件,给 master 发 SIGHUP,然后退出
2. master 收到 SIGHUP
→ 重新解析配置文件
→ 失败:打日志、保留旧配置继续跑,reload 静默失效 ⚠️
→ 成功:继续
3. master 创建新的 listen socket(如果 listen 配置变了)
→ 老的 listen fd 如果配置没变则复用
4. master fork 出新的 worker 进程(用新配置)
5. master 给所有老 worker 发 SIGQUIT
6. 老 worker 收到 SIGQUIT:
→ 从 epoll 中移除 listen fd(不再 accept 新连接)
→ 继续处理手上已建立的连接
→ 所有连接处理完 → 销毁池 → 退出
→ 超过 worker_shutdown_timeout 仍未完 → 强制关闭连接后退出
7. master 收到老 worker 的 SIGCHLD,回收僵尸进程,reload 完成
所以 reload 不丢请求:新连接由新 worker 处理,老连接由老 worker 处理完。
需要注意的坑:
- 长连接会拖住老 worker。WebSocket、SSE、大文件下载都会。用
worker_shutdown_timeout 30s;兜底。 - 频繁 reload 会累积老 worker。如果每次 reload 都有一批老 worker 退不掉,进程数会越堆越多,内存吃紧。
ps -ef | grep "nginx: worker process is shutting down"能看到它们。 - 共享内存 zone 在 reload 时会重建。限流计数器、缓存 key 索引会清零,所以别把 reload 当成常规操作反复执行。
8. 平滑升级的完整流程
1. 替换二进制文件
2. kill -USR2 <old_master_pid>
→ 老 master 把 nginx.pid 重命名为 nginx.pid.oldbin
→ 老 master fork + execve 新二进制,产生新 master
→ 新 master 通过环境变量 NGINX 拿到继承的 listen fd 列表
(老 master 在 exec 前把 fd 号写进了环境变量)
→ 新 master fork 新 worker
→ 此时新老 worker 都在同一批 listen fd 上 accept,共同服务
3. kill -WINCH <old_master_pid>
→ 老 master 让老 worker 优雅退出
→ 只剩老 master 空转着(作为回滚的后路)
4. 验证新版本无问题 → kill -QUIT <old_master_pid>
验证发现问题 → kill -HUP <old_master_pid> 重新拉起老 worker
→ kill -QUIT <new_master_pid> 干掉新的
关键机制是通过环境变量传递 listen fd。execve 不会关闭没设 FD_CLOEXEC 的 fd,所以新进程能直接继承监听套接字,端口一刻都没有释放过,连接不会被拒绝。
9. 阻塞操作:Nginx 的软肋与线程池
事件驱动模型有个致命前提:回调函数绝不能阻塞。一旦某个回调卡住,整个 worker 上的几万个连接全部停摆。
哪些操作会阻塞 worker?
| 操作 | 说明 |
|---|---|
| 磁盘 I/O | read() 一个不在 page cache 里的文件,会阻塞等待磁盘 |
sendfile 冷文件 |
同上,数据不在页缓存时会阻塞 |
| DNS 同步解析 | proxy_pass 用域名时,如果没配 resolver 会走 glibc 的同步解析 |
| 第三方模块的同步调用 | 自己写的模块里调了阻塞函数 |
| Lua 里的阻塞操作 | OpenResty 里用 os.execute、阻塞的 socket 库 |
9.1 线程池
Nginx 1.7.11 引入了线程池,把阻塞的磁盘读操作卸载到独立线程:
# main 块
thread_pool default threads=32 max_queue=65536;
http {
aio threads; # 或 aio threads=default
aio_write on;
sendfile on;
directio 8m; # 大于 8M 的文件绕过 page cache 直读
output_buffers 2 512k;
}
工作方式:worker 遇到可能阻塞的读操作,把任务丢进线程池队列,自己立刻回到事件循环;线程池的线程完成读取后,通过 eventfd 通知 worker 继续处理。
官方测试数据:在大文件(数据集远大于内存)随机读的场景下,吞吐提升了 9 倍。但如果文件都在 page cache 里,开线程池反而会因为额外的调度开销让性能变差。所以这是「大量冷数据读取」场景的专用优化,不是万能加速开关。
9.2 DNS 解析阻塞
这个坑非常常见:
# ❌ 启动时解析一次域名,之后永远用这个 IP
# 上游 IP 变了(K8s Pod 重建、云厂商 SLB 换 IP)就全挂
location / {
proxy_pass http://api.example.com;
}
# ✅ 用 resolver + 变量强制运行时解析
location / {
resolver 8.8.8.8 valid=30s ipv6=off;
set $backend "api.example.com";
proxy_pass http://$backend;
}
关键点:只有 proxy_pass 的地址里含变量时,Nginx 才会在运行时用自己的异步 resolver 解析。写死域名的话,只在启动/reload 时解析一次并缓存到进程生命周期结束。
用变量的副作用:URI 不会自动传递,需要显式写。
set $backend "api.example.com";
proxy_pass http://$backend$request_uri;
10. 性能数字感
一台普通的 8 核 16G 云主机上,Nginx 的大致能力(供估算参考,实际取决于业务):
| 场景 | 量级 |
|---|---|
| 静态小文件 QPS | 10 万+ |
| 反向代理 QPS | 3~5 万(受后端和 TLS 影响大) |
| 并发长连接数 | 10 万+(内存约 100MB~1G) |
| HTTPS 新建握手(RSA 2048) | 每核 1000~2000 次/秒 |
| HTTPS 新建握手(ECDSA P-256) | 每核 5000+ 次/秒 |
TLS 握手是最贵的操作,这也是为什么会话复用(Session Ticket / Session Cache)和 ECDSA 证书那么重要——第 10 篇会详细讲。
11. 面试题
Q:Nginx 的进程模型是什么样的?master 和 worker 各干什么?
一个 master 加多个 worker。master 以 root 运行,负责读配置、创建监听 socket、fork 和监控 worker、接收信号,不处理任何请求。worker 以低权限运行,从继承的 listen fd 上 accept 连接,在 epoll 事件循环里处理所有请求。worker 之间相互独立、无共享(除共享内存),单线程无锁。另有 cache manager / cache loader 两个辅助进程。
Q:多个 worker 怎么同时监听 80 端口?
master 在 fork 之前完成 socket/bind/listen,fork 时 worker 继承这个 fd,所以它们共享同一个内核 socket。或者开启 SO_REUSEPORT,让每个 worker 各自 bind 同一个端口,由内核分发。
Q:什么是惊群?Nginx 怎么解决的?
多个进程在同一个 listen fd 上等待,新连接到来时内核唤醒所有进程,但只有一个能 accept 成功,其余空转。Nginx 有三代方案:早期用 accept_mutex 互斥锁串行化 accept(有 500ms 延迟毛刺);Linux 4.5+ 用 EPOLLEXCLUSIVE 让内核只唤醒一个(1.11.3+ 自动启用,accept_mutex 默认改为 off);最优解是 listen ... reuseport,每个 worker 有独立的 listen fd 和 accept 队列,内核按四元组 hash 分发,彻底无竞争。
Q:epoll 为什么比 select 快?
三点:(1) fd 集合常驻内核红黑树,不用每次调用都从用户态全量拷贝;(2) 内核用回调把就绪 fd 直接挂到就绪链表,epoll_wait 不需要 O(n) 遍历所有 fd;(3) 返回时只拷贝就绪的 fd。所以开销只与活跃连接数相关,与总连接数无关。此外 select 有 1024 的 fd 上限。
Q:Nginx 是单线程的吗?
每个 worker 进程默认是单线程的事件循环。但整体是多进程并行,另外可以开启 aio threads 线程池把阻塞的磁盘 I/O 卸载到辅助线程。所以准确说法是「多进程 + 每进程单线程事件循环 + 可选的 I/O 线程池」。
Q:什么操作会阻塞 worker?后果是什么?
磁盘冷读、未配 resolver 时的同步 DNS 解析、第三方模块里的同步调用、Lua 里的阻塞函数。后果是这个 worker 上的所有连接(可能上万个)全部停止处理,表现为部分请求突然变慢或超时。解决办法分别是 aio threads、配置 resolver + 变量式 proxy_pass、改用异步 API。
Q:reload 的过程中发生了什么?共享内存里的数据会丢吗?
master 收 SIGHUP → 重新解析配置(失败则静默保留旧配置)→ fork 新 worker → 给老 worker 发 SIGQUIT → 老 worker 停止 accept、处理完存量连接后退出。共享内存 zone 会重建,所以限流计数器、upstream 的 zone 状态、缓存索引都会重置。这是不宜频繁 reload 的原因之一。
Q:Nginx 的内存池解决了什么问题?
按生命周期(connection / request / cycle)批量分配内存,小块分配只是移动指针,几乎零成本;请求结束时一次性销毁整个 request pool,所有该请求分配的内存自动回收。既避免了频繁 malloc/free 的开销和碎片,也从机制上杜绝了忘记 free 导致的泄漏。
上一篇:Nginx-02 配置文件结构与核心指令 | 下一篇:Nginx-04 请求处理流程与 11 个阶段
xingliuhua