目录

Nginx-03 进程模型与事件驱动原理

前置阅读:Nginx-02 配置文件结构与核心指令网络-20 Socket 编程与 IO 模型

这一篇是整个系列的原理核心,也是面试被问得最深的部分。搞懂了「Nginx 为什么快」,很多配置项存在的意义自然就明白了。

1. master-worker 多进程模型

./nginx多进程模型.png

启动后的进程长这样:

$ 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(socketbindlisten
  • fork worker 进程,并把 listen fd 继承给它们
  • 监控 worker 状态,异常退出时自动重新拉起
  • 接收信号(HUP/USR2/QUIT…)并转发给 worker
  • 平滑升级时管理新旧二进制的交接

worker 进程(低权限,实际干活):

  • 从 listen fd 上 accept 连接
  • 用 epoll 事件循环处理所有连接的读写
  • 执行 HTTP 请求处理的完整流程(第 04 篇讲)
  • 互相独立、不共享数据(除了共享内存 zone),一个崩了不影响其他

cache manager:定期检查缓存目录,按 max_sizeinactive 淘汰过期缓存文件。

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_connectionsngx_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 fdexecve 不会关闭没设 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 会重建,所以限流计数器、upstreamzone 状态、缓存索引都会重置。这是不宜频繁 reload 的原因之一。

Q:Nginx 的内存池解决了什么问题?

按生命周期(connection / request / cycle)批量分配内存,小块分配只是移动指针,几乎零成本;请求结束时一次性销毁整个 request pool,所有该请求分配的内存自动回收。既避免了频繁 malloc/free 的开销和碎片,也从机制上杜绝了忘记 free 导致的泄漏。


上一篇:Nginx-02 配置文件结构与核心指令 | 下一篇:Nginx-04 请求处理流程与 11 个阶段