跳转到内容
← 返回系统剖析
系统剖析当代32 分钟阅读

Nginx 的事件驱动架构:一个进程如何扛住十万连接

Nginx's Event-Driven Architecture: How One Process Holds a Hundred Thousand Connections

2002 年,Igor Sysoev 在俄罗斯门户站点 Rambler 做系统管理。他遇到的不是"服务器慢",而是一个更硬的墙:Apache 的连接数一旦爬到几千,机器就开始换页、卡顿、拒绝服务,而此时 CPU 使用率并不高,磁盘也没跑满。资源明明还在,服务却已经不能用了。 他花了两年写出 nginx,2004 年 1…

Nginx事件驱动架构I/O 多路复用反向代理并发模型

2002 年,Igor Sysoev 在俄罗斯门户站点 Rambler 做系统管理。他遇到的不是"服务器慢",而是一个更硬的墙:Apache 的连接数一旦爬到几千,机器就开始换页、卡顿、拒绝服务,而此时 CPU 使用率并不高,磁盘也没跑满。资源明明还在,服务却已经不能用了。

他花了两年写出 nginx,2004 年 10 月 4 日发布 0.1.0 版。这个系统解决问题的方式不是"把每一步做快一点",而是换掉了"一条连接对应一个执行单元"这个前提

这篇文章要讲清楚的是:那堵墙的材质到底是什么,nginx 用什么换掉了什么,以及这套模型在哪些时刻会当场垮掉。

破除误解

误解一:nginx 是"单进程"或"单线程"的服务器。

不是。跑起来的 nginx 至少有两类进程:一个 master 进程和若干 worker 进程worker_processes auto 会把 worker 数量设成 CPU 核数。"一个进程扛十万连接"说的是单个 worker 能同时持有的连接数量,不是整机只用一个核。一台 16 核的机器上跑 16 个 worker,每个 worker 各自跑一份独立的事件循环,彼此不共享请求状态。真正精确的说法是:nginx 是"每核一个单线程事件循环"的进程模型

误解二:epoll 只是"更快的 select",所以任何场景换上去都会更快。

epoll 换掉的不是常数,而是复杂度曲线的自变量。select 的开销跟你监视的连接总数成正比,epoll 的开销跟这一刻真正有事发生的连接数成正比。当你只盯着 20 条连接、而且这 20 条一直在收发数据时,select 反而更省事——它没有 epoll_ctl 的注册开销,一次系统调用就问完了。epoll 的胜利条件是一个很具体的前提:连接总数远大于活跃连接数。浏览器的 keep-alive 长连接、手机 App 的长轮询正好制造了这个前提,所以它赢了;如果没有这个前提,它的账并不划算。

误解三:"非阻塞 I/O"意味着 worker 永远不会被卡住。

O_NONBLOCK 只对套接字、管道这类"可能没数据"的对象有意义。对普通磁盘文件,O_NONBLOCK 基本不起作用——read() 一个不在页缓存里的文件,内核会老老实实把调用线程挂起去等磁盘,没有"稍后再来"这个选项。这就是为什么 nginx 在 2015 年 3 月的 1.7.11 版本里不得不加进线程池thread_pool 指令):不是为了并行计算,而是为了把这类无法非阻塞化的调用赶出事件循环。事件驱动没有取消阻塞,只是把阻塞挪走了;挪不走的地方,它照样会卡。

C10K:一万条连接为什么能压垮"每连接一线程"

1999 年,Dan Kegel 写了一篇后来被反复引用的文章《The C10K problem》,把问题摆成一道算术题:一台机器的硬件已经足够同时服务一万个客户端了,为什么软件做不到?

"每连接一线程"(thread-per-connection)的代价并不在你以为的地方。它一共开出三张账单。

第一张账单:栈的地址空间。 Linux 上用 glibc 创建线程,默认栈大小取 ulimit -s,通常是 8 MB。这 8 MB 是虚拟地址空间的预留,不是立刻占用的物理内存——真正用到几 KB 就只驻留几 KB。所以"一万线程 = 80 GB 内存"这个常见说法是错的。但预留本身不是免费的:它吃掉地址空间、制造大量互不相邻的映射区、让内存分配器的行为变差。

