这几天在社区里又被同一个问题顶上来了:「PHP 开发者,需要协程吗?」。说实话,这个问题我前几年也纠结过,那时候 Python 的 asyncio 和 Go 的 goroutine 把「高并发」这个词炒得火热,我一度怀疑自己写 PHP 是不是落后了。后来在真实项目里把队列消费、批量抓取、长连接推送都折腾了一遍,才慢慢摸清一件事:协程不是银弹,它只解决特定层面的问题,而 PHP 的执行模型决定了它大部分时候用不上,但在某些场景下又确实绕不开。
这篇文章我想把这个话题彻底掰开揉碎:先讲 PHP 的执行模型天然缺少什么,再讲哪些信号说明你该考虑协程,然后逐个拆解 Swoole、Workerman、Amp、PHP 8.1 Fiber 这些方案的真实面貌,最后给出我自己的实测数据和落地建议。无论你是刚入行的 PHP 新人,还是想给现有项目引入协程的老手,应该都能从这里找到判断依据。
1. 先从 PHP 的执行模型说起:协程到底在解决什么
1.1 每个 PHP 请求都是一条「直线」
先看看传统 PHP-FPM 跑一个请求的完整流程:Web 服务器(Nginx 或 Apache)把请求转交给一个空闲的 FPM 进程,FPM 进程从入口文件开始从上往下执行,遇到数据库查询就等数据库,遇到 curl 调用就等外部接口,最后输出响应体,进程重置,等待下一个请求。
整个过程是严格同步的、一条直线走到底。每个 PHP-FPM 子进程在同一时刻只能处理一个请求,它的内存和上下文也都是为这一个请求服务的。请求结束了,进程变量清空、内存释放,所有资源回归初始状态。
这种模型有个被很多人忽视的优点:进程短命且无状态。即使某个请求把内存干爆了,挂掉的也只是一个 worker,其他请求毫发无损;即使代码里泄漏了连接资源,请求结束也会被统一回收。你不需要像写 Java 服务那样考虑线程池的泄漏、对象的全局状态等一堆问题。
缺点是显而易见的:这个 worker 在等数据库、等外部接口、等磁盘的时候,CPU 完全闲置。传统 Web 请求通常只有一两个 IO 等待点,用户本来就要等结果,多等几毫秒没什么感觉,所以这个缺点被掩盖了。
1.2 协程不是一个线程,更不是并行
先给协程下一个准确的定义:协程是一个可以被暂停、稍后再恢复的函数执行流。它不像线程那样由操作系统抢占式调度,而是由程序员在代码里主动声明挂起点,让出控制权。协程的好处是切换成本极低,几乎就是在用户态保存和恢复几个寄存器、跳转一下栈指针,开销通常只有微秒级别。
关键在于:协程不是并行,而是并发。一个 CPU 核心同一时刻依然只能在跑一段代码,只是当某个任务在等待 IO 时,CPU 立刻切去执行另一段代码,不会干等着。
我用一个生活化的类比来解释:一个厨师要做红烧肉、清蒸鱼、炒青菜三道菜。传统同步做法是,先把红烧肉切好下锅炖,然后傻站在灶台边等它熟,熟完了再做鱼,这样一顿饭要一小时。会做菜的师傅不会这么等,他在红烧肉上汽的时候去切鱼,鱼上锅之后去洗菜,炒菜的空隙回来给肉翻个面。桌子上的菜同时都在推进,但厨师只有一个。协程就是让这一个厨师学会同时开三个灶。
所以结论先摆在这里:协程解决的是「单个进程/线程在处理多个 IO 等待时,把等待时间利用起来」的问题。它是给 IO 密集型任务用的,不是给 CPU 密集型任务用的。如果你的任务是纯粹的数学计算,或者写一万行文件,那协程帮不了你。
1.3 为什么 FPM 时代一直没觉得缺协程
大多数经典 PHP 项目长这样:用户访问一个页面或接口,PHP 代码从 Redis 读个缓存,或查一次数据库,然后渲染模板或输出 JSON。整条执行链路上只有一个主要的阻塞点,而这个阻塞时间本身就是用户的等待时间。你不可能用一个协程把一个数据库查询给「并发掉」——查询本来就只有一次。
就算遇到少量的多接口聚合场景,比如一个页面要同时调订单接口、用户接口、商品接口,串行耗时是三者之和。在传统的 FPM 模型下,你确实可以在 Swoole 里把这三个请求并发出去,把 900ms 变成 300ms。但对很多业务来说,这个优化没有那么迫在眉睫,尤其是页面本身还有缓存可以兜底的时候。
更现实的情况是,传统 PHP 项目遇到性能瓶颈,第一反应通常是加 FPM worker、加缓存、加服务器。一台机器跑 50 个 FPM 进程,每个进程同时就是一个并发单位,Nginx 往外再横向扩展几台,架构简单得不需要聪明人也能维护。这种「堆机器、堆进程」的模型很笨,但它极其稳定、极好排查问题。
所以说,在传统 Web 场景下协程是「锦上添花」,不是「雪中送炭」。你感觉不到缺它,是因为你的瓶颈根本不在进程内部的 IO 等待。
2. 哪些场景真的会让 PHP 开发者「卡脖子」
2.1 队列消费者:一个进程被阻塞 IO 拖到怀疑人生
我见过最典型的协程刚需场景是 CLI 队列消费。假设你用 RabbitMQ 或 Redis 做消息队列,消费者进程的逻辑是:从队列里取一条消息 → 调用支付网关查单(约 2 秒)→ 发一条通知短信(约 1 秒)→ 更新订单状态写几张表(约 0.5 秒)→ 确认消费。
这个消费者进程是典型的「串行循环」,每条消息从取出到确认大概耗时 3.5 秒,其中 3 秒是在等外部接口和网络返回。单进程一小时只能处理约 1000 条消息。要提升吞吐,最容易想到的是多开几个 worker 进程,比如开 8 个进程,一小时 8000 条。这是最简单的方案,很多人也是这么干的。
但如果你把业务代码改造为协程并发,让同一个进程里同时跑 10 个消息处理协程,一个协程等支付网关的时候,CPU 立刻切到另一个协程去发短信去写表。单进程吞吐直接就能到 9000 条/小时左右,一台 4 核机器不用开太多进程,IO 等待时间就被榨干了。
当然,代价是你得处理并发带来的副作用:数据库连接不能共用、写入同一行数据要防冲突、调用外部接口要注意并发限流。这些在串行模型里不存在的问题,引入协程后会冒出来。
2.2 批量抓取和接口聚合:串行等待的天花板
另一个直接就让你感觉到「疼」的场景是批量抓取。比如要抓取某个平台 50 个商品详情页,每个页面平均响应 300ms。串行循环的时间就是 50 × 300ms = 15 秒,这是物理上限,改代码优化不动。
用协程把并发度控制在 20,50 个请求以 20 个为一批并发发出,总耗时只由最慢的那一批决定,大概 3 倍延迟,也就是 900ms 左右。同样是 50 个请求,时间从 15 秒降到 1 秒以内。
接口聚合也是同理。一个管理后台页面要同时加载 5 个第三方平台的库存数据来对比,串行要累加延迟,并发的话总耗时等于最慢的那个接口。负责这类后台功能的朋友会非常理解这种感觉。
我在这里强调一下:这类场景里协程提升的是单进程的并发能力,前提是上游服务允许你并发打过去。如果对方接口限流,比如每秒钟最多 5 个请求,那你并发度再高也会被限流挡住,需要配合信号量控制并发数。
2.3 长连接和推送服务:FPM 模型的结构性短板
WebSocket 聊天、站内推送、游戏服务端这类长连接服务,FPM 模型基本做不了。原因很简单:FPM 的一个 worker 在处理一个请求期间不能腾出手去处理别的请求。如果有 1000 个客户端保持 WebSocket 连接,你需要 1000 个常驻的 FPM worker,这在现实里根本不可行。
这类服务必须用事件驱动的常驻进程模型:一个进程维护成千上万个连接,某个连接没有消息到达时,进程去做别的事情,等消息来了再回来处理。这个模型天然就和协程是绝配:每个连接对应一个协程,连接没数据时协程挂起,事件循环检测到数据后恢复对应的协程。
Swoole 和 Workerman 能在这个领域站稳脚跟,核心原因就是这个。需要注意的是,长连接场景的难点不只是协程,还包括进程间通信、连接心跳、集群横向扩展、消息广播等,协程只是其中一块拼图。
2.4 用这张表快速判断:你到底需要不需要
我把上面说的内容合并成一张自查表,你可以对照自己手头的项目:
| 信号 | 说明 | 建议 |
|---|---|---|
| CLI 批量任务多,且大量时间花在等待外部接口/网络 | 队列消费、数据同步、抓取脚本 | 认真考虑协程 |
| 单进程处理大量长连接 | WebSocket、TCP 推送、IM | 必须上事件驱动 + 协程 |
| 一个请求要并行访问多个上游服务 | 聚合接口、竞品价格对比 | 可以针对性用协程 |
| 瓶颈集中在数据库慢 SQL 或自身业务逻辑 | 查询本身慢,等协程没意义 | 先优化 SQL、加索引 |
| 项目跑在共享主机,装不了扩展,全是 FPM | 部署受限 | 暂时别碰 |
| 团队没人熟悉事件循环,项目排期又紧 | 交付压力大 | 先堆进程,稳定第一 |
一句话总结:协程解决的是「一个进程在 IO 等待时白白浪费 CPU」的问题,你的项目要是没有这个痛点,就不需要协程。
3. PHP 生态里的协程方案:Swoole、Workerman、Amp 和 Fiber 该怎么看
3.1 Swoole:激进但成熟,C 扩展直接把协程引擎搬进来
Swoole 是 PHP 协程生态里最重型的方案。它是一个 C 扩展,内置了事件循环、协程调度器,还维护了一套协程版本的网络客户端,比如协程 MySQL 客户端、协程 Redis 客户端、协程 HTTP 客户端。最让人惊艳的是它的 Hook 机制:开启SWOOLE_HOOK_ALL之后,很多原生 PHP 函数在协程上下文里会自动变成非阻塞版本,像file_get_contents、sleep、PDO 查询这些,你在协程里照常写同步代码,底层却是异步执行。这一点极大地降低了使用门槛,写起来跟普通 PHP 没区别,这是 Swoole 很难被替代的核心优势。
基于 Swoole 的框架有 Hyperf、Swoft 等,它们把常驻进程、依赖注入、AOP、连接池都做了封装,适合用来构建微服务。Hyperf 的文档和社区都比较活跃,如果你决定走这条路,我建议直接从 Hyperf 上手,而不是裸写 Swoole。
代价也有:Swoole 要求常驻进程,代码里一旦出现全局变量残留,就会跨请求污染数据;内存泄漏在短命进程模型里无所谓,在常驻进程里就是灾难;调试方式也从「一次请求一次日志」变成「进程日志 + 性能分析」。你可以在传统 PHP 和 Swoole 之间无缝切换语言,但思维方式需要调整。
3.2 Workerman:纯 PHP 的 event-loop 老牌方案,5.0 拥抱 Fiber
Workerman 是另一个常驻进程网络服务框架,纯 PHP 实现,不需要编译扩展,历史比 Swoole 更早。它最早是事件循环加多进程模型,写网络服务时会用到回调。Workerman 5.0 之后引入了基于 Fiber 的协程支持,用起来比之前舒服不少。
它的优点是部署简单,PHP 版本够新就行,没有扩展编译的问题;框架本身也比较克制,源码好读。基于 Workerman 的 webman 框架在性能和易用性之间做到了不错的平衡,很多项目用它替代 FPM 跑普通 HTTP 服务。
缺点是纯 PHP 实现的网络层在极高并发下性能不如 C 扩展,但对大多数中小型业务来说差距感受不明显。我个人的经验是,如果你只是需要开一个 TCP 服务或者轻量 HTTP 服务,Workerman 足够,而且踩坑比 Swoole 少。
3.3 Amp / ReactPHP:纯 PHP 异步生态,适合学原理
Amp 和 ReactPHP 是纯 PHP 的异步编程库,没有扩展要求。ReactPHP 很早就提出了基于事件循环和 Promise 的异步模型;Amp 在 3.x 之后把底部换成了 Revolt 事件循环,也支持 Fiber。
这两个库的问题在于生态比较小,成熟的开源组件不如 Swoole 和 Workerman 丰富。而且它们的使用方式更接近 Node.js 的 Promise 风格,跟 PHP 开发者习惯的同步顺序编程差异较大,容易写出嵌套地狱。我自己用过一段 ReactPHP 写简单的聚合接口,能跑,但心智负担不小。
纯 PHP 异步库最大的价值是学习。通过读它们的源码,你能彻底搞懂事件循环、Promise、协程调度是怎么回事,这些知识在理解 Swoole 或 Workerman 内部机制时非常有用。如果目的不是生产而是学习,我很推荐拿 Amp 练手。
3.4 PHP 8.1 自带 Fiber:一个基础原语,不是完整答案
PHP 8.1 发布了 Fiber 类,这是 PHP 官方首次在语言层面提供协程原语。很多人在朋友圈兴奋地转发「PHP 终于有协程了」,但这里必须泼一盆冷水:Fiber 只是提供了一个「挂起-恢复」的执行流原语,它没有事件循环,也不接管任何 IO。
用 Fiber 可以做到让两个任务交错执行:任务 A 跑到一半挂起,任务 B 开始跑,再切换回来。但如果你用file_get_contents去请求一个外部接口,这个函数本身是阻塞的,Fiber 挂不挂起都改变不了它阻塞的事实。真正让网络请求非阻塞的,是底层的非阻塞 socket 和stream_select/epoll事件循环,Fiber 只是让「每个 socket 的处理逻辑」可以写成同步顺序的样子,挂起时把控制权交回事件循环。
换句话说,裸用 Fiber 写一个并发 HTTP 抓取程序,你需要自己维护非阻塞连接、事件循环、超时处理,工作量相当于重新造一个 ReactPHP。所以官方的 Fiber 是框架的基石,而不是开发者的直接工具。
3.5 方案对比表与选型建议
| 方案 | 协程实现 | 部署要求 | 框架生态 | 适合场景 | 学习曲线 |
|---|---|---|---|---|---|
| Swoole | C 扩展 + Hook 原生函数 | 需要编译扩展,常驻进程 | Hyperf、Swoft | 高并发服务、长连接、重 IO 消费 | 陡 |
| Workerman | 纯 PHP 事件循环 + Fiber(5.0) | 无特殊扩展,常驻进程 | webman | Socket 服务、中小型长连接、队列消费 | 中 |
| Amp / ReactPHP | 纯 PHP 事件循环 + Promises / Fiber | 无特殊扩展 | 组件较少 | 异步编程学习、小型服务 | 中 |
| 裸 Fiber | PHP 8.1 原语,仅挂起/恢复 | 无 | 需自建事件循环 | 学习原理、极小规模实验 | 难 |
| 不引入协程 | FPM + 多进程 + 队列 | 无 | 无 | 传统 Web、水平扩展瓶颈不大 | 无 |
给一个直接的选型建议:如果你是为了性能优化,且能接受编译扩展和常驻进程,选 Swoole + Hyperf;如果你只想做一个网络服务、不想引入 C 扩展,选 Workerman 5;如果你是想搞懂原理,用 Amp 或者直接拿 Fiber 写个小实验。不要裸用 Fiber 上生产,这是我希望你能记住的一句话。
4. 上手实测:串行 vs 协程的差距,以及 Fiber 的真实用法
4.1 先跑一个串行基线:20 个请求耗时多少
判断协程值不值得上,第一步一定是先量出当前串行版本的耗时。比如这样一个简单脚本,顺序抓取 20 个接口:
<?php $urls = []; for ($i = 1; $i <= 20; $i++) { $urls[] = "https://api.example.com/items/{$i}"; } $start = microtime(true); foreach ($urls as $url) { $body = file_get_contents($url); echo $url . ' : ' . strlen($body) . " bytes\n"; } echo '串行耗时: ' . round(microtime(true) - $start, 3) . "s\n";假设每个接口平均响应 250ms,串行 20 个就是 5 秒左右。这个数字就是你的基线。所有并发改造后的收益,都要拿它来对比。
4.2 Fiber 的最小示例:挂起、恢复、交错执行
在进入复杂的 IO 并发之前,先用 PHP 8.1 的 Fiber 做一个最简单的实验,把「挂起/恢复」的机制跑明白。下面这段代码创建两个任务,让它们轮流执行:
<?php function makeTask(string $name, int $steps): Fiber { return new Fiber(function () use ($name, $steps) { for ($i = 1; $i <= $steps; $i++) { echo "{$name}: 执行到第 {$i} 步\n"; Fiber::suspend(); // 主动挂起,把控制权交回外层 } return "{$name} 完成"; }); } $taskA = makeTask('任务A', 3); $taskB = makeTask('任务B', 3); while (!$taskA->isTerminated() || !$taskB->isTerminated()) { if (!$taskA->isTerminated()) { $taskA->resume(); } if (!$taskB->isTerminated()) { $taskB->resume(); } } var_dump($taskA->getReturn(), $taskB->getReturn()); echo "主程序结束\n";运行输出:
任务A: 执行到第 1 步 任务B: 执行到第 1 步 任务A: 执行到第 2 步 任务B: 执行到第 2 步 任务A: 执行到第 3 步 任务B: 执行到第 3 步 string(9) "任务A 完成" string(9) "任务B 完成" 主程序结束两个任务在单线程内交错执行,每一步之间的切换都是我们主动调用resume()的结果。这就是协程最底层的机制:你帮我挂起,我恢复你,咱们轮流用这一个 CPU。注意这里没有真正的收益,因为任务之间没有任何阻塞等待,切换本身还带一点点开销。协程的价值从来都在「等待」上,这点必须想清楚。
4.3 关键点:Fiber 不管 IO,事件循环才管 IO
好,现在我们把问题升级:既然 Fiber 能挂起恢复,那能不能用它来实现 20 个并发 HTTP 请求?答案是可以,但你需要自己搭一个事件循环。
原理是这样的:把每个 socket 设置为非阻塞模式,发起请求后不等待数据返回,而是先挂起对应协程;然后主循环用stream_select同时监听所有 socket,哪个 socket 可读了,就去恢复等待它的那个协程。核心调度代码长这样(示意,非完整实现):
public function waitRead($sock, Fiber $fiber): void { $this->readMap[get_resource_id($sock)] = [$sock, $fiber]; } public function tick(): void { if (!$this->readMap && !$this->writeMap) { return; } $read = array_column($this->readMap, 0); $write = array_column($this->writeMap, 0); $except = null; stream_select($read, $write, $except, null); // 阻塞等待事件 foreach ($read as $sock) { $id = get_resource_id($sock); [$_, $fiber] = $this->readMap[$id]; unset($this->readMap[$id]); $fiber->resume(); // 此刻读取这个 socket 必定有数据,不会阻塞 } }对应的协程任务这样写:
$fiber = new Fiber(function () use ($loop, $url) { $fp = stream_socket_client('tcp://api.example.com:80', $errno, $errstr, 30); stream_set_blocking($fp, false); fwrite($fp, "GET /items/1 HTTP/1.1\r\nHost: api.example.com\r\nConnection: close\r\n\r\n"); $body = ''; while (!feof($fp)) { $loop->waitRead($fp, Fiber::this()); // 登记等待 Fiber::suspend(); // 挂起,等可读再恢复 $body .= fread($fp, 8192); } fclose($fp); return $body; });我特意把这两段代码写得极简,目的是让你看清协程和事件循环的分工:事件循环负责告诉你「哪个 socket 能动了」,Fiber 负责把「等这个 socket」的逻辑挂起来、不占 CPU。真正在生产里实现还得处理连接握手、半包、响应头解析、超时这些细节,所以没人会手写,直接上框架。
4.4 生产环境写法:Swoole 的 Co\run + Channel 实现并发抓取
如果你装了 Swoole 扩展,生产环境写同样的事情只需要这么简单。先保证扩展就绪:
pecl install swoole php --ri swoole然后写一个并发抓取脚本:
<?php Co::set(['hook_flags' => SWOOLE_HOOK_ALL]); $urls = []; for ($i = 1; $i <= 20; $i++) { $urls[] = "https://api.example.com/items/{$i}"; } $start = microtime(true); $stats = []; Co\run(function () use ($urls, &$stats) { $chan = new Swoole\Coroutine\Channel(count($urls)); foreach ($urls as $url) { go(function () use ($url, $chan) { $body = file_get_contents($url); // 被 Swoole Hook 接管后是非阻塞的 $chan->push([$url, strlen($body)]); }); } for ($i = 0; $i < count($urls); $i++) { [$url, $len] = $chan->pop(); $stats[$url] = $len; } }); echo '协程并发耗时: ' . round(microtime(true) - $start, 3) . "s\n";这里有一个大多数人会忽略的知识点:file_get_contents在普通 PHP CLI 里是阻塞的,为什么在 Swoole 下就变成协程版了?因为SWOOLE_HOOK_ALL让 Swoole 在协程环境里用自己实现的事件驱动版本替换了底层的流函数。没有 Hook,这个写法会阻塞整个进程,这就是为什么我要在前面花那么大篇幅讲 Fiber 本身不管 IO。
假设网络延迟同样是 250ms,20 个串行请求耗时约 5 秒,Swoole 并发版本大约只需要 1 秒左右,差距就是协程在 IO 等待上榨出来的时间。具体数字当然要看上游实际响应,但这已经足够说明问题了。
5. 三个常见错觉,以及我不建议上协程的情况
5.1 错觉一:用了协程,网站整体变快
这个错觉的迷惑性最强。很多人看到 Swoole 的并发能力就幻想:「我把 FPM 换成 Swoole,网站不就高并发了吗?」如果你只是把单个 FPM 请求里的业务代码包进协程,它的速度不会有任何提升,因为一个请求本来就是一条直线,没有多余等待可以优化。
要让网站因为协程变快,前提是你把整个 Web 服务改成 Swoole/Workerman 常驻进程模式,让一个 worker 进程同时处理多个请求。这时候模型变了、部署方式变了、框架也要换。这是「换运行环境」级别的改造,不是「加一个库」级别的操作。如果你只是想优化现有 FPM 项目,协程帮不上忙,老老实实做缓存、加机器更划算。
5.2 错觉二:协程让代码可以随便写阻塞调用
在 Swoole 里,sleep()会被 Hook 成非阻塞版本,file_get_contents()会被 Hook,PDO 查询也能在连接池配合下协程化。但这些 Hook 不是万能的。第三方 C 扩展的阻塞调用、某些没被 Hook 覆盖的函数、一个复杂的原生 socket 库,都有可能在某一个协程里把整个进程卡死。
后果是严重的:一个协程阻塞住了事件循环,所有协程全部停摆,对外表现为「整个服务卡住」的雪崩式故障。所以在引入协程时,必须清楚地知道:哪些函数在协程环境里是安全的、哪些不是;数据库要用连接池而不是共用一个 PDO;Redis 客户端要用协程版本或走 Swoole 的协程支持。
5.3 错觉三:协程能替代数据库优化和队列扩容
协程提高的是吞吐上限,不是单次任务的延迟。一个查询本身要 1.5 秒,加协程它还是 1.5 秒,只是同一进程可以同时跑更多这样的查询而已。如果数据库连接池只有 10 条连接,你把并发开成 50,多出来的 40 个协程都在排队等连接,性能不会有提升。
同理,如果你的队列消费瓶颈在业务逻辑里的一段 CPU 密集计算,协程并发也帮不了你,反而可能因为多协程共享 CPU 导致互相拖慢。这种情况下合理多开进程、或者优化算法,比上协程靠谱得多。
5.4 什么情况下你应该坚定地说「不需要」
把这条单独列出来,是因为太多人学协程纯粹是因为「技术焦虑」。我建议遇到以下情况直接放弃:项目跑在共享主机上装不了扩展;现有系统瓶颈明确是慢 SQL 且还没做索引优化;团队没有一个人理解事件循环,而项目下个月要上线;现有 FPM + Redis 队列 + 多进程 worker 的扩展方式运行良好,加机器就够用。
要记住,协程引入的是「常驻进程 + 事件驱动」的一套新思维,它在提高单进程吞吐的同时,也把短命进程模型里那些被自动兜底的问题全部暴露出来:内存泄漏要自己扛、全局变量要小心、连接要手动管理。这些成本在小型团队里很容易吞噬掉性能收益。
6. 决定要学/要上协程之后,我的建议路线
6.1 先补事件循环和非阻塞 IO 的心智模型
如果你已经决定要学协程,我建议不要一上来就装 Swoole 写 Demo。先把底层的两块知识补齐:事件循环是什么、非阻塞 IO 和阻塞 IO 的区别是什么。推荐路径是:读 PHP 官方文档里stream_select和stream_set_blocking的用法,写一个「同时监听两个 socket、谁有数据读谁」的小程序;然后读一篇讲 Reactor 模式的文章,理解 Nginx、Redis 单线程为什么能支撑那么高的并发,原理完全相通。
这些基础打牢之后,再去碰 Fiber,你会立刻明白它到底是干嘛的,也知道为什么单纯的 Fiber 不够用。我见过太多人跳过了这步,结果被 Swoole 的各种调度怪问题折磨。
6.2 从小项目切入:聚合接口、抓取任务、队列消费
学协程最好的方式是找一个痛感明确的小项目练手,别拿核心业务祭刀。我个人推荐三个练手方向,按难度排序:
一是写一个多接口聚合脚本。比如输入一批 URL,并发抓取并聚合结果返回,对比串行和并发的耗时差异。这个项目能让你理解并发度控制、超时处理和结果收集,几天就能搞定。
二是把某个定时同步脚本改成协程。脚本要根据 ID 列表拉取远程数据回写本地表,串行要跑半小时,协程后五分钟跑完。项目里需要有数据库连接池的处理,正好逼你去解决真正的生产问题。
三是做一个简单的 WebSocket 服务端,用 Workerman 或 Swoole 实现一个聊天室。这个项目能让你体会长连接 + 多个客户端并发连接的处理模式,跟 HTTP 请求模型完全不同。
每一步都必须做前后对比:记录串行耗时、并发耗时、内存峰值、失败率。没有数据支撑,任何改造都是耍流氓。
6.3 生产化前必须解决的四件事:进程管理、超时、连接池、监控
从练手项目走向生产,有四件事是必须提前规划好的。第一是进程管理,常驻进程必须交给 Supervisor 或 systemd 管理,进程挂了能自动拉起,手动 kill 后能优雅退出。
下面是一个 Supervisor 配置示例:
[program:queue_consumer] command=php /data/app/bin/consumer.php numprocs=4 autostart=true autorestart=true stopasgroup=true killasgroup=true第二是超时控制。每个协程都必须有超时兜底,比如等待数据库连接、等待上游响应、等待 Channel 出队,都要设置最大等待时间。一个协程无限期卡住,实际上占用的不只是一个协程,还有底层的连接资源。
第三是连接池。数据库连接、Redis 连接都不能无节制创建,用统一连接池管理,池大小就是你的并发上限,比直接把协程数开到几千更可控。Swoole 提供了Swoole\Database\PDOPool之类的组件,别绕过它。
第四是监控与日志。常驻进程不像 FPM 那样每个请求都有独立日志,你需要自己记录进程级指标:当前活跃协程数、协程调度延迟、平均处理时长、错误计数。日志里最好带上协程 ID,否则并发时的错误日志根本对不上号。
6.4 最后一点个人体会
我自己在项目里做过一次类似的决定。当时我们维护了二十多个 PHP 服务,大部分是典型 CRUD 接口,跑在 FPM 上一直稳如老狗。真正让我决定引入协程的,是一个数据抓取调度服务和一个推送网关。前者一个进程每小时只能跑几百个任务,大量时间花在等第三方接口;后者需要维持上万个长连接。我把这两个服务分别迁到了 Swoole 和 Workerman 上,抓取吞吐提升了十几倍,推送网关也稳定跑了很久。但剩下的二十个服务,我一个都没动。
我的结论是:协程对 PHP 开发者来说不是必需的技能,但它是一味对症的药。判断要不要吃,你先看自己有没有「进程在 IO 等待上大量浪费」的病。如果你写的代码里有大量 curl、file_get_contents、外部 API 调用,并且这些调用集中在 CLI 的循环任务里,那协程几乎肯定能带来质变;如果你的日常就是写 FPM 接口、查数据库、渲染模板,那优雅地保持现状也是一种专业。
如果你真的决定尝试,建议从我最开始讲的心智模型补起,用一个小抓取项目跨出第一步,改完之后拿串行数据做一次诚实的对比。到那时候,你自己心里就有答案了。