“PHP开发者需要协程吗”,这问题我在不同技术群被问了不下十次,最近又在热搜词里看到它,干脆把自己这几年折腾协程的体会写透。先说结论:你要是只写传统Web业务、CRUD接口、后台管理系统,那协程对你来说是选修课,不学完全不影响吃饭。可一旦你手里压着爬虫采集、长连接网关、物联网接入、批量外部请求这类IO密集场景,还不学协程,那就是拿自己的时间和服务器资源硬扛。这篇东西适合所有想搞明白“我到底要不要碰协程”的PHP工程师,尤其是从PHP-FPM模型转过弯来的人。
这个问题的难点在于,网上聊协程的教程95%都从原理讲起,动不动就贴内核源码、贴调度器实现,普通业务开发者听了只想跑。我不打算那么写,我会先用最直白的方式把“协程到底解决了什么问题”讲清楚,再把Swoole、PHP原生Fiber、ReactPHP这几个主流方案摆在一起对比,然后给你一个判断标准:什么业务适合、什么业务纯粹是给自己找麻烦。最后附上可以直接抄的并发采集示例和我在生产环境踩过的坑。
1. 先搞清楚协程到底解决了什么,别被概念绕晕
1.1 PHP传统模型的瓶颈在哪里
要理解协程的价值,得先看一眼PHP最经典的运行模型。以PHP-FPM为例,每个请求进来,FPM会分配一个Worker进程去处理,这个进程里跑完PHP代码,把结果返回,进程又回到空闲状态等下一个请求。一个进程同时只处理一个请求,进程内是“同步阻塞”的。
这套模型有个隐蔽的问题:大部分时间,你的进程不是在“干活”,而是在“等”。等数据库查询返回,等Redis响应,等外部HTTP接口回包。等待期间,CPU资源近乎闲置,但内存和进程号仍然被占着。100个并发请求进来,你就得开100个Worker进程,每个进程哪怕啥也没算,就握着一个连接等数据库返回,都在吃内存。业务一上来,第一反应是加机器,加完发现CPU其实没跑满,内存和进程数先爆了。
我见过很多团队排查“服务器CPU不高但请求很慢”的案例,最后都落在“阻塞等待拖垮并发”上。PHP里如果你用file_get_contents去请求一个耗时3秒的外部接口,那这一个Worker进程在这3秒里什么都干不了,紧跟它后面的那个请求就只能排队。而在数据库、外部API交互占比极高的业务里,这种“空等”的时间能占到70%以上。协程就是冲着这个“等待时间”去的。
1.2 协程本质上是“拆迁”了等待时间
给你打个比方。传统写法是你在银行柜台办业务,一个窗口只能服务一个人,这个人没办完,后面全排着。协程做的事情等价于一个窗口同时接待多位客户:你把资料递给柜员,柜员受理到需要后台审批的环节,不干等着,转头去处理下一位客户;后台审批结果回来,再接着办你剩下的步骤。每个客户看起来都享受了“连续服务”,但窗口的利用率被拉满了。
技术上的说法叫“用户态调度”,协程的切换不经过操作系统内核,纯粹由程序自己决定什么时候让出、什么时候恢复。PHP 8.1引入的Fiber、Swoole的协程调度器,走的基本都是这条路。对比一下线程,线程切换要陷入内核,几百个线程同时跑会有明显的上下文切换成本;协程切换的成本非常低,你开几千上万个协程去并发请求外部接口,机器依然扛得住。
这里有一个关键认知要拉直:协程解决的是IO密集型并发,不是计算密集型并发。如果你的服务运行在纯CPU计算上,比如大量加密解密、图片处理死循环,协程帮不了你,它只会把你本来就不多的计算时间再分薄一点。所以“要不要用协程”的第一个筛选条件,就是先看看你这个项目的时间都耗在哪里。耗在网络等待上的,协程是灵药;耗在循环算力上的,该上多进程或者加机器。
2. PHP协程生态全景,Swoole、原生Fiber、ReactPHP怎么选
2.1 Swoole:功能最全但门框最高的选择
国内聊PHP协程,Swoole是绕不开的名字。它做的事情比协程大得多,内置了常驻内存的进程模型、异步网络Server、协程客户端,以及一套完整的协程调度器。你用Swoole拓展写出来的服务,不是跑在FPM下的“请求-响应”脚本,而是一个独立运行的长生命周期进程。
Swoole最让我满意的是协程化做得彻底。Swoole4以后提供了协程版的MySQL、Redis、HTTP客户端,还会通过hook把部分阻塞函数自动转成异步。比如你在协程里直接写sleep(),它不会真的阻塞整个进程,而是让当前协程挂起,其他协程继续跑,这个体验非常接近同步编程,但实际效果是异步的。代码写起来是顺序的,跑起来是并发的,排查问题的思路和同步代码差别不大,这对从传统PHP转过来的人来说特别友好。
不过门槛要泼三盆冷水。第一,Swoole是C扩展,部署环境必须装扩展,虚拟主机和部分云平台用不了,你至少得有一台能控制PHP环境的服务器或容器。第二,常驻内存意味着“不能用完即走”,你的代码里如果有静态变量缓存、单例对象不释放、全局变量累积,这些在传统PHP里无伤大雅的东西,在常驻进程里就是内存泄漏的温床。第三,Swoole对PHP版本升级往往滞后,经常出现新版PHP刚出、Swoole暂时没法编译的情况,如果你追新版本比较积极,得有心理准备。
2.2 PHP 8.1原生Fiber:标准库的轻量“挂起/恢复”
PHP 8.1把Fiber加入了标准库,这是一个用户态的轻量协程原语。很多人把它和“PHP官方协程”画等号,这个理解只说对了一半。Fiber本身只提供了“暂停”和“恢复”的能力,它不替你解决IO异步化。你用Fiber包住一个file_get_contents,该阻塞还是照样阻塞,进程不会因为你把它放进Fiber就变异步。
Fiber的正确用法,是配合事件循环或者生成器去实现“任务主动让出”。官方文档里有个经典例子,创建Fiber后在Fiber内部调用Fiber::suspend(),把自己挂起,主逻辑用resume()把它唤醒。这种模式适合需要手动控制执行流程的场景,比如实现一个可控的分页拉取器、构造一个状态机、或者把一个大任务切片执行,避免单次请求超时。
但有句公道话,原生Fiber只是“原料”,不是“解决方案”。你在生产项目里直接用Fiber裸写并发请求,会发现缺的东西太多:没有事件循环、没有网络IO抽象、没有调度策略,写完一个并发HTTP抓取器相当于把半个异步框架重新造一遍。所以我的判断是,Fiber更适合那些已经在用自定义事件循环库的开发者,或者你想深入理解协程调度底层原理的时候。业务项目里,纯Fiber方案目前还不够“上手即用”。
2.3 ReactPHP这类纯PHP方案是协程的“近义词”
ReactPHP严格来说走的不是协程,而是事件驱动和异步回调,但它解决的问题和协程一致,都是提升IO并发,所以我做技术选型时也会把它放进候选池。它最大的优势是纯PHP实现,不需要任何扩展,兼容虚拟主机,部署门槛低。缺点是回调风格写多了容易嵌套,代码读起来绕,配合Promise能缓解,但跟Swoole那种“同步写法异步效果”的体验有明显差距。
顺着这个思路,我把三个方案的核心差异整理成一个表:
| 方案 | 是否常驻内存 | 是否自动异步化IO | 部署要求 | 适合场景 |
|---|---|---|---|---|
| Swoole | 是 | 是,可hook常用阻塞函数 | 需要装C扩展 | 高并发长连接网关、自研网络服务、复杂协程业务 |
| 原生Fiber | 否 | 否,只提供挂起恢复 | PHP 8.1+即可 | 状态机、任务切片、配合事件循环自研调度 |
| ReactPHP/Amp | 否 | 部分,依赖事件循环实现 | 纯PHP,无需扩展 | 轻量异步任务、回调风格可接受的中小型项目 |
真要做选型,我有个实用建议:先看你的部署边界,装不了扩展就老老实实ReactPHP,别硬上Swoole;再看项目规模,协程逻辑复杂、要承载长连接大规模并发,直接Swoole;如果只是想熟悉协程概念,从Fiber入手成本最低。
3. 哪些业务场景才真正需要协程
3.1 四个明显的“协程刚需”特征
什么样的业务值得引入协程?你把当前项目的技术痛点拿出来对一下特征就能判断。第一个特征是单进程内存在大量并发外向请求,典型代表是爬虫采集器,一个任务要抓几百上千个URL,阻塞式循环的话耗时线性增长,协程并发能把总耗时打成一纵列。第二个特征是长连接通信,像IM推送、客服系统、游戏房间消息转发,这类服务要维持大量客户端同时在线并实时收发消息,传统FPM每来一个请求就开进程的模型根本扛不住,必须得有常驻内存+事件驱动的服务端。第三个特征是网关节透,你的服务作为中间层把请求转发给多个后端并聚合结果,比如聚合搜索、组合订单状态查询,每一次透传都是一次等待,用协程并行透传能把响应时间从“最慢那个接口”改善成“所有接口并行结束”。第四个特征是物联网设备接入,设备长时间连接、低频上报数据,跟长连接场景一样依赖事件驱动模型。
我在做聚合搜索类项目时感受最明显。串行调用3个外部搜索接口,每个耗时2秒,总耗时要6秒;换成协程并发调用,总耗时约等于最慢的那个接口,2秒出头就能返回,效果是实打实的。
3.2 传统Web业务不要为了“用协程”而用协程
反过来也必须把话说清楚。你要是做企业内部管理系统、内容网站的增删改查、普通API接口,业务逻辑基本是“查一次数据库,渲染页面,返回”,那坚持用PHP-FPM完全正确,千万不要因为自己写业务写得烦了就拿协程开刀。
理由一,PHP-FPM的一套进程隔离模型本来就简单可靠。进程挂了就重启,请求处理完内存自动释放,数据库连接用完自动回收,你不需要考虑全局状态污染、内存泄漏这些事,开发心智负担极低。强行换成协程常驻进程,等于绕了一大圈给自己凭空增加了进程管理、内存监控、连接池运维的负担。理由二,绝大多数Web业务并发瓶颈出现在数据库层,而不是PHP应用层。你先把慢查询修了、把Redis缓存配上、把Nginx的并发参数调好,收益远大于换个运行模型。我见过不止一个小团队,业务QPS才一两百,非要说FPM并发低,愣是把系统改成Swoole常驻服务,改完以后三天两头内存告警,数据库连接池又不会配,最后灰溜溜回退。理由三,协程调度的复杂度一旦引入,你的排障链路会变长。传统FPM下,一个请求一个进程,出错就是单条线;协程环境下,多个协程交错跑,同一个进程里日志会穿插着多个任务的输出,排查时你必须额外加协程ID标记。
3.3 用成本账来帮你下决心
如果还是拿不准,算一笔实际的成本账。假设一台4核8G的机器部署PHP-FPM,按默认配置跑一个中等复杂的接口,连接MySQL和Redis,吞吐量大约在每秒300到500请求之间,CPU通常只有20%到40%。为什么上不去?不是执行代码慢,而是Worker进程被执行人占着,等网络IO返回时其他请求进不来。把同样的机器换成Swoole协程常驻模式,进程内可以同时跑几千个协程,每个请求虽然还是一个Worker进程,但等待IO时这个进程可以立即处理其他协程,吞吐量提升按倍数算并不夸张。
当然,提升幅度取决于你的业务里“等待耗时”占比多高。如果接口有一半时间花在等待上游响应上,协程化的收益非常可观;如果接口只是查本库一行记录、耗时不到2毫秒,那协程化的收益微乎其微,纯粹是自我感动。我建议动手之前,先在你的真实接口上压测一轮,统计一下当前最大并发和CPU利用率,再决定值不值得改。
4. 实操示例:用Swoole协程重写一个并发采集任务
4.1 从同步阻塞版本说起
我拿一个比较典型的场景举例:需要批量请求某第三方平台的详情接口,输入一批商品ID,每个ID对应一个HTTP请求。同步写法很直白:
$ids = range(1, 50); $results = []; foreach ($ids as $id) { $results[$id] = file_get_contents('https://api.example.com/detail/' . $id); }这段代码的问题一眼可见:一次只发一个请求,未返回之前进程完全空转。50个ID,假设每个请求2秒,串行跑就是100秒。常规优化思路是改curl_multi进行并发请求,能提速不少,但curl_multi的代码写起来逻辑分散,要维护请求句柄、状态机、错误处理,一旦你想加“失败重试”“并发度控制”“超时处理”,代码复杂度会迅速膨胀。
4.2 Swoole协程并发版本
同样的需求换到Swoole协程下,写法几乎同步,你却拥有了完全的并发能力:
<?php use Swoole\Coroutine\Http\Client; Co\run(function () { $ids = range(1, 50); $results = []; $tasks = []; foreach ($ids as $id) { $tasks[$id] = go(function () use ($id, &$results) { $client = new Client('api.example.com', 443, true); $client->set(['timeout' => 5]); $client->get('/detail/' . $id); if ($client->statusCode === 200) { $results[$id] = $client->body; } $client->close(); }); } foreach ($tasks as $id => $task) { $tasks[$id]->join(); } var_dump(count($results)); });这段代码的核心就三步:Co\run启动协程容器,go()把闭包变成协程并发执行,join()等待所有协程完成。每个请求都在自己的协程中独立跑,等待响应时让出调度,50个请求并发执行,总耗时从100秒压缩到约2秒。注意Swoole的Http\Client构造函数,第三个参数传true表示启用SSL,且端口必须和你访问的协议对应,HTTPS是443,HTTP是80。
4.3 并发度控制与连接管理
有一种踩坑很常见:把并发请求数直接拉满,一次性创建2000个协程。HTTP连接数过多不仅会让目标服务器把你限流,还会占用大量内存和文件描述符。健康的做法是控制并发度,我通常用一个简单的“通道”思路:维护一个计数器,超出上限就让后来的协程等待。或者更省事,直接把任务拆成小块,比如每次只放100个ID进协程池,跑完一批再放下一批:
Co\run(function () { $ids = range(1, 200); $batchSize = 20; foreach (array_chunk($ids, $batchSize) as $batch) { $tasks = []; foreach ($batch as $id) { $tasks[] = go(function () use ($id) { // 具体请求逻辑 }); } foreach ($tasks as $task) { $task->join(); } } });另外,协程环境下的连接管理不能沿用传统思路。传统PHP里你每次new PDO连一次数据库,脚本结束就释放了;常驻协程下如果每个请求都新建连接,连接数会异常爆炸。正确姿势是使用连接池,把若干固定连接放入池中,协程需要时借出、用完归还。Swoole内置了Runtime Hook能力,开启PHP函数协程化后,用标准PDO/Redis扩展访问也自动走协程调度,但连接池还是得自己设计或用现成库,这块是整个协程化改造里最需要花心思的部分。
4.4 原生Fiber的最小可用示例
如果只是做概念验证,装不了Swoole的环境可以用Fiber写一个极简“挂起-恢复”流程理解思路:
<?php $fiber = new Fiber(function (): void { $ids = [1, 2, 3, 4, 5]; foreach ($ids as $id) { // 模拟一次耗时IO $result = 'data-' . $id; Fiber::suspend($result); } }); while (!$fiber->isTerminated()) { $value = $fiber->resume(); echo $value . PHP_EOL; }Fiber的流程每次resume进去执行到suspend就退回主逻辑,主逻辑拿到一个结果,然后再次resume进入下一轮。你可以把它理解成一个“可暂停的函数”。但你注意,我这次只是概念演示,真实业务里Fiber要搭配事件循环才能做到IO不阻塞,这也就是前面说的“原料不等于方案”。
5. 协程模式下最容易踩的坑,能帮你省下三天的排查时间
5.1 超时未设置导致协程永久挂起
协程并发最隐蔽的风险之一就是超时配置缺失。常规的curl调用,如果你不设置超时,底层默认可能有很长的等待时间;但协程场景下,无数个协程同时挂在一个永不返回的请求上,整个进程看起来就是“卡死”,日志没有任何报错。尤其做爬虫采集时,目标网站偶尔连接挂起又不断开,协程永远等在那。我的习惯是所有协程内网络请求一律显式设置超时,Swoole的Client用set(['timeout' => 5]),MySQL查询也要设置timeout,宁可超时重试,也不要让协程无限等待。
5.2 全局状态污染,数据悄悄串号
常驻进程里最致命的是全局变量和静态属性。FPM模型下进程处理完请求就回收,静态变量在下次请求时未必保留;但常驻协程进程里,一个静态数组可能被无数个协程共享。我曾遇到过一个问题:协程A把某个订单ID赋值给一个类静态属性,协程B也去赋值,A之后再读这个属性,居然拿到了B的数据。这种问题表现随机、复现困难,排查时要逐个检查静态属性和单例,尽量把业务数据封装在协程函数的局部变量里。
5.3 MySQL和Redis连接池耗尽
协程释放给并发的想象力很大,底层资源却没那么慷慨。我常看到有人用Swoole做高并发接口,数据库用的是每次请求新建连接,结果并发一上来,MySQL瞬间报too many connections。正确做法必须用连接池。如果你不想引入重型框架,至少做一层简单的连接复用:创建固定数量的连接放入数组,用协程的Channel机制进行借出和归还。原理上类似银行窗口,窗口数量固定,客户再多也在外面等,不能无限开窗口把大厅挤爆。
5.4 协程内异常处理不好会拖垮父协程
协程里抛出的异常如果你不及时捕获,处理起来比传统代码更麻烦。在Swoole中,协程异常会向外抛出,如果你在go闭包里没有try/catch包裹,异常可能让整个进程退出。我在生产环境踩过一次:某个第三方接口偶发返回500,PHP解析JSON直接抛异常,由于没捕获,进程崩了,线上服务瞬间不可用。从此我的所有协程任务闭包,第一行都是try/catch,捕获后记录日志并优雅中断该任务,绝不把异常抛出协程边界。
5.5 日志与排障技巧
常驻协程进程的日志排障,最怕所有输出搅在一起。建议从设计的第一天就给每个任务分配一个唯一ID或至少一个协程ID,所有日志都带上这个标记,再配合日志聚合工具按标记切分查看,才看得清单条完整调用链。Swoole自带底层的协程堆栈打印,排查死锁时可以用。另外,逐步推进是血的教训:不要一次性把一个大型项目协程化,先从单条链路改造、对比性能、验证稳定性,再铺开。协程虽好,但换运行模型属于动地基的改造,稳字当头。
我个人这几年用协程改造过不少服务,最大的体会是“测量前置,选型冷静”。协程不是银弹,不匹配场景硬上就是给自己增加运维复杂度;匹配了场景,比如并发外部请求、长连接网关这种,它的收益又是其他方案难以替代的。建议你先拿着本文这个判断标准,去压测一下自己最慢的那个接口,看看等待时间占比,基本就能得出答案。