第二张账单:内核对象。 这一张是实打实的物理内存。每个线程在内核里有一个 task_struct,还有一个独立的内核栈(x86-64 上是 16 KB,且必须是常驻的、不可换出的物理页)。一万个线程,光内核栈就是 160 MB 起步,再加上调度器的运行队列、每线程的信号处理结构、文件描述符表项。这部分不能被换出、不能被压缩

第三张账单,也是最贵的一张:调度与缓存。 一次上下文切换本身的直接开销在微秒量级,听起来不多。真正的损失是间接的:切换意味着新线程的工作集要重新填进 L1/L2 缓存,TLB 条目失效。当一万个线程轮流上 CPU,每个线程刚把缓存捂热就被换下去,CPU 大部分时间在搬运数据而不是处理请求。这就是 Rambler 那台机器的症状——CPU 使用率不高,因为它在等内存。

三张账单有一个共同点:成本与"连接总数"成正比,而收入与"活跃连接数"成正比。Web 的流量形态恰恰让这两个数字差出两个数量级——一万条 keep-alive 连接里,任一毫秒真正有字节要处理的可能只有几十条。你在为 9950 条什么都没干的连接付全额工资。

Apache 的两代模型都撞在这里。prefork MPM 一个子进程服务一条连接,MaxRequestWorkers 默认就是 256——不是因为 256 是个好数字,而是因为每个子进程(尤其挂着 mod_php 时)的常驻内存在几 MB 到几十 MB,再多机器就换页了。Apache 2.4(2012 年)把 event MPM 转为稳定版,做法是让一个监听线程接管空闲的 keep-alive 连接,只有正在处理请求时才占用工作线程——这已经是往事件驱动方向挪了半步,但只要请求在处理中,一个线程仍然被一条连接独占。

nginx 的答案更彻底:取消"执行单元"和"连接"之间的绑定。连接不再是一个正在运行的东西,而是一份数据——一个状态机结构体,静静躺在内存里,只有当它对应的文件描述符上有事件发生时,才被取出来推进一步。

master/worker:为什么是进程,而不是线程池

启动 nginx,你会看到这样的进程树:

