首页
下载
文档
社区
视频
捐赠
开源项目
typephp
swoole
code-galaxy
swoole-cli
phpx
phpy
赞助商
AOT 编译器
AI 助理
商业产品
Swoole-Compiler 代码加密器
CRMEB 新零售社交电商系统
新加坡独享 Mac mini AI 工作站
登录
注册
全部
提问
分享
讨论
建议
公告
开发框架
TypePHP
CodeGalaxy
发表新帖
为什么 PHP-FPM 没办法支持协程和异步
每隔一段时间,社区里就会出现同一个期待:PHP 官方什么时候给 FPM 加一个异步 IO,让 PHP 也能像 Swoole 那样,几个进程扛住上万并发? 这个问题的答案不是"官方不想做"。真正的原因是:**FPM 这个形态,从设计的第一天起就装不下协程**。异步不是给 FPM 加一个开关,而是要同时推翻它的进程模型、它的全局状态假设,以及它对"请求边界"的定义。 下面把这件事拆开讲。 ## 一、先把 FPM 的并发算清楚 PHP-FPM 是典型的 Leader-Follower 多进程模型:一个 master 负责管理,N 个 worker 负责干活。每个 worker 在任意时刻只处理一个请求,从头跑到尾,中间不切走。 所以它的并发公式非常简单: - 并发上限 = worker 数 - 吞吐 = worker 数 ÷ 平均响应时间 按这个公式算一下,300 个 worker 在不同响应时间下的理论上限: | 平均响应时间 | 理论 QPS | 说明 | | --- | --- | --- | | 10 ms | 30000 | 实际会被 CPU 和网络先卡住 | | 100 ms | 3000 | 纯 PHP 计算的健康区间 | | 1 s | 300 | 开始出现明显拐点 | | 3 s | 100 | 一次外部 API 调用就能拖到这里 | | 10 s | 30 | 基本等于不可用 | 问题不在于"300 个进程不够多",而在于**这 300 个进程里,绝大部分时间都在等**。等 MySQL 返回、等 curl 握手、等大模型吐出第一个 token。等待期间 CPU 是空闲的,但进程被占着,不能干别的。 新请求来了怎么办?进 `listen.backlog` 队列排队(默认 511)。队列也满了,nginx 就连不上 FPM,直接 502/504。这就是大家熟悉的雪崩:不是算不动,是没人接活。 ## 二、异步真正依赖的三件事 异步 IO 的思路很朴素:**不要用执行单元去等 IO,等的时候把它让出来**。 一次网络请求在异步模型里被拆成三段: 1. 发起请求,注册"数据可读"事件,立刻返回; 2. 事件循环继续处理别的任务; 3. 数据就绪,事件循环把原来的执行现场恢复,继续往下跑。 要让这套流程成立,必须同时具备三个条件: - 有一个事件循环在调度; - 所有 IO 都是非阻塞的; - 能把一段执行现场"挂起"再"恢复"。 第三条就是协程。挂起时需要保存什么?C 栈、寄存器、VM 的当前执行状态。听上去很重,但 PHP 8.1 引入的 Fiber 已经把它做完了,而且成本低得出乎意料。 在一台开发机上实测(ZTS DEBUG 构建,绝对值偏高,只看差值): ```php $n = (int)($argv[1] ?? 0); $fibers = []; for ($i = 0; $i < $n; $i++) { $f = new Fiber(function () { Fiber::suspend(); }); $f->start(); $fibers[] = $f; } preg_match('/VmRSS:\s+(\d+)/', file_get_contents('/proc/self/status'), $m); printf("n=%d VmRSS=%d KB\n", $n, $m[1]); ``` ```text n=0 VmRSS=54040 KB n=1000 VmRSS=66824 KB n=5000 VmRSS=118488 KB ``` 折算下来,一个挂起的空协程大约 **13 KB**。一万个也就 130 MB。 先澄清一件容易搞错的事:**PHP 的 Fiber 和 Swoole 的协程是同一个东西**——都是 stackful coroutine,各自持有一份独立的 C 栈,切换在汇编层完成。13 KB 不是"Fiber 比 Swoole 轻",它就是"协程"这个机制本身的开销,Swoole 协程也在同一个量级。(虚拟地址空间每份会预留得更多,但真正提交的物理页只有实际用到的这些。) 所以"协程很吃内存"这个说法从一开始就问错了方向。协程机制本身是 KB 级的,真正决定内存的是**协程里装了什么**。而要回答这个问题,得先量一下:一个 PHP-FPM 请求本身占多少内存。那才是这份内存的真正来源。 ## 三、为什么事件循环塞不进 FPM 的 worker 最直觉的方案是:让每个 worker 里跑一个 epoll 循环,收到请求就 spawn 一个协程。 先看 FPM 的 worker 主循环长什么样(示意): ```c while (fcgi_accept_request(request) >= 0) { php_request_startup(); php_execute_script(&file_handle); // 请求从头跑到尾,占满当前 C 栈 php_request_shutdown(); fcgi_finish_request(request); } ``` `php_execute_script()` 返回之前,这个进程的控制流完全属于当前请求。事件循环想运行,只有两个位置: - 在 `fcgi_accept_request()` 之后、`php_execute_script()` 之前跑——但那时候请求还没开始,等于没跑; - 在 `php_execute_script()` 返回之后跑——但那时候请求已经处理完了。 **要在一个请求执行到一半时切去处理另一个请求,唯一的办法就是把前一个请求挂起。** 而"把执行到一半的请求挂起",定义上就是协程。 所以"给 FPM 加异步"和"给 FPM 加协程"其实是同一句话。你不可能只加一半:要么放弃"一个进程一次一个请求",要么异步根本不发生。 那能不能把 worker 改成"accept 之后不阻塞地处理"?能,但那一刻你已经不在 FPM 的模型里了——你写的是一个常驻的协程服务器,只是借用了 FPM 的进程管理代码。这正是 Swoole 走的路,而不是 FPM 的补丁。 ## 四、第一道墙:只有 PHP 代码能让出 CPU 就算有了事件循环,PHP 还有一个更硬的问题:**Fiber 只能在 PHP 用户态代码里挂起。** ```php $f = new Fiber(function () { // 这一行执行期间,整个进程卡在 read() 上 $data = file_get_contents('https://example.com/slow-api'); Fiber::suspend($data); }); ``` `Fiber::suspend()` 只有在 PHP 代码执行到它时才有机会被调用。`file_get_contents()` 是 C 实现的内部函数,它执行期间线程就在系统调用里睡觉,调度器连一行代码都跑不了。 换句话说:**协程给了你"可以挂起"的能力,但没有给你"挂起正在阻塞的 C 调用"的能力。** 后者必须由被调用方配合——把阻塞改写成"注册事件 + 挂起当前协程 + 数据就绪后恢复"。 这就意味着需要重写的不只是 SAPI,而是整条 IO 链路: | 类别 | 现状 | 异步需要 | | --- | --- | --- | | 文件/网络流 | `fopen` / `fread` / `stream_select` 同步 | 非阻塞 fd + 事件驱动 | | HTTP 客户端 | `curl_exec` 是同步 easy handle | `curl_multi` + 事件循环 | | 数据库 | mysqlnd / pgsql / sqlite3 同步协议 | 协议层重写成可中断状态机 | | 缓存 | Redis / Memcached 扩展同步收发 | 同上 | | 其他 | `sleep` / `flock` / `gethostbyname` / `proc_open` | 全部要提供协程版本 | Swoole 的做法是在运行时把一部分内部函数的 handler 和 stream 的 ops 替换掉,让用户态的阻塞调用被劫持到协程实现上。这个方案能工作,但边界非常清晰:**没有被 hook 覆盖的路径,照样阻塞整个进程**。一个扩展在 C 层直接调用 `php_stream_read()`,或者自己维护 socket,就绕过了全部 hook。 这也是为什么 Swoole 除了 hook,还要额外提供一整套协程客户端(MySQL、Redis、HTTP、PostgreSQL……)。不是重复造轮子,而是在 C 层没有别的办法。 ## 五、第二道墙:一个进程只有一份全局状态 FPM 能"一次一个请求"跑得这么安稳,是因为整个 PHP 运行时都建立在"单请求独占进程"的假设上。 这些状态在 FPM 里每个进程只有一份,而且天然安全: - `EG()`:当前执行栈、异常、错误处理、GC 根、输出缓冲; - `SG()`:响应头、状态码、`headers_sent`; - `PG()` / `CG()` / `FG()`:INI 配置、类表、函数表; - 扩展的模块全局 `ZEND_MODULE_GLOBALS`,以及扩展源码里的 C `static` 变量; - 用户态:函数静态变量、类静态属性、常量、单例、`$GLOBALS`。 一旦两个请求在同一进程里并发执行,上面每一项都会互相踩。最直观的例子:两个协程同时调用 `header()`,`SG(sapi_headers)` 只有一份,谁覆盖谁? 再看一个更日常的例子: ```php function counter(): int { static $n = 0; return ++$n; } ``` 在 FPM 下,每个请求调用 `counter()` 都返回 1,因为 `php_request_shutdown()` 会清空函数静态变量表。换成常驻进程,同一个协程先后返回 1、2、3,不同协程之间还共享这个 `$n`。 这不是"实现细节",这是**应用代码依赖的语义**。Laravel 的容器单例、Symfony 的 event dispatcher、各种 `static::$instances` 缓存,全部建立在这个语义上。 解决方案只有两条路: 1. **每个 Context 一份全局**——把所有 `EG/SG/PG` 和扩展模块全局改成按 Context 切换(或干脆 TLS 化)。这就是 ZTS 加 Scope 要做的事,代价是要求所有扩展配合;很多扩展的 C 静态变量从来没考虑过并发。 2. **共享一份,由开发者自己保证安全**——这就是 Swoole 的模型。代价是代码契约变了:静态变量跨请求存活,单例跨请求存活,容器里不能随便塞请求态数据。 顺带回答一个常见的设想:能不能基于 TSRM 做隔离?TSRM 解决的是"多个线程各自的全局变量",它的粒度是线程,而且假设一个执行单元从头跑到尾。协程共享线程,要做到 per-context 就得在每次切换时替换一整套 TSRM 上下文——这比给每个线程一份环境难得多,而且同样不解决"阻塞调用无法挂起"的问题。 社区正在推进的 true-async 方案引入 Scope,方向就是自动化第 1 条。难点从来不在 API 设计,而在于把整个扩展生态的隐式全局状态变成 per-scope——这是以年计的工作量。 ## 六、第三道墙:请求边界与内存 FPM 有一份非常慷慨的契约:**请求结束,一切归零。** - `memory_limit` 按请求计; - `max_execution_time` 按请求计(Unix 下只算 CPU 时间,所以阻塞 IO 不计时); - `register_shutdown_function`、对象析构、`zend_mm` 整块回收; - `pm.max_requests` 定期重启 worker,把泄漏兜住。 这套机制让 PHP 应用可以非常"脏"地写:随便 new、随便缓存、随便泄漏,请求一结束全部归零。Laravel 每次请求重新 bootstrap 一遍框架,能活到今天,靠的就是这个。 协程化之后,"请求结束"这件事没有了。你必须在长生命周期进程里**手工模拟出请求边界**:每个 Context 独立的内存记账、独立的超时、独立的 shutdown 队列、独立的销毁顺序。连错误处理都变复杂——FPM 里 `zend_bailout()` 一 longjmp 直接跳回请求顶层,丢掉整段 C 栈是安全的,因为整个请求本来就要结束了;协程里丢掉一段还在等待的栈,语义要重新定义。 但最现实的问题还是内存。算这笔账的单位不是"协程",而是"一个请求"。 先实测一个真实的 FPM 请求占多少。这是一个 Laravel 13 的空首页,走标准 HTTP Kernel 处理: ```php function rss(): int { preg_match('/VmRSS:\s+(\d+)/', file_get_contents('/proc/self/status'), $m); return (int)$m[1]; } $root = '/path/to/laravel'; require $root . '/vendor/autoload.php'; $rss0 = rss(); $app = require $root . '/bootstrap/app.php'; $kernel = $app->make(Illuminate\Contracts\Http\Kernel::class); $response = $kernel->handle( Illuminate\Http\Request::create('/', 'GET') ); $rss1 = rss(); printf("status=%d\n", $response->getStatusCode()); printf("peak = %.1f MB\n", memory_get_peak_usage(true) / 1048576); printf("rss = %d KB -> %d KB\n", $rss0, $rss1); ``` ```text status=200 peak = 28.0 MB rss = 61668 KB -> 82168 KB ``` 一个没有任何业务逻辑的空首页,请求内峰值 28 MB,工作集增量 20 MB。**这是下限**:它没有 Eloquent 模型、没有鉴权、没有多语言、没有业务数据。真实项目一次请求通常要到 50–100 MB,接口一次查出大量模型时还会更高。(进程启动的那 61 MB 里包含 ZTS DEBUG 构建和 opcache 的开销,发布版会低不少,但它是所有请求共享的。) 这笔内存要拆成两部分看: | 部分 | 内容 | 请求之间是否共享 | | --- | --- | --- | | 进程基线 | PHP 二进制、已加载扩展、opcache 映射、分配器 arena | 共享,一次加载 | | 请求工作集 | 框架 bootstrap、配置、路由、容器、请求数据、视图 | 每请求一份 | FPM 的并发等于 worker 数,也就是说——**FPM 一直在为每个并发请求付这份工作集**。300 个 worker 同时处理 300 个请求,就是 300 份工作集。它之所以可控,是因为这个份数被你写死在了 `pm.max_children` 里。 协程化的变化有两面: - 好的一面:进程基线被共享了。300 个协程只需要一份基线,而不是 300 份。 - 坏的一面:工作集共享不了。协程挂起时,那几十 MB 必须原样留在内存里等它恢复——这正是"挂起"的含义。 所以在**同样的并发数**下,协程其实比 FPM 更省内存。异步的问题不在单份成本,而在于它把"份数"这个乘数放开了: - FPM:300 × 20 MB = 6 GB;按真实项目的 60 MB 算,就是 18 GB。这仍是你规划过的容量; - 异步:10000 × 20 MB = 200 GB;按 60 MB 算,是 600 GB。这是规划不了的。 墙不在"每个请求几十 MB",墙在"几十 MB 乘以一个没有上限的并发数"。 而这里有一个最容易被忽略的不对称:**FPM 天然带背压,异步没有。** FPM 忙不过来时,新连接在 backlog 里排队,或者被 nginx 挡在门外,客户端看到的是"慢"和"超时"。整条链路上每一层都有拒绝的语义,服务是**降级**。 异步之后,accept 循环永远不会阻塞:每个新请求都能成功建连、成功创建 Context、成功开始执行。内存一路涨,直到 OOM Killer 挑 RSS 最大的进程杀——通常就是承载了几百个协程的那个 worker,一次性带走全部 Context。就算没走到 OOM,`memory_limit` 触发时 PHP 也只有一招:`zend_bailout()` 把整个进程带回请求顶层。多协程下根本没有"顶层",一次分配失败就是整个进程一起死。 **异步把"排队"换成了"接受然后死"。** 在容器内存受限的环境里,这比排队糟得多。 ## 七、那 FrankenPHP 和 true-async 呢 这两个常被拿来当"FPM 也能异步"的证据,值得单独说清楚。 **FrankenPHP** 用 Caddy(Go)做协议层,PHP 侧用 ZTS 线程执行。每个请求占用一个 PHP 线程,线程数就是并发上限;Go 的 goroutine 只负责等待结果,并不承载 PHP 执行。所以它本质上是"线程版 FPM":比 FPM 强的地方在于 worker 常驻、不需要 nginx、HTTP/2 和 HTTP/3 支持好;但它不是"一个线程跑一万个协程"。第一道墙(阻塞调用)和第二道墙(全局状态)它都用"一个线程一份"绕过去了,代价是并发仍然被线程数锁死。 **true-async** 把 Fiber 和事件循环做进核心,用 Scope 隔离请求上下文。方向是对的,但要同时解决三件事:所有阻塞扩展的非阻塞化、所有全局状态的 per-scope 化、以及用户代码对"共享还是隔离"的重新认知。任何一件都不是一两个版本能完成的。 **RoadRunner / Swoole 的 worker 模式**:Go 或 C 做 master,PHP 常驻 worker,一个 worker 一次一个请求。并发等于 worker 数,没有变化,省掉的只是 bootstrap 开销。 三种方案,没有一个能同时做到"高并发"和"不改代码"。 ## 八、为什么最后都会回到 Swoole 的模式 想在 PHP 里同时拿到高并发和可控内存,只有一条路:**让每个并发单元里装的东西足够少。** 这就是 Swoole 的协程服务器模式: - 进程常驻,框架只 bootstrap 一次——把框架从"每个请求的工作集"搬进"进程的共享基线"; - 协程只持有请求态数据,一个 Context 的增量从几十 MB 降到几百 KB; - 共享状态由开发者显式管理,而不是靠请求结束自动清零; - IO 走协程客户端,或者被 hook 接管。 **Swoole 能扛住高并发,原因不是"协程比进程轻"——两者本来就是同一个东西。真正的原因是它把框架从每个请求的工作集里拿走了。** 剩下的请求态数据,才是必须为每个并发准备一份的东西,而它的量级完全不同。 它不是在 FPM 上打补丁,它是换了一套模型。而换模型是有代价的: - 函数静态变量、单例、全局变量不再自动重置; - 需要 `reload` 机制来加载新代码; - 一个 fatal error 的影响面从一个请求扩大到整个进程; - 调试和排查方式与 FPM 完全不同。 **前面换来的是内存和并发,后面就是你要付的账。** 这笔交易是等价的,天下没有既不用改代码、又能白拿并发的方案。 ## 九、结语 把三道墙放在一起看: | 墙 | FPM 的做法 | 异步需要什么 | 代价 | | --- | --- | --- | --- | | 控制流 | 一请求一进程,同步阻塞 | 可挂起、可恢复的执行现场 | 每个并发一份栈 + 调度器 | | 状态 | 进程独占,请求结束清零 | 每个 Context 一份全局 | 全部全局 TLS 化,扩展全面配合 | | 生命周期 | 请求边界 = 一切归零 | 长生命周期 + 显式隔离 | 内存记账、超时、清理、背压 | 三堵墙里,只有第一堵能靠 SAPI 层解决。第二堵要改整个扩展生态,第三堵要改应用代码的写法。 所以结论不是"PHP 不能异步",而是:**FPM 不能异步。而当你把它改到能异步的那一天,它已经不是 FPM 了。** PHP 的同步阻塞模型不是缺陷,是它最值钱的设计:进程隔离带来故障隔离和内存隔离,请求边界让应用可以随便写,阻塞本身提供了天然的背压。 异步确实更省内存——它把进程基线共享了。但它省下的只是一份固定的基线,省不掉随并发线性增长的请求工作集;而它换来的高并发,恰恰是在放大后者。 所以它们不是同一个产品的两个开关,是两种运行时哲学。 想清楚这一点,就不会再等一个"官方异步 FPM"了。 ## 技术社区 本文由识沃科技(Swoole 团队)撰写。欢迎添加识沃客服微信,加入技术交流群,与开发者直接交流。 
发布于1周前 · 4 次浏览 · 来自
分享
Rango
每隔一段时间,社区里就会出现同一个期待:PHP 官方什么时候给 FPM 加一个异步 IO,让 PHP 也能像 Swoole 那样,几个进程扛住上万并发? 这个问题的答案不是"官方不想做"。真正的原因是:**FPM 这个形态,从设计的第一天起就装不下协程**。异步不是给 FPM 加一个开关,而是要同时推翻它的进程模型、它的全局状态假设,以及它对"请求边界"的定义。 下面把这件事拆开讲。 ## 一、先把 FPM 的并发算清楚 PHP-FPM 是典型的 Leader-Follower 多进程模型:一个 master 负责管理,N 个 worker 负责干活。每个 worker 在任意时刻只处理一个请求,从头跑到尾,中间不切走。 所以它的并发公式非常简单: - 并发上限 = worker 数 - 吞吐 = worker 数 ÷ 平均响应时间 按这个公式算一下,300 个 worker 在不同响应时间下的理论上限: | 平均响应时间 | 理论 QPS | 说明 | | --- | --- | --- | | 10 ms | 30000 | 实际会被 CPU 和网络先卡住 | | 100 ms | 3000 | 纯 PHP 计算的健康区间 | | 1 s | 300 | 开始出现明显拐点 | | 3 s | 100 | 一次外部 API 调用就能拖到这里 | | 10 s | 30 | 基本等于不可用 | 问题不在于"300 个进程不够多",而在于**这 300 个进程里,绝大部分时间都在等**。等 MySQL 返回、等 curl 握手、等大模型吐出第一个 token。等待期间 CPU 是空闲的,但进程被占着,不能干别的。 新请求来了怎么办?进 `listen.backlog` 队列排队(默认 511)。队列也满了,nginx 就连不上 FPM,直接 502/504。这就是大家熟悉的雪崩:不是算不动,是没人接活。 ## 二、异步真正依赖的三件事 异步 IO 的思路很朴素:**不要用执行单元去等 IO,等的时候把它让出来**。 一次网络请求在异步模型里被拆成三段: 1. 发起请求,注册"数据可读"事件,立刻返回; 2. 事件循环继续处理别的任务; 3. 数据就绪,事件循环把原来的执行现场恢复,继续往下跑。 要让这套流程成立,必须同时具备三个条件: - 有一个事件循环在调度; - 所有 IO 都是非阻塞的; - 能把一段执行现场"挂起"再"恢复"。 第三条就是协程。挂起时需要保存什么?C 栈、寄存器、VM 的当前执行状态。听上去很重,但 PHP 8.1 引入的 Fiber 已经把它做完了,而且成本低得出乎意料。 在一台开发机上实测(ZTS DEBUG 构建,绝对值偏高,只看差值): ```php $n = (int)($argv[1] ?? 0); $fibers = []; for ($i = 0; $i < $n; $i++) { $f = new Fiber(function () { Fiber::suspend(); }); $f->start(); $fibers[] = $f; } preg_match('/VmRSS:\s+(\d+)/', file_get_contents('/proc/self/status'), $m); printf("n=%d VmRSS=%d KB\n", $n, $m[1]); ``` ```text n=0 VmRSS=54040 KB n=1000 VmRSS=66824 KB n=5000 VmRSS=118488 KB ``` 折算下来,一个挂起的空协程大约 **13 KB**。一万个也就 130 MB。 先澄清一件容易搞错的事:**PHP 的 Fiber 和 Swoole 的协程是同一个东西**——都是 stackful coroutine,各自持有一份独立的 C 栈,切换在汇编层完成。13 KB 不是"Fiber 比 Swoole 轻",它就是"协程"这个机制本身的开销,Swoole 协程也在同一个量级。(虚拟地址空间每份会预留得更多,但真正提交的物理页只有实际用到的这些。) 所以"协程很吃内存"这个说法从一开始就问错了方向。协程机制本身是 KB 级的,真正决定内存的是**协程里装了什么**。而要回答这个问题,得先量一下:一个 PHP-FPM 请求本身占多少内存。那才是这份内存的真正来源。 ## 三、为什么事件循环塞不进 FPM 的 worker 最直觉的方案是:让每个 worker 里跑一个 epoll 循环,收到请求就 spawn 一个协程。 先看 FPM 的 worker 主循环长什么样(示意): ```c while (fcgi_accept_request(request) >= 0) { php_request_startup(); php_execute_script(&file_handle); // 请求从头跑到尾,占满当前 C 栈 php_request_shutdown(); fcgi_finish_request(request); } ``` `php_execute_script()` 返回之前,这个进程的控制流完全属于当前请求。事件循环想运行,只有两个位置: - 在 `fcgi_accept_request()` 之后、`php_execute_script()` 之前跑——但那时候请求还没开始,等于没跑; - 在 `php_execute_script()` 返回之后跑——但那时候请求已经处理完了。 **要在一个请求执行到一半时切去处理另一个请求,唯一的办法就是把前一个请求挂起。** 而"把执行到一半的请求挂起",定义上就是协程。 所以"给 FPM 加异步"和"给 FPM 加协程"其实是同一句话。你不可能只加一半:要么放弃"一个进程一次一个请求",要么异步根本不发生。 那能不能把 worker 改成"accept 之后不阻塞地处理"?能,但那一刻你已经不在 FPM 的模型里了——你写的是一个常驻的协程服务器,只是借用了 FPM 的进程管理代码。这正是 Swoole 走的路,而不是 FPM 的补丁。 ## 四、第一道墙:只有 PHP 代码能让出 CPU 就算有了事件循环,PHP 还有一个更硬的问题:**Fiber 只能在 PHP 用户态代码里挂起。** ```php $f = new Fiber(function () { // 这一行执行期间,整个进程卡在 read() 上 $data = file_get_contents('https://example.com/slow-api'); Fiber::suspend($data); }); ``` `Fiber::suspend()` 只有在 PHP 代码执行到它时才有机会被调用。`file_get_contents()` 是 C 实现的内部函数,它执行期间线程就在系统调用里睡觉,调度器连一行代码都跑不了。 换句话说:**协程给了你"可以挂起"的能力,但没有给你"挂起正在阻塞的 C 调用"的能力。** 后者必须由被调用方配合——把阻塞改写成"注册事件 + 挂起当前协程 + 数据就绪后恢复"。 这就意味着需要重写的不只是 SAPI,而是整条 IO 链路: | 类别 | 现状 | 异步需要 | | --- | --- | --- | | 文件/网络流 | `fopen` / `fread` / `stream_select` 同步 | 非阻塞 fd + 事件驱动 | | HTTP 客户端 | `curl_exec` 是同步 easy handle | `curl_multi` + 事件循环 | | 数据库 | mysqlnd / pgsql / sqlite3 同步协议 | 协议层重写成可中断状态机 | | 缓存 | Redis / Memcached 扩展同步收发 | 同上 | | 其他 | `sleep` / `flock` / `gethostbyname` / `proc_open` | 全部要提供协程版本 | Swoole 的做法是在运行时把一部分内部函数的 handler 和 stream 的 ops 替换掉,让用户态的阻塞调用被劫持到协程实现上。这个方案能工作,但边界非常清晰:**没有被 hook 覆盖的路径,照样阻塞整个进程**。一个扩展在 C 层直接调用 `php_stream_read()`,或者自己维护 socket,就绕过了全部 hook。 这也是为什么 Swoole 除了 hook,还要额外提供一整套协程客户端(MySQL、Redis、HTTP、PostgreSQL……)。不是重复造轮子,而是在 C 层没有别的办法。 ## 五、第二道墙:一个进程只有一份全局状态 FPM 能"一次一个请求"跑得这么安稳,是因为整个 PHP 运行时都建立在"单请求独占进程"的假设上。 这些状态在 FPM 里每个进程只有一份,而且天然安全: - `EG()`:当前执行栈、异常、错误处理、GC 根、输出缓冲; - `SG()`:响应头、状态码、`headers_sent`; - `PG()` / `CG()` / `FG()`:INI 配置、类表、函数表; - 扩展的模块全局 `ZEND_MODULE_GLOBALS`,以及扩展源码里的 C `static` 变量; - 用户态:函数静态变量、类静态属性、常量、单例、`$GLOBALS`。 一旦两个请求在同一进程里并发执行,上面每一项都会互相踩。最直观的例子:两个协程同时调用 `header()`,`SG(sapi_headers)` 只有一份,谁覆盖谁? 再看一个更日常的例子: ```php function counter(): int { static $n = 0; return ++$n; } ``` 在 FPM 下,每个请求调用 `counter()` 都返回 1,因为 `php_request_shutdown()` 会清空函数静态变量表。换成常驻进程,同一个协程先后返回 1、2、3,不同协程之间还共享这个 `$n`。 这不是"实现细节",这是**应用代码依赖的语义**。Laravel 的容器单例、Symfony 的 event dispatcher、各种 `static::$instances` 缓存,全部建立在这个语义上。 解决方案只有两条路: 1. **每个 Context 一份全局**——把所有 `EG/SG/PG` 和扩展模块全局改成按 Context 切换(或干脆 TLS 化)。这就是 ZTS 加 Scope 要做的事,代价是要求所有扩展配合;很多扩展的 C 静态变量从来没考虑过并发。 2. **共享一份,由开发者自己保证安全**——这就是 Swoole 的模型。代价是代码契约变了:静态变量跨请求存活,单例跨请求存活,容器里不能随便塞请求态数据。 顺带回答一个常见的设想:能不能基于 TSRM 做隔离?TSRM 解决的是"多个线程各自的全局变量",它的粒度是线程,而且假设一个执行单元从头跑到尾。协程共享线程,要做到 per-context 就得在每次切换时替换一整套 TSRM 上下文——这比给每个线程一份环境难得多,而且同样不解决"阻塞调用无法挂起"的问题。 社区正在推进的 true-async 方案引入 Scope,方向就是自动化第 1 条。难点从来不在 API 设计,而在于把整个扩展生态的隐式全局状态变成 per-scope——这是以年计的工作量。 ## 六、第三道墙:请求边界与内存 FPM 有一份非常慷慨的契约:**请求结束,一切归零。** - `memory_limit` 按请求计; - `max_execution_time` 按请求计(Unix 下只算 CPU 时间,所以阻塞 IO 不计时); - `register_shutdown_function`、对象析构、`zend_mm` 整块回收; - `pm.max_requests` 定期重启 worker,把泄漏兜住。 这套机制让 PHP 应用可以非常"脏"地写:随便 new、随便缓存、随便泄漏,请求一结束全部归零。Laravel 每次请求重新 bootstrap 一遍框架,能活到今天,靠的就是这个。 协程化之后,"请求结束"这件事没有了。你必须在长生命周期进程里**手工模拟出请求边界**:每个 Context 独立的内存记账、独立的超时、独立的 shutdown 队列、独立的销毁顺序。连错误处理都变复杂——FPM 里 `zend_bailout()` 一 longjmp 直接跳回请求顶层,丢掉整段 C 栈是安全的,因为整个请求本来就要结束了;协程里丢掉一段还在等待的栈,语义要重新定义。 但最现实的问题还是内存。算这笔账的单位不是"协程",而是"一个请求"。 先实测一个真实的 FPM 请求占多少。这是一个 Laravel 13 的空首页,走标准 HTTP Kernel 处理: ```php function rss(): int { preg_match('/VmRSS:\s+(\d+)/', file_get_contents('/proc/self/status'), $m); return (int)$m[1]; } $root = '/path/to/laravel'; require $root . '/vendor/autoload.php'; $rss0 = rss(); $app = require $root . '/bootstrap/app.php'; $kernel = $app->make(Illuminate\Contracts\Http\Kernel::class); $response = $kernel->handle( Illuminate\Http\Request::create('/', 'GET') ); $rss1 = rss(); printf("status=%d\n", $response->getStatusCode()); printf("peak = %.1f MB\n", memory_get_peak_usage(true) / 1048576); printf("rss = %d KB -> %d KB\n", $rss0, $rss1); ``` ```text status=200 peak = 28.0 MB rss = 61668 KB -> 82168 KB ``` 一个没有任何业务逻辑的空首页,请求内峰值 28 MB,工作集增量 20 MB。**这是下限**:它没有 Eloquent 模型、没有鉴权、没有多语言、没有业务数据。真实项目一次请求通常要到 50–100 MB,接口一次查出大量模型时还会更高。(进程启动的那 61 MB 里包含 ZTS DEBUG 构建和 opcache 的开销,发布版会低不少,但它是所有请求共享的。) 这笔内存要拆成两部分看: | 部分 | 内容 | 请求之间是否共享 | | --- | --- | --- | | 进程基线 | PHP 二进制、已加载扩展、opcache 映射、分配器 arena | 共享,一次加载 | | 请求工作集 | 框架 bootstrap、配置、路由、容器、请求数据、视图 | 每请求一份 | FPM 的并发等于 worker 数,也就是说——**FPM 一直在为每个并发请求付这份工作集**。300 个 worker 同时处理 300 个请求,就是 300 份工作集。它之所以可控,是因为这个份数被你写死在了 `pm.max_children` 里。 协程化的变化有两面: - 好的一面:进程基线被共享了。300 个协程只需要一份基线,而不是 300 份。 - 坏的一面:工作集共享不了。协程挂起时,那几十 MB 必须原样留在内存里等它恢复——这正是"挂起"的含义。 所以在**同样的并发数**下,协程其实比 FPM 更省内存。异步的问题不在单份成本,而在于它把"份数"这个乘数放开了: - FPM:300 × 20 MB = 6 GB;按真实项目的 60 MB 算,就是 18 GB。这仍是你规划过的容量; - 异步:10000 × 20 MB = 200 GB;按 60 MB 算,是 600 GB。这是规划不了的。 墙不在"每个请求几十 MB",墙在"几十 MB 乘以一个没有上限的并发数"。 而这里有一个最容易被忽略的不对称:**FPM 天然带背压,异步没有。** FPM 忙不过来时,新连接在 backlog 里排队,或者被 nginx 挡在门外,客户端看到的是"慢"和"超时"。整条链路上每一层都有拒绝的语义,服务是**降级**。 异步之后,accept 循环永远不会阻塞:每个新请求都能成功建连、成功创建 Context、成功开始执行。内存一路涨,直到 OOM Killer 挑 RSS 最大的进程杀——通常就是承载了几百个协程的那个 worker,一次性带走全部 Context。就算没走到 OOM,`memory_limit` 触发时 PHP 也只有一招:`zend_bailout()` 把整个进程带回请求顶层。多协程下根本没有"顶层",一次分配失败就是整个进程一起死。 **异步把"排队"换成了"接受然后死"。** 在容器内存受限的环境里,这比排队糟得多。 ## 七、那 FrankenPHP 和 true-async 呢 这两个常被拿来当"FPM 也能异步"的证据,值得单独说清楚。 **FrankenPHP** 用 Caddy(Go)做协议层,PHP 侧用 ZTS 线程执行。每个请求占用一个 PHP 线程,线程数就是并发上限;Go 的 goroutine 只负责等待结果,并不承载 PHP 执行。所以它本质上是"线程版 FPM":比 FPM 强的地方在于 worker 常驻、不需要 nginx、HTTP/2 和 HTTP/3 支持好;但它不是"一个线程跑一万个协程"。第一道墙(阻塞调用)和第二道墙(全局状态)它都用"一个线程一份"绕过去了,代价是并发仍然被线程数锁死。 **true-async** 把 Fiber 和事件循环做进核心,用 Scope 隔离请求上下文。方向是对的,但要同时解决三件事:所有阻塞扩展的非阻塞化、所有全局状态的 per-scope 化、以及用户代码对"共享还是隔离"的重新认知。任何一件都不是一两个版本能完成的。 **RoadRunner / Swoole 的 worker 模式**:Go 或 C 做 master,PHP 常驻 worker,一个 worker 一次一个请求。并发等于 worker 数,没有变化,省掉的只是 bootstrap 开销。 三种方案,没有一个能同时做到"高并发"和"不改代码"。 ## 八、为什么最后都会回到 Swoole 的模式 想在 PHP 里同时拿到高并发和可控内存,只有一条路:**让每个并发单元里装的东西足够少。** 这就是 Swoole 的协程服务器模式: - 进程常驻,框架只 bootstrap 一次——把框架从"每个请求的工作集"搬进"进程的共享基线"; - 协程只持有请求态数据,一个 Context 的增量从几十 MB 降到几百 KB; - 共享状态由开发者显式管理,而不是靠请求结束自动清零; - IO 走协程客户端,或者被 hook 接管。 **Swoole 能扛住高并发,原因不是"协程比进程轻"——两者本来就是同一个东西。真正的原因是它把框架从每个请求的工作集里拿走了。** 剩下的请求态数据,才是必须为每个并发准备一份的东西,而它的量级完全不同。 它不是在 FPM 上打补丁,它是换了一套模型。而换模型是有代价的: - 函数静态变量、单例、全局变量不再自动重置; - 需要 `reload` 机制来加载新代码; - 一个 fatal error 的影响面从一个请求扩大到整个进程; - 调试和排查方式与 FPM 完全不同。 **前面换来的是内存和并发,后面就是你要付的账。** 这笔交易是等价的,天下没有既不用改代码、又能白拿并发的方案。 ## 九、结语 把三道墙放在一起看: | 墙 | FPM 的做法 | 异步需要什么 | 代价 | | --- | --- | --- | --- | | 控制流 | 一请求一进程,同步阻塞 | 可挂起、可恢复的执行现场 | 每个并发一份栈 + 调度器 | | 状态 | 进程独占,请求结束清零 | 每个 Context 一份全局 | 全部全局 TLS 化,扩展全面配合 | | 生命周期 | 请求边界 = 一切归零 | 长生命周期 + 显式隔离 | 内存记账、超时、清理、背压 | 三堵墙里,只有第一堵能靠 SAPI 层解决。第二堵要改整个扩展生态,第三堵要改应用代码的写法。 所以结论不是"PHP 不能异步",而是:**FPM 不能异步。而当你把它改到能异步的那一天,它已经不是 FPM 了。** PHP 的同步阻塞模型不是缺陷,是它最值钱的设计:进程隔离带来故障隔离和内存隔离,请求边界让应用可以随便写,阻塞本身提供了天然的背压。 异步确实更省内存——它把进程基线共享了。但它省下的只是一份固定的基线,省不掉随并发线性增长的请求工作集;而它换来的高并发,恰恰是在放大后者。 所以它们不是同一个产品的两个开关,是两种运行时哲学。 想清楚这一点,就不会再等一个"官方异步 FPM"了。 ## 技术社区 本文由识沃科技(Swoole 团队)撰写。欢迎添加识沃客服微信,加入技术交流群,与开发者直接交流。 
赞
0
收藏
提问
分享
讨论
建议
公告
开发框架
TypePHP
CodeGalaxy
登录
后参与评论
评论
还没有评论!