PHP Swoole 协程实战:把串行的 IO 等待变成并发(go/Channel/WaitGroup)

#PHPSwoole协程实战:把串行的IO等待变成并发(go/Channel/WaitGroup)我第一次用Swoole协程的时候,犯过一个特别蠢的错。当时我想并发抓三个接口的数据,于是一口气写了三个`curl_exec…

PHP Swoole 协程实战:把串行的 IO 等待变成并发(go/Channel/WaitGroup)

我第一次用 Swoole 协程的时候,犯过一个特别蠢的错。当时我想并发抓三个接口的数据,于是一口气写了三个 curl_exec(),结果跑起来比串行还慢,CPU 还飙到 100%。后来才反应过来:我那根本不是协程,只是把三个同步阻塞调用堆在一起,谁也不让谁,纯纯的"假并发"。

为了搞明白"协程到底怎么用",我前后折腾了快两周。看官方文档看不懂,看别人的 demo 又总觉得"看着能跑但不知道为什么能跑"。直到我把一个真实的串行脚本改造成协程版,亲眼看到耗时从 2.4 秒掉到 0.8 秒,才真正"通了"。这篇我就把我这两周踩过的坑、想通的道理,按能直接上手的方式讲给你,省得你再走我的弯路。

先说结论,Swoole 协程异步到底怎么用?核心就三点:

  1. 先开一个协程环境(容器);
  2. 用go() 把要做 IO 的任务丢进去并发跑;
  3. 剩下的 IO 等待,Swoole 自动帮你非阻塞处理,不用你管回调。

别被"协程"俩字吓到,它比你想的简单,也比你想的容易翻车。下面一条条拆。

先把环境搞对:扩展装错全白搭

我见过太多人代码写得没问题,一跑就报Class 'Swoole\Coroutine' not found,折腾半天才发现扩展没开或者版本不对。地基不牢,后面全白搭。这几步建议你新机器上先验一遍:

  1. 确认扩展已加载:php -m | grep swoole 能看到 swoole 才行。看不到就去 php.ini 加 extension=swoole,Windows 下是 extension=php_swoole.dll,然后重启你的 PHP 服务。
  2. 版本别太老:协程相关 API 从 Swoole 2.x 就有,但 Co\run 这种写法在 4.x 才稳定,建议直接上 4.8+。PHP 侧至少 PHP 7.2,现在都 2026 年了,PHP 8.x 完全没问题,JIT 还能再帮你榨点性能。
  3. 不要和 pthreads 或其他协程库混用:Swoole 的协程是独占事件循环的,你再塞个别的协程方案进来,调度直接打架,表现就是偶发卡死、诡异崩溃。
  4. php --ri swoole看一眼编译选项:确认coroutine 是 enabled。有些精简编译把这块关了,代码写得再对也跑不起来,报错还特别迷惑。

这块属于"不起眼但能卡你一下午"的坑。我现在的习惯是每台新机器部署前必跑php --ri swoole,十秒钟省下后面几小时。

协程环境到底怎么开

这是最容易迷糊的地方。Swoole 不会因为你装了扩展就自动把所有代码变协程,你得先"开个场子"。开场子有两种主流姿势,按你的场景选。

第一种:Co\run()临时开一个。适合命令行脚本、定时任务、一次性并发抓取这种"跑完就退"的场景。它接受一个闭包,闭包里你就能随便go()了:

php
复制
<?php// 用 Co\run() 临时开一个协程容器,脚本跑完就自动退出Co\run(function () { // go() 启动一个子协程,里面可以放心做 IO 等待 go(function () { echo"任务A 开始\n"; Co::sleep(1); // 注意:是 Co::sleep 不是 sleep!否则卡死整个进程 echo"任务A 结束\n"; }); // 再起一个,和上面的任务A"同时"等待,而不是排队 go(function () { echo"任务B 开始\n"; Co::sleep(0.5); echo"任务B 结束\n"; });});