root     nginx: master process /usr/sbin/nginx
nginx     \_ nginx: worker process
nginx     \_ nginx: worker process
nginx     \_ nginx: cache manager process
```

master 进程不处理任何请求。 它做四件事:以 root 身份读配置、绑定特权端口(80/443)、打开日志和证书文件;fork 出 worker 并把权限降到 user 指令指定的非特权用户;监控 worker 存活,异常退出就重新拉起;接收信号完成配置重载与二进制升级。

配置重载(nginx -s reload,即 HUP 信号)的流程值得记住:master 用新配置启动一批新 worker,然后向老 worker 发 QUIT。老 worker 立刻停止接受新连接,但把手上正在处理的请求走完才退出。所以 nginx 的 reload 对在途请求是无感的——这个能力直接来自"master 不碰请求"这条分工。二进制热升级(USR2 + WINCH + QUIT)是同一套机制的延伸:新老两个 master 可以短暂共存,共享同一批监听套接字。

那么关键问题:worker 之间为什么用进程隔离,而不是一个进程里开一组线程?

维度多进程 worker(nginx)单进程多线程(线程池/work-stealing)
故障爆炸半径一个 worker 段错误,master 重启它,其余 worker 无感任一线程踩坏堆,整个进程带着所有连接一起死
共享状态默认什么都不共享,因此不需要锁天然共享,凡是共享的都要加锁或做无锁设计
负载均衡只能靠内核分发连接,可能出现 worker 忙闲不均可以任务窃取,天然均衡
第三方模块不要求线程安全,模块作者门槛低所有模块必须线程安全,否则全局翻车
内存占用每 worker 一份缓存副本(如 open_file_cache),有重复一份缓存全线程共用
权限模型master 保留特权,worker 全程非特权特权与业务代码在同一地址空间

这张表里最重要的一行是"共享状态"。worker 之间不共享请求状态,是整个模型能保持简单的根基:worker 内部是单线程的,一条连接的状态机在推进时不可能被同一份数据的另一个访问者打断,所以 nginx 的绝大部分代码可以完全不考虑并发。这省下的不是几个锁,而是一整类 bug。

代价当然存在,而且很具体。凡是真正需要全局一致的东西,都得走共享内存:proxy_cache_pathkeys_zonelimit_req_zone 的限流计数器、ssl_session_cache 的会话票据,都要在 mmap 出来的共享内存区里用 slab 分配器管理,并且必须加互斥锁。于是 nginx 里所有的锁几乎都集中在这几个共享内存区上,成为一个可识别的、可优化的小面积热点。

还有一个历史包袱值得一提:多个 worker 同时在同一个监听套接字上等待新连接,一个连接到来会把它们全部唤醒,但只有一个能 accept 成功——这就是惊群效应(thundering herd)。nginx 早年用 accept_mutex 解决:让 worker 排队去抢一把互斥锁,只有拿到锁的才去 accept。但这引入了新问题:抢锁本身有延迟,且拿到锁的 worker 可能正忙。后来内核提供了更好的原语——Linux 3.9 的 SO_REUSEPORT(nginx 1.9.1 起支持 listen ... reuseport,由内核按四元组哈希把连接直接分给某个 worker),以及 Linux 4.5 的 EPOLLEXCLUSIVE(nginx 1.11.3 起使用,保证一个事件只唤醒一个等待者)。所以 accept_mutex 从 nginx 1.11.3(2016 年 7 月)起默认关闭了。这是一个典型的"应用层变通被内核原语取代"的例子。

select 的轮询与 epoll 的就绪通知

现在看事件循环的底座。I/O 多路复用(I/O multiplexing)要解决的是同一个问题:一个执行单元,怎么知道成千上万个连接里哪几个现在有数据。

select(2) 的做法是每次都重新问一遍。你把关心的文件描述符装进三个位图(读/写/异常),交给内核;内核遍历全部描述符,逐个检查状态,把结果写回位图;你拿回来之后还得自己再遍历一遍,才知道哪些就绪了。这里有三处 O(n):拷贝位图、内核扫描、用户态回扫。更硬的限制是 FD_SETSIZE,Linux 上通常编译期固定为 1024——不是性能问题,是根本装不下。poll(2) 用数组替代位图,去掉了这个上限,但那三处 O(n) 一个都没少。

epoll 的关键改动是把"你关心什么"这件事从每次调用里拿出来,变成内核长期持有的状态。三个系统调用分工明确:

c
int ep = epoll_create1(0);                   // 建一个内核对象
epoll_ctl(ep, EPOLL_CTL_ADD, fd, &event);    // 注册一次,长期有效
int n = epoll_wait(ep, events, maxevents, timeout);  // 只拿回就绪的那几个
```

内核侧维护两个结构:一棵红黑树存放所有注册过的描述符(保证增删查是 O(log n)),一个就绪双向链表存放已经有事件的那些。当网卡驱动收到数据、唤醒某个套接字时,回调会把对应的节点挂到就绪链表上。epoll_wait 只需要把这条链表倒出来——代价与就绪数量成正比,与注册总数无关

Davide Libenzi 在 2001 年 7 月提出 epoll,2002 年 10 月并入 Linux 2.5.44,随 2003 年 12 月的 2.6 稳定版进入主流。更早的同类设计是 Jonathan Lemon 的 kqueue,2000 年随 FreeBSD 4.1 出现,并在 USENIX 2001 上发表论文《Kqueue: A Generic and Scalable Event Notification Facility》。kqueue 的设计其实更野心:它不止管文件描述符,还能统一处理定时器、信号、子进程退出、文件系统变更(EVFILT_VNODE),是一个通用事件总线。

机制平台 / 年份每次调用的代价描述符上限状态保存在哪
selectPOSIX,1983 起O(总连接数),位图来回拷贝FD_SETSIZE,Linux 通常 1024每次调用重新提交
pollPOSIXO(总连接数)无硬上限每次调用重新提交
epollLinux 2.6,2003O(就绪连接数)ulimit -n 约束内核红黑树 + 就绪链表
kqueueFreeBSD 4.1,2000O(就绪事件数)ulimit -n 约束内核 kqueue 对象,事件源统一
io_uringLinux 5.1,2019共享环形队列,可批量、可零系统调用ulimit -n 约束用户态与内核共享的 SQ/CQ 环

nginx 在编译期按平台挑选后端,运行时可用 use epoll; 显式指定:Linux 用 epoll,BSD 与 macOS 用 kqueue,Solaris 用 eventport,都没有才退回 select

还有一个细节决定了 nginx 的代码风格:水平触发(LT)与边缘触发(ET)。水平触发是"只要缓冲区里还有数据,每次 epoll_wait 都会告诉你";边缘触发是"只在状态从无到有的那一刻通知你一次"。nginx 在 epoll 上默认使用边缘触发EPOLLET),因为它能减少重复通知、少跑几趟系统调用。代价是编码纪律变严:收到通知后必须循环读到 EAGAIN 为止,否则剩下的数据将永远等不到第二次通知,那条连接就静默挂死。这是事件驱动服务器最经典的一类 bug,也是 ET 模式必须由框架而不是业务代码来承担的原因。

一条连接在 worker 里的一生

把上面两块拼起来,就是 worker 的主循环 ngx_process_events_and_timers()

  1. 查定时器红黑树,找出最近一个将要超时的事件,把它到期的剩余时间作为 epoll_wait 的 timeout。
  2. epoll_wait 阻塞等待,返回一批就绪事件。
  3. 逐个调用事件的读/写回调,把对应连接的状态机往前推一步,然后立刻返回循环。
  4. 处理超时的定时器(连接超时、上游响应超时、限速的延迟发送)。
  5. 回到第 1 步。

这里有两个设计点值得单独说。

定时器为什么用红黑树。 十万条连接意味着十万个超时时间,每次循环都要问"最近的一个是谁"。红黑树给出 O(log n) 的插入删除和 O(1) 的取最小值(缓存最左节点)。如果用链表,光是维护超时顺序就能把事件循环拖垮。

内存池 ngx_pool_t nginx 为每个请求开一个内存池(request_pool_size 默认 4k),请求期间所有小块分配都从池里切,请求结束时整块一次性归还,不做逐个 free。这在事件驱动模型里尤其重要:请求的生命周期被切成很多段回调,如果靠人工配对 malloc/free,泄漏几乎是必然的。用池换掉的是"精细回收",买到的是"生命周期与请求对齐"。

HTTP 请求本身被拆成 11 个阶段(post-read、server-rewrite、find-config、rewrite、post-rewrite、preaccess、access、post-access、precontent、content、log),模块挂在阶段上。这个阶段表就是那个状态机的骨架——任何一步都可以返回"我还没做完,等下一次事件再叫我"。

现在算内存账,这是整篇文章的落点:

每条连接的常驻成本量级说明
每连接一线程:栈预留8 MB 虚拟地址空间ulimit -s,物理占用远小于此,但地址空间与映射管理是真实成本
每连接一线程:内核对象十几 KB 物理内存task_struct + 16 KB 内核栈,不可换出
nginx 空闲 keep-alive 连接约 256 字节官方文档给出的数字:10000 条空闲 keep-alive 连接约占 2.5 MB
nginx 活跃请求(静态文件)数 KB 到十几 KB请求池 4k + client_header_buffer_size 1k + 输出缓冲
nginx 活跃请求(反向代理)数十 KB再加 proxy_buffer_sizeproxy_buffers(默认 8 个 4k 或 8k)

从 MB 级到 KB 级的压缩,来源不是什么巧妙的编码,而是一次会计口径的改变:线程模型为"这条连接可能要执行的代码"预留资源,事件模型只为"这条连接现在的状态"付费。空闲连接的状态少到几百字节,因为它此刻确实什么都没在做。

worker 启动时会一次性分配一个大小为 worker_connections 的连接数组——所以这个参数不只是上限,它在启动瞬间就决定了 worker 的内存底盘worker_connections 默认 512(发行版配置通常写成 1024),理论最大并发是 worker_processes × worker_connections。注意官方文档的提醒:这个数字包含所有连接,不只是客户端连接。做反向代理时,一个客户端请求要同时占客户端侧和上游侧两个槽位,实际能服务的客户端数量要除以二。同时必须把 worker_rlimit_nofile 调到相匹配的值,否则先撞上的是文件描述符上限。

代价:一次阻塞,全员罚站

现在讲这个模型最锋利的那一面。

在线程模型里,某个线程干了件慢活,调度器会把它换下去,其他线程继续跑,受损的只有这一个请求。在 nginx 的 worker 里没有调度器——事件循环是协作式的,只有回调主动返回,下一个事件才能被处理。所以某个回调里出现一次耗时 200 毫秒的操作,这个 worker 上所有连接(可能是上万条)都要一起等 200 毫秒。

会造成这种事故的操作有明确清单:

  • 磁盘读取。前面说过,O_NONBLOCK 对普通文件不起作用。如果数据集远大于内存、页缓存命中率低,每次 read() 都可能变成一次几毫秒的磁盘等待。
  • 大响应的 gzip 压缩。压缩是纯 CPU 工作,gzip_comp_level 调到 9 会让单次调用显著变长。
  • TLS 握手。非对称运算是 CPU 密集的,握手风暴(大量新连接同时到达)能把 worker 的 CPU 打满,而此时已建立的连接只能排队。
  • 同步的第三方模块。官方的 ngx_http_perl_module 文档明确把它标为实验性并提示执行 Perl 代码时 worker 会被阻塞;用 OpenResty 写 Lua 时,任何调用阻塞式库的代码都有同样的效果。
  • 正则回溯。nginx 用 PCRE,一个写得不好的 location ~ 正则遇到构造过的 URI,可能触发指数级回溯。
  • DNS 解析。标准库的 getaddrinfo() 是阻塞的,这就是 nginx 自带一个异步 DNS 解析器(resolver 指令)而不是直接调用它的原因。

nginx 对第一项给出了正式解法:1.7.11(2015 年 3 月 24 日)引入线程池thread_pool 指令的默认配置是 thread_pool default threads=32 max_queue=65536;,配合 aio threads; 使用后,可能阻塞的文件读取和 sendfile 被投递给这组线程执行,完成后通过事件通知回主循环。注意这个线程池只承接 I/O,不执行任何请求处理逻辑——那条"命令处理全在单线程"的边界依然没被打破。官方博客那篇《Thread Pools in NGINX Boost Performance 9x!》报告的 9 倍提升,前提是一个数据集远大于内存的磁盘密集测试;在页缓存能覆盖热数据的场景下,开启它反而多一次线程调度,收益接近于零甚至为负。

其余几项没有通用解法,只有工程纪律:把 CPU 密集的活赶出 worker(TLS 卸载给专门的层、压缩结果缓存起来复用)、给 worker 留出 CPU 余量、用 $request_time$upstream_response_time 的分位数而不是均值来监控——均值会把"一次 200 毫秒卡顿波及一万条连接"这件事完全抹平

为什么反向代理和静态文件天生适配

回过头看,nginx 最主要的两种用途有一个共同特征:几乎不消耗 CPU,全程在等 I/O

静态文件服务的极限形态是 sendfile(2):内核直接把页缓存里的文件内容送进套接字,数据从头到尾不进用户态,省掉两次拷贝和两次上下文切换。配上 tcp_nopush(内核侧对应 TCP_CORK),响应头和文件首块被凑成一个满包再发出。这条路径上 nginx 做的事只有"发起一次系统调用然后等通知",正是事件循环最擅长的形状。

反向代理的适配理由更有意思,它揭示了 nginx 在架构里真正的位置。上游的应用服务器——PHP-FPM 的进程、Java 的线程池、Python 的 worker——每一个都很昂贵,数量以几十计。如果让它们直接面对公网客户端,一个用 2G 网络、300 毫秒往返、慢慢接收响应的手机用户,就能占着一个 PHP-FPM 进程好几秒钟。几百个慢客户端足以耗尽整个上游池,而此时上游的 CPU 是闲的。

nginx 默认开启的 proxy_buffering on 正是针对这件事:nginx 用最快的速度从上游读完整个响应,放进自己的缓冲区,立刻释放上游连接,然后再按客户端能接受的速度慢慢喂。上游被占用的时间从"客户端接收完的时间"缩短为"上游生成完的时间"。同样地,请求方向上,client_body_buffer_size 让 nginx 先把慢速上传收完再转给上游。