上面这段代码,任务A 睡 1 秒、任务B 睡 0.5 秒。如果你用普通 sleep() 串行写,总耗时是 1.5 秒;但用 Co\run+go(),A 和 B 是同时发起的,总耗时约等于最慢那个,也就是 1 秒。这就是协程最直观的好处——把串行的等待时间,压成了"取最大值"而不是"求和"。

第二种:Swoole\Runtime::enableCoroutine()全局开。 适合常驻的 HTTP 服务,比如用 Swoole 自带的 Server。在 start() 之前调一次,之后一堆原本阻塞的 IO 操作(file_get_contents、PDO、curl、Redis、MySQL)会被 Swoole 自动"协程化",你写起来还是同步那套写法,但底层已经是非阻塞了:

php
复制
<?php// 全局开启"一键协程化",一行顶一堆事Swoole\Runtime::enableCoroutine();$server = new Swoole\Http\Server('0.0.0.0', 9501);$server->on('request', function ($request, $response) { // 下面这些原本会阻塞的调用,开启 Runtime 后自动变非阻塞 $pdo = new PDO('mysql:host=127.0.0.1;dbname=test', 'user', 'pass'); $stmt = $pdo->query('SELECT * FROM users WHERE id = 1'); $user = $stmt->fetch(PDO::FETCH_ASSOC); // 看起来同步,实际不卡进程 $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $cache = $redis->get('user:1'); // 同样非阻塞 $response->end(json_encode([$user, $cache]));});$server->start();

我自己更常用Co\run()写工具脚本,因为心智负担小;但线上服务基本都走Runtime::enableCoroutine()全局开,毕竟不可能给每个请求都Co\run()一层。

为什么协程比串行快?把账算给你看

这个问题不搞明白,你永远是"知其然不知其所以然",出了性能问题也没法排查。

假设你有三个外部接口要调,每个平均耗时 800ms:

  • 串行写法:调完 A 等 800ms,再调 B 等 800ms,再调 C 等 800ms,总共 2400ms。注意这 2400ms 里,CPU 大部分时间在"等网络回来",干等着,啥正事没干。
  • 协程写法:三个请求同时go() 发出去,各自的 IO 等待期间,CPU 切去处理别的协程。等三个响应都回来,总耗时约等于最慢那个,也就是 ~800ms。

快了 3 倍,而且请求越多倍数越夸张。本质上,协程是在一个线程内做用户态调度:当一个协程遇到 IO(网络、磁盘、sleep),它就主动让出 CPU,让同容器的其他协程继续跑;等 IO 好了再被唤醒。整个过程没有线程切换的内核开销,所以比多线程轻得多,单机轻轻松松跑几万协程。

不过我得泼盆冷水:协程只在"IO 密集"场景爽,CPU 密集的计算(比如大循环、加密解密)它救不了你,因为计算时不会主动让出。那种场景老老实实上 Swoole 的 Process或者多进程。

为了让你更直观地感受差距,下面把"串行"和"协程"两种写法摆一起对比一下性能:

php
复制
<?php// 串行写法:三个请求排队,耗时 = 800 + 800 + 800 ≈ 2400ms$t1 = microtime(true);file_get_contents('http://www.baidu.com'); // 阻塞 800msfile_get_contents('http://api.github.com'); // 再阻塞 800msfile_get_contents('http://httpbin.org'); // 再阻塞 800msecho"串行耗时:" . round(microtime(true) - $t1, 3) ."s\n";// 协程写法:三个请求同时发出,耗时 ≈ 最慢那个 ≈ 800ms$t2 = microtime(true);Co\run(function () { $hosts = ['www.baidu.com', 'api.github.com', 'httpbin.org']; foreach ($hosts as $host) { go(function () use ($host) { $client = new Swoole\Coroutine\Http\Client($host, 443, true); $client->get('/'); // 网络等待期间自动让出 CPU $client->close(); }); }});echo"协程耗时:" . round(microtime(true) - $t2, 3) ."s\n";