这是一次成本对齐:把"等待慢客户端"这项工作,从每单位成本高达 MB 级的上游进程,转移到每单位成本几百字节的 nginx 连接槽上。它同时也是 nginx 与上游之间用 keepalive 保持长连接的理由——面向客户端的连接是海量且短命的,面向上游的连接应该是少量且复用的,这两种流量形态需要用不同的资源模型来承接,而 nginx 站在它们的交界处做转换。

反过来说,什么时候不该用这个模型:

  • 业务逻辑本身重 CPU。模板渲染、图像处理、加解密——这些活放进 worker 就是在拿全局延迟做赌注。它们属于上游。
  • 需要跨 worker 的强一致状态。精确到个位数的全局限流、跨 worker 共享的上游连接池,在多进程模型里要么做不到,要么要付共享内存加锁的代价。limit_req 的计数器就在共享内存里,这是它比纯本地方案慢的原因。
  • 单 worker 被打满。事件循环没有抢占,一个 worker 的 CPU 就是它的硬顶。TLS 握手风暴、大量 gzip 都能把它顶满,而增加 worker_connections 只会让排队更长。
  • 瓶颈根本不在应用层。十万连接会先撞上别的东西:ulimit -n、conntrack 表容量、源端口耗尽(做反向代理时对同一上游只有约 6 万个端口)、TCP 内存。这些参数不调,nginx 的架构优势一次也用不上。

一句话概括这个系统的性格:nginx 用"每个回调都必须很快返回"这条纪律,换来了与连接数几乎无关的内存开销和可预测的调度。纪律守得住,一台普通机器确实能扛住十万连接;守不住,一次阻塞就让一万个人一起等。

跨域连接

  • 电信网络:C10K 的解法在电信业已经被验证过一遍。电路交换给每一路通话独占一条物理线路,利用率极低;分组交换与统计复用则押注"绝大多数信道在绝大多数时刻是空闲的",于是用远少于用户数的资源服务所有人。事件驱动就是这一赌注在操作系统里的复刻——一万条 keep-alive 连接中,任一毫秒真正有数据可读的往往只有几十条,"每连接一线程"本质上是在为空闲付费。这条类比还给出了容量规划的正确口径:电信用爱尔兰公式从"并发呼叫的概率分布"而非"用户总数"推算中继数量,nginx 的 worker_connections 同样必须按活跃并发而不是注册用户数来估——两者的失效模式也一致,都是需求分布突然去掉长尾(全体同时到达)时,统计复用的假设当场崩塌。
  • 信号处理selectepoll 的差别,在信号处理里有一个成熟得多的对应物。select 是定周期采样:不管有没有信号,每个周期都把全部通道扫一遍,采样成本由通道数决定而非事件数决定,在稀疏信号上做的几乎全是无用功。epoll 是事件驱动采样,只在状态发生跃变时才产生一个样本,代价随信息量而非随时间流逝增长。更精确的对应在触发模式上:水平触发关心的是"当前电平是否高",边缘触发关心的是"是否发生了跳变"——这正是数字电路里电平敏感与边沿敏感的区分,连失效模式都一样。边沿检测一旦漏掉一个跳变,状态就永久错位,所以 EPOLLET 下必须循环读到 EAGAIN,等价于边沿触发电路必须保证不丢脉冲。
  • 供应链proxy_buffering 是一个教科书式的解耦缓冲区。供应链理论里,当上下游的节拍不匹配时,标准做法是在两者之间设置库存缓冲,让快的一方不必等慢的一方——代价是占用仓储、延长在途时间。nginx 做的完全是同一件事:上游 PHP-FPM 进程的产出速度以毫秒计,公网客户端的消费速度以秒计,nginx 用自己的缓冲区把两者的节拍隔开,让昂贵的上游产能不被慢消费者锁死。这个类比还解释了关闭缓冲的后果:proxy_buffering off 相当于取消库存改做直连,上游的每一次交付都必须等客户端确认,节拍波动被原样传导回去并逐级放大,这正是牛鞭效应的机理。而缓冲区该设多大,取决于同一个古典权衡——库存成本(内存)对缺货成本(上游被占用),proxy_buffers 就是这道题的配置项。
  • 失效分析:单线程事件循环把一类局部故障升级成了系统性故障,这是它必须被单独分析的原因。失效分析区分独立失效与共因失效:前者影响单个部件,后者一个原因同时击穿多个看似独立的部分。在线程模型里,一次慢磁盘读取只拖垮一个线程,是独立失效;在事件循环里,同一次读取阻塞的是整个 worker,上万条连接共享了这一个失效原因——爆炸半径从 1 涨到 worker_connections。这解释了 nginx 的两项设计为什么必要:多 worker 进程把爆炸半径限制在 1/N(这是分区隔离,不是为了性能),线程池则是把已识别出的共因——不可非阻塞化的文件 I/O——物理移出共享路径。它也解释了监控口径为什么必须换:均值会把这类事故完全掩盖,只有尾部分位数才能暴露"少数极端值波及全体"的失效特征。
  • 创造性破坏:Apache 到 nginx 的更替不是"新软件写得更好",而是约束条件变了之后旧架构的最优性失效。Apache 的每连接一进程模型诞生于 1995 年,那时一台服务器面对的是几百个短连接的用户,进程隔离带来的稳健性远比内存效率重要——它当时是正确解。真正让它失效的是外部条件:keep-alive 成为常态、连接数涨了两个数量级、连接的空闲比例逼近 1,于是"成本随连接数走"这个原本无害的性质突然变成致命伤。Schumpeter 强调的正是这一点:被淘汰者往往不是变差了,而是环境的评价函数变了,而深度优化过的架构恰恰最难转向——Apache 用了十七年才在 2.4 里把 event MPM 转正。同样的逻辑现在指向 nginx 自己:io_uring(2019)让系统调用本身可以批量化和零切换,Envoy 与 Cloudflare 的 Pingora(2024 年开源)用多线程工作窃取取代了多进程分区,而 nginx 的无锁简洁性正建立在"不共享"这个前提上,转向的成本同样高昂。

参考文献

  • Kegel, D. The C10K problem. kegel.com, 1999(后续持续更新).(问题的原始提法,以及当时各平台 I/O 方案的横向清单)
  • Lemon, J. Kqueue: A Generic and Scalable Event Notification Facility. USENIX Annual Technical Conference (FREENIX Track), 2001.(kqueue 的设计论文,统一事件源的思路来源)
  • nginx. Core functionality(ngxcoremodule). nginx 官方文档.(worker_connections 默认 512、worker_processes autoaccept_mutex 自 1.11.3 起默认关闭、thread_pool default threads=32 max_queue=65536 的一手出处)
  • nginx. nginx.org 首页与 CHANGES.(0.1.0 于 2004 年 10 月 4 日发布、10000 条空闲 keep-alive 连接约占 2.5 MB、1.7.11 于 2015 年 3 月 24 日加入线程池、1.9.1 加入 reuseport
  • Bartenev, V. Thread Pools in NGINX Boost Performance 9x! NGINX 官方博客, 2015.(线程池的动机、O_NONBLOCK 对普通文件无效的说明,以及那个 9 倍数字的具体测试条件)
  • man7.org. epoll(7) — Linux manual page.(红黑树注册表与就绪链表、水平触发与边缘触发的语义、EPOLLEXCLUSIVE 自 Linux 4.5 起可用)

延伸阅读

  • Reese, W. Nginx: the High-Performance Web Server and Reverse Proxy. Linux Journal, 2008——nginx 早期的架构综述,能看到这套模型在被广泛采用之前是怎么被解释的。
  • Chemeris, A. 等. nginx 一章,收于 Brown, A. & Wilson, G. 编《The Architecture of Open Source Applications, Volume II》, 2012——由 nginx 开发者撰写的内部结构剖析,含模块与阶段体系的完整说明。
  • Cloudflare. How we built Pingora, the proxy that connects Cloudflare to the Internet. Cloudflare 博客, 2022——一份明确以 nginx 为对照的重写自述,把"多进程分区"与"多线程工作窃取"的取舍讲得很具体。