注意这 2.4 秒里,CPU 大部分时间在"等网络回来",啥正事没干。而协程版把这三段等待重叠在一起,等于你烧水的同时去切菜、择菜,水开了菜也切好了——这就是"并发"和"并行"的区别:协程是单线程内的并发(交替前进),不是多核并行。

协程、多线程、多进程,到底该选谁

很多人一听"并发"就想到开进程开线程,其实这三兄弟解决的问题不一样,别乱用:

  • 多进程:最稳,一个崩了不影响别的,但内存开销大(每个进程一份 PHP 解释器),进程间通信也麻烦。
  • 多线程:共享内存、切换便宜,但 PHP 的多线程基本靠 pthreads 扩展,而且线程安全(ZTS)编出来的 PHP 性能掉一截,生产里很少用。
  • 协程:单进程单线程内做用户态调度,内存占用极低(一个协程就几 KB 栈),切换几乎零成本,缺点是单核、怕 CPU 密集。

所以 Swoole 的经典组合是"多进程 + 每进程内多协程":用 Swoole\Http\Server 起多个 worker 进程扛并发,每个 worker 内部再用协程扛 IO。这样既有进程级的隔离稳,又有协程级的轻量快。我自己的线上服务基本都是这个架构,扛过日活几十万的接口,从来没出过协程相关的幺蛾子。

go() 并发请求实战:Swoole\Coroutine\Http\Client

并发抓数据最常用Swoole\Coroutine\Http\Client,它是协程版的 HTTP 客户端,底层自动非阻塞,写法和同步客户端几乎一样:

php
复制
<?phpuse Swoole\Coroutine\Http\Client;Co\run(function () { $hosts = [ '百度' => 'www.baidu.com', 'GitHub' => 'api.github.com', ]; foreach ($hosts as $name => $host) { // 每个 host 起一个协程并发请求 go(function () use ($name, $host) { // 第三个参数 true 表示走 SSL(https) $client = new Client($host, 443, true); $client->setHeaders(['Host' => $host, 'User-Agent' => 'swoole']); $client->get('/'); // 发请求,遇到网络等待自动让出 CPU echo"{$name} 返回" . strlen($client->body) ." 字节\n"; $client->close(); }); } // 这里要注意:主协程如果不等,脚本会直接退出});

跑这段代码你会看到,百度那条和 GitHub 那条几乎是"同时"打出来的,而不是先等完百度再等 GitHub。这就是并发的实感。

这里有个坑我必须提醒:Swoole\Coroutine\Http\Client 的构造函数第二个参数是端口,第三个才是是否 SSL。新手经常把 api.github.com 直接丢进去又忘了 443 和 true,结果连不上还不知道为啥。另外 setHeaders里最好带上Host和User-Agent,有些站点没 UA 直接返 403,调试时能让你少抓半小时头发。

如果你想把协程并发这套思路和"浏览器里点对点通信"对照着理解,可以看WebRTC DataChannel 实战:不依赖服务器的点对点消息传输(ICE/STUN 与信令),那里是前端侧的异步通道,概念上有点像跨端版的 Channel。

Channel:协程之间怎么传数据

go()把任务并发出去了,但子协程的结果怎么收回来?用全局数组?错,协程里共享全局变量是个大坑(后面讲坑的时候细说)。正解是Swoole\Coroutine\Channel,它是协程安全的队列,生产者和消费者通过它传递数据,没有数据时会自动挂起等待:

php
复制
<?phpCo\run(function () { // 缓冲区大小为 2,超过会背压(push 时挂起等消费) $channel = new Swoole\Coroutine\Channel(2); // 生产者1:抓百度,结果塞进 channel go(function () use ($channel) { $client = new Swoole\Coroutine\Http\Client('www.baidu.com', 443, true); $client->get('/'); $channel->push(['baidu' => strlen($client->body)]); // 塞数据,满了就等 $client->close(); }); // 生产者2:做个计时,结果也塞进 channel go(function () use ($channel) { Co::sleep(0.3); $channel->push(['timer' => '0.3s done']); }); // 消费者:主协程取两次,没数据会自动挂起等生产者 for ($i = 0; $i < 2; $i++) { $data = $channel->pop(); var_dump($data); }});

Channel 的价值在于它把"并发生产者 + 串行消费者"这个模式写得特别干净。你不用自己写锁、不用自己轮询,挂起唤醒全交给 Swoole。缓冲区大小这块我一般设成并发任务数,或者干脆不设(无缓冲,纯同步交接),看你要不要削峰。

Channel 还有两个我自己常用的技巧,顺手说一下:

  • pop()带超时:$channel->pop(1.0) 表示最多等 1 秒,超时返回 false。这能避免生产者挂了、消费者永远傻等的死局。我一般生产环境都加超时,宁可丢一条数据也别把主协程吊死。
  • Channel::select()多路监听:能同时盯着多个 channel,谁先有数据先处理谁,类似 IO 多路复用。我拿它做"竞速"——比如同一个数据从 Redis 和 MySQL 各取一份,哪个先回来用哪个,慢的那个直接丢弃,接口延迟直接砍半。

另外 Symfony 那一票 PHP 8 新特性里,注解驱动的路由其实和 Channel 这套"解耦"思想是相通的——把"做什么"和"什么时候做"分开。感兴趣可以移步 [[PHP 8 Attributes 注解实战:用 #[Route] 取代注释解析(反射读取与路由映射)]],看看属性怎么把配置从字符串注释里解放出来。

WaitGroup:等所有任务都干完再收工

如果场景是"并发发 N 个请求,全部回来后统一汇总",WaitGroup 比手动数 Channel 更直观。它就像军训点名:起一个任务 +1,干完-1,主协程wait()等到归零:

php
复制
<?phpuse Swoole\Coroutine\WaitGroup;Co\run(function () { $wg = new WaitGroup(); $results = []; $tasks = [ '百度' => 'www.baidu.com', 'GitHub' => 'api.github.com', '示例' => 'httpbin.org', ]; foreach ($tasks as $name => $host) { $wg->add(); // 每起一个并发任务,计数器 +1 go(function () use ($name, $host, $wg, &$results) { $client = new Swoole\Coroutine\Http\Client($host, 443, true); $client->get('/'); $results[$name] = strlen($client->body); // 各自写各自的 key,互不冲突 $client->close(); $wg->done(); // 任务结束,计数器 -1 }); } $wg->wait(); // 主协程阻塞在这,等所有 done() 把计数减到 0 var_dump($results); // 三个结果都齐了才往下走});

WaitGroup的关键点是:$wg->add()必须在go()之前或之内调用,且done()一定要调,漏一个done()主协程就永远卡在wait()。我以前就因为在一个分支里忘了done(),脚本跑完不退出,还以为是 Swoole 的 bug,查了半天才发现是自己漏写。建议把 done()放进try/finally,保证异常了也能减计数。

其实这种"多任务并发、等全部完成再汇总"的模式,在浏览器多标签页之间也能玩——比如登录态要在多个 tab 同步,可以看 BroadcastChannel 实战:同源多标签页实时通信(登录态同步与状态广播),那是前端的广播通道,思路和 WaitGroup 的"等大家到齐"异曲同工。

数据库和 Redis 连接的正确姿势

协程环境里连数据库,必须用 Swoole 自带的协程客户端,不能用同步版。具体:

  • MySQL:用Swoole\Coroutine\MySQL,connect之后直接query(),没有回调,写法跟同步一样;
  • Redis:用Swoole\Coroutine\Redis,set/get都是同步写法,底层非阻塞;
  • 连接复用:每个协程尽量用独立连接,或者用连接池。Swoole 5.0+ 支持 Swoole\Coroutine\MySQL\Pool;
  • 千万别共用实例:多个协程共用同一个 MySQL 对象,状态会乱套(连接状态、事务、查询结果互相踩),这是新手第二常见的翻车点。

说白了,协程客户端的"同步写法"是 Swoole 给你的最大温柔——你几乎不用改业务代码风格,只换类名,性能就上去了。但温柔背后有代价:底层那根连接是被调度器接管的,你不能在协程外随便用。

如果你要往数据库里落地一些离线缓存、本地索引之类的数据,前端侧有个更轻量的方案,可以对照着看IndexedDB 前端数据库实战:离线应用与索引查询(增删改查与事务),那是浏览器里的本地库,和 Swoole 的协程 MySQL 在不同层解决"数据存取"问题。

常见坑:这些地方最容易翻车

协程不是魔法,下面这几个坑我基本都踩过,列出来给你排雷:

坑 1:同步函数卡死整个事件循环。sleep()、file_get_contents()(没开 Runtime 时)、exec()这些是会阻塞整个进程的,一旦在协程里调用,所有其他协程全被冻住。正确替换:

  • sleep(1)→Co::sleep(1)
  • file_get_contents($f)→Swoole\Coroutine\FileSystem::readFile($f)
  • exec($cmd)→Co::exec($cmd)

坑 2:全局变量不安全。 协程之间看到的是同一份全局/静态变量,但执行时序是交错的。你在 A 协程改了 static $x,B 协程读到的可能已经是被改过的。正确做法是用 Co::getContext()存每个协程独立的数据,或者通过函数参数、Channel传值。

坑 3:异常不冒泡。协程内的throw不会跑到外层go()外面去,你必须在每个go()块里自己try/catch,否则异常直接被吞,你连错在哪都不知道。

坑 4:调试要打协程 ID。多个协程的日志会混在一起,分不清谁是谁。用Co::getCid() 拿当前协程 ID,打进日志,排查时能救命。

再加两个我常提醒同事的点,都是血泪教训:

  • $_GET/$_POST/$_SERVER在常驻服务里不可信:这些超全局变量是请求级的,协程切换时可能被别的请求覆盖。正确做法是从$request对象取,或者用协程上下文存。曾经我图省事直接读$_GET,结果偶发拿到隔壁请求的参数,查了两天。
  • 别在array_walk/array_map 的闭包里做 IO:这些函数不是协程感知的,闭包里的 IO 不会让出,等于白并发。要并发就老老实实 go()+WaitGroup,别想着走捷径。

下面这段代码把坑 2、坑 3、坑 4 一起演示了:

php
复制
<?phpCo\run(function () { // 错误示范:多个协程共享改同一个全局计数,结果不可预期 $counter = 0; for ($i = 0; $i < 3; $i++) { go(function () use (&$counter) { Co::sleep(0.1); $counter++; // 并发自增,没有隔离,最终值不一定等于 3 }); } // 正确示范:协程上下文 + 异常捕获 + 打 CID go(function () { $cid = Co::getCid(); // 拿协程 ID,日志必备 $ctx = Co::getContext(); // 每个协程独立的数据袋 $ctx['trace_id'] = uniqid(); // 存自己的数据,不污染别人 try { // 协程内异常不会冒泡,必须自己 catch if (random_int(0, 1) === 0) { throw new RuntimeException('模拟一个 IO 错误'); } } catch (Throwable $e) { echo"[cid={$cid}] 出错: {$e->getMessage()}\n"; } });});

写到这我突然想到,如果你玩 PHP 已经玩到协程这一层,那顺手把"直接调 C 函数"也学了其实收益很大——比如要搞点高性能计算,可以看 PHP FFI 实战:在 PHP 里直接调用 C 函数(libc/自定义 so 与内存对齐),FFI 让你绕过扩展直接调 libc,和协程配合能做不少黑科技。

来一个完整的:并发抓取并汇总成报告

光讲散点不够过瘾,我给你串一个能直接跑的完整脚本:开协程容器,并发抓三个站点,用 Channel 收结果,最后统计总字节数。把前面学的全用上:

php
复制
<?phpuse Swoole\Coroutine\Http\Client;use Swoole\Coroutine\Channel;Co\run(function () { $channel = new Channel(10); // 缓冲 10,足够装下所有结果 $hosts = ['www.baidu.com', 'api.github.com', 'httpbin.org']; $start = microtime(true); foreach ($hosts as $host) { go(function () use ($host, $channel) { $client = new Client($host, 443, true); $client->setHeaders(['Host' => $host]); $client->get('/'); $len = strlen($client->body); $channel->push("{$host} => {$len} 字节"); // 丢结果进 channel $client->close(); }); } // 主协程取齐所有结果(数量 = host 数) for ($i = 0; $i < count($hosts); $i++) { echo $channel->pop() ."\n"; } echo"全部完成,总耗时:" . round(microtime(true) - $start, 3) ."s\n";});

这个脚本单次跑可能看不出太大优势,但当你抓的站点从 3 个变成 300 个时,串行要几分钟,协程版还是那一两秒——这就是协程在真实爬虫、批量通知、聚合接口里的威力。我自己的一个"聚合几十个第三方 API"的接口,从串行 12 秒优化到协程版 1.3 秒,老板以为我重构了整个架构,其实就加了 Co\run+go()。这种"改动小、收益大"的优化,才是协程最迷人的地方。

我的一点使用心得

协程这套东西,入门快、精通难。但先泼一盆清醒水:协程不是银弹,有些场景它真的帮不上忙,硬上反而添乱。我总结了几条"别上协程"的判断:

  • 你的瓶颈是 CPU 密集计算(视频转码、大批量加密、复杂算法),协程不会让计算变快,老老实实上多进程或多机分片;
  • 你的请求量本来就极小(一天几百次),串行写法清清楚楚,强行协程只会增加维护成本;
  • 你依赖一堆不支持协程化的同步扩展(比如某些老旧的数据库驱动),又不想改客户端,那协程化会处处踩雷。

把这几条排除掉,剩下的 IO 密集、高并发、要等外部接口的场景,才是协程的主场。别为了"用新技术"而用新技术,这是我踩了无数坑之后最实在的体会。

我自己的经验是:

  • 小脚本用Co\run(),服务用Runtime::enableCoroutine(),别混着来;
  • 并发结果传递,无结构化用WaitGroup汇总,要流式交互用Channel;
  • 所有 IO 客户端全换协程版,一个同步调用就能拖垮整池;
  • 日志必带getCid(),否则出事你连哪条协程崩的都看不出;
  • done()进finally,WaitGroup 漏一个就卡死。

还有个实操建议:上线前用Co::stats()或Swoole\Coroutine::stats()看一眼当前协程数,如果数字一直涨不下来,八成是哪个协程没正常退出(典型就是漏了done() 或 Channel 没 pop 完)。我每次压测都会盯这个指标,比看 CPU 还准。

Swoole 协程真正的价值,不是让你代码写得多炫,而是把"等待"这件最浪费时间的事,变得几乎不花钱。你原来串行要 2.4 秒的三次请求,协程一包变成 0.8 秒,代码量没多几行,性能直接翻三倍——这种投入产出比,在 PHP 世界里真的少见。

最后啰嗦一句版本:上面这些 API 在 Swoole 4.x 就稳定了,Co\run是Swoole\Coroutine\run的别名,老文档里可能还看到go()要写成Swoole\Coroutine::create(),现在直接go() 就行,别被老教程带偏。另外如果你用的是 Swoole 6.x,短名 go()和Co默认开启,省心;但有些发行版编译时关了短名,那种情况老老实实写全名Swoole\Coroutine\run()和Swoole\Coroutine::create(),别硬刚。

标签:Swoole, 协程, Coroutine, Channel, PHP异步描述:从协程环境开启(Co\run / Runtime::enableCoroutine)、go() 并发请求、Channel 数据传递到 WaitGroup 汇总,手把手讲清 Swoole 协程怎么把串行 IO 等待变成并发,并附可直接复制运行的 PHP 代码与常见配置坑,帮你把多次网络请求从"求和耗时"压成"取最慢值"。

1q5ojbryoo1q5ojbryoo
评论

评论列表共有0条评论

发表评论发布评论后审核成功,即可显示在列表中...

文章2
粉丝0
获赞0
私信