今天是第一周周三我们将深入 Swoole 协程最核心的工具箱Channel通道、WaitGroup等待组、协程上下文Context和 defer延迟执行。这些是编写健壮、高性能协程应用的基石也是 Hyperf 框架内部大量使用的机制。学完今天你将能够自信地构建复杂的协程工作流并写出第一个协程安全的单元测试。今日目标掌握Swoole\Coroutine\Channel的使用实现线程间安全的生产者-消费者模式。使用Swoole\Coroutine\WaitGroup协调多个并发协程等待其全部完成。理解协程上下文Context的作用域并能在 HTTP 请求中安全传递请求 ID。学会使用defer进行资源清理确保文件句柄、锁等被正确释放。编写 PHPUnit 协程测试验证并发环境下的数据一致性。一、环境准备与工具安装 (约 30 分钟)进入我们的一贯工作环境并准备好 PHPUnit 以便进行单元测试。1. 进入容器并创建今日目录cdswoole-coursedocker-composeexecswoolebashmkdir-pday3cdday32. 安装 PHPUnit在容器内由于 Swoole 官方镜像不包含 PHPUnit我们手动安装它。# 下载 phpunit.phar 到当前目录wget-Ophpunit https://phar.phpunit.de/phpunit-10.pharchmodx phpunit# 移动到可执行路径或者直接使用相对路径# 建议直接在当前目录使用 ./phpunit验证安装./phpunit--version现在我们就有了测试利器。二、知识核心协程工具箱深度解析 (约 1.5 小时)1. Channel通道—— 协程间的安全桥梁类比Channel 像一个线程安全的管道或队列生产者把数据 push 进去消费者 pop 出来。它完美解决了共享内存的加锁问题。特性容量 (capacity)若指定容量通道满时push会挂起当前协程直到有空间通道空时pop也会挂起直到有数据。天然的流量控制。无需锁底层基于原子操作和协程调度保证线程安全。可用于通信不仅仅传递数据还可传递信号如关闭信号。常用方法$channewSwoole\Coroutine\Channel(10);// 容量为 10 的通道$chan-push($data);// 写入数据$data$chan-pop();// 读取数据若空则协程挂起$chan-close();// 关闭通道所有 pop 会立刻返回 false2. WaitGroup —— 优雅地等待一群协程类比就像餐厅里服务员需要等厨房里所有菜都做好才一起上桌。WaitGroup就是那个计数器。基本用法$wgnewSwoole\Coroutine\WaitGroup();$wg-add();// 计数器 1go(function()use($wg){// 执行任务$wg-done();// 任务完成计数器 -1});$wg-wait();// 等待计数器归零3. 协程上下文 (Context) —— 协程专属的全局变量在每个协程中我们可能需要传递一些数据如请求 ID、用户信息等但又不想弄脏函数参数。Context提供了一个隔离的键值存储每个协程拥有自己独立的上下文子协程可以继承父协程的上下文。Swoole\Coroutine\Context::get($key, $default null)Swoole\Coroutine\Context::set($key, $value)在 Hyperf 中我们常用Context::set/get但其底层原理相同。4. defer —— 延迟执行资源清理利器Swoole\Coroutine::defer(callable $callback)注册一个回调在当前协程结束前一定会执行。即使协程异常退出也会执行。非常适合释放锁、关闭文件、归还连接池等。三、实战构建协程工作流 (约 2.5 小时)实战 1生产者-消费者爬虫模拟Channel 实战我们设计一个场景多个协程生产 URL另几个协程消费这些 URL 并“爬取”模拟耗时 0.5 秒最后汇总结果。这完美展示了 Channel 的流量控制与 WaitGroup 的等待。创建文件producer_consumer.php?phpuseSwoole\Coroutine;useSwoole\Coroutine\Channel;useSwoole\Coroutine\WaitGroup;// 启动时设置一个全局协程化环境Co\run(function(){$startmicrotime(true);$urls[http://site1.com,http://site2.com,http://site3.com,http://site4.com,http://site5.com,http://site6.com,];// 1. 创建通道容量为 2故意设小一点观察流量控制$channewChannel(2);// 2. 用于等待消费者完成$wgnewWaitGroup();// 3. 启动生产者协程Coroutine::create(function()use($chan,$urls){foreach($urlsas$url){echo[生产者] 推送:{$url}\n;$chan-push($url);}// 所有 URL 生产完毕关闭通道以通知消费者退出$chan-close();echo[生产者] 生产完毕通道已关闭\n;});// 4. 启动多个消费者协程 (3个)$results[];// 注意多个协程写同一个数组不安全仅演示用for($i1;$i3;$i){$wg-add();// 计数器1$consumerId$i;Coroutine::create(function()use($chan,$wg,$consumerId,$results){while(true){$url$chan-pop();// 如果通道空且未关闭会挂起if($urlfalse){// 通道已关闭且无数据break;}echo[消费者{$consumerId}] 正在爬取:{$url}\n;// 模拟爬取耗时Coroutine\System::sleep(0.5);$results[]{$url}- 200 OK;echo[消费者{$consumerId}] 完成:{$url}\n;}$wg-done();// 任务完成echo[消费者{$consumerId}] 退出\n;});}// 5. 等待所有消费者完成$wg-wait();// 打印结果echo所有爬取结果\n;var_dump($results);echo总耗时: .(microtime(true)-$start). 秒\n;// 注意并发执行总耗时约 0.5秒 * ceil(6/3) 1秒而非 3秒});运行观察php producer_consumer.php你会看到生产者推送时当通道满2个时push会挂起直到有消费者取走数据。这自动实现了背压。实战 2WaitGroup 与请求上下文传递与 HTTP 结合我们结合周二的 HTTP 服务器实现每个请求生成唯一的 request_id并通过 Context 在整个协程生命周期内传递日志中打印出来。创建文件http_context.php?phpuseSwoole\Http\Server;useSwoole\Http\Request;useSwoole\Http\Response;useSwoole\Coroutine;useSwoole\Coroutine\Context;$servernewServer(0.0.0.0,9501);$server-on(Request,function(Request$req,Response$res){// 1. 为当前请求生成唯一 ID并存入协程上下文$requestIduniqid(req_,true);Context::set(request_id,$requestId);// 2. 记录请求开始日志logMessage(请求开始:{$req-server[request_uri]});// 3. 模拟一个需要调用多个下游服务的复杂操作使用 WaitGroup 协调$wgnewCoroutine\WaitGroup();$userData$orderDatanull;// 子协程1获取用户信息耗时0.2秒$wg-add();Coroutine::create(function()use($wg,$userData){// 子协程会继承父协程的上下文可以拿到 request_idlogMessage(正在获取用户信息...);Coroutine\System::sleep(0.2);$userData[id1,nameSwoole];logMessage(用户信息获取完成);$wg-done();});// 子协程2获取订单信息耗时0.3秒$wg-add();Coroutine::create(function()use($wg,$orderData){logMessage(正在获取订单信息...);Coroutine\System::sleep(0.3);$orderData[order_id1001,amount99.9];logMessage(订单信息获取完成);$wg-done();});// 4. 等待所有子协程完成$wg-wait();// 5. 组装响应$result[code200,request_id$requestId,data[user$userData,order$orderData,]];logMessage(请求处理完成);$res-header(Content-Type,application/json);$res-end(json_encode($result));});functionlogMessage(string$msg){$requestIdContext::get(request_id,unknown);echo[{$requestId}] .$msg.PHP_EOL;}$server-start();启动并测试php http_context.php使用curl http://localhost:9501/发送几个请求你会看到控制台输出中每个请求的日志都带有自己的request_id互不干扰。这就是协程上下文隔离的强大之处。实战 3defer 清理资源与锁释放假设我们有一个模拟的“文件锁”或者“连接资源”需要在使用后安全释放。我们用defer来确保清理。创建文件defer_demo.php?phpuseSwoole\Coroutine;Coroutine\run(function(){$filedemo.txt;// 协程1写入文件并使用 defer 保证关闭go(function()use($file){$fpfopen($file,w);if(!$fp){echo无法打开文件\n;return;}// 注册 defer无论协程如何结束都会执行此回调Coroutine::defer(function()use($fp){echo关闭文件句柄\n;fclose($fp);});fwrite($fp,Hello Swoole\n);Coroutine\System::sleep(1);fwrite($fp,Another line\n);// 即使我们抛出异常defer 也会执行// throw new \Exception(some error);echo正常写入完成\n;});// 协程2模拟获取分布式锁用 defer 释放go(function(){$lockKeyresource_lock;echo尝试获取锁...\n;// 模拟获取锁成功$lockedtrue;Coroutine::defer(function()use($lockKey){echo释放锁:{$lockKey}\n;});if($locked){echo执行业务逻辑...\n;Coroutine\System::sleep(0.5);echo业务完成\n;}// 协程结束defer 自动执行});});运行php defer_demo.php你会发现无论中间发生什么defer 中的释放代码都会在最后被调用。注意defer是绑定在当前协程上的不同协程的 defer 互相独立。四、成果测试编写协程单元测试 (约 1.5 小时)PHPUnit 结合 Swoole 协程环境我们需要使用Swoole\Coroutine\run包裹测试用例或者直接使用 Hyperf 测试框架但那在后面。今天我们先裸写一个测试来验证生产者-消费者数据一致性。1. 配置 PHPUnit新建phpunit.xml在day3目录?xml version1.0 encodingUTF-8?phpunitbootstrapvendor/autoload.phpcolorstruetestsuitestestsuitenameSwooleCoroutinedirectorytests/directory/testsuite/testsuites/phpunit创建tests目录mkdirtests2. 编写生产者-消费者测试创建tests/ChannelTest.php?phpusePHPUnit\Framework\TestCase;useSwoole\Coroutine;useSwoole\Coroutine\Channel;useSwoole\Coroutine\WaitGroup;classChannelTestextendsTestCase{publicfunctiontestProducerConsumerDataConsistency(){// 在协程环境中运行测试Coroutine\run(function(){$totalItems100;$channewChannel(10);$consumed0;$wgnewWaitGroup();// 生产者Coroutine::create(function()use($chan,$totalItems){for($i1;$i$totalItems;$i){$chan-push($i);}$chan-close();});// 消费者2个for($c0;$c2;$c){$wg-add();Coroutine::create(function()use($chan,$wg,$consumed){while(true){$item$chan-pop();if($itemfalse){break;}// 模拟处理$consumed;}$wg-done();});}$wg-wait();// 断言消费总数应等于生产总数$this-assertEquals($totalItems,$consumed,数据丢失或重复);echo测试通过: 生产{$totalItems}消费{$consumed}\n;});}publicfunctiontestMultipleProducersSingleConsumer(){Coroutine\run(function(){$channewChannel(5);$total0;$wgnewWaitGroup();// 3个生产者各生产 50 个for($p0;$p3;$p){$wg-add();Coroutine::create(function()use($chan,$wg){for($i0;$i50;$i){$chan-push(1);}$wg-done();});}// 消费者$consumerWgnewWaitGroup();$consumerWg-add();Coroutine::create(function()use($chan,$consumerWg,$total){while(true){$val$chan-pop();if($valfalse)break;$total;}$consumerWg-done();});// 等待所有生产者完成然后关闭通道$wg-wait();$chan-close();$consumerWg-wait();$this-assertEquals(150,$total);});}}3. 运行测试./phpunit tests/ChannelTest.php如果一切正常你应该看到绿色的通过提示。这就证明了我们的协程操作是线程安全的。进阶可以故意修改代码例如不加锁并发写数组看看测试是否会失败深刻理解 Channel 的重要性。五、今日检验清单知识点检验方法通过标准Channel 通信运行producer_consumer.php所有 URL 被消费通道满时生产者阻塞结果无丢失WaitGroup 协调HTTP 服务器返回包含用户和订单的完整数据请求总耗时约 0.3s并发而非 0.5sContext 隔离同时发送多个 HTTP 请求观察日志每个请求日志带有唯一 request_id不窜乱defer 资源释放运行defer_demo.php即使抛出异常也能看到释放日志“关闭文件句柄”、“释放锁” 必定出现在最后协程单元测试执行 PHPUnit 测试全部绿灯数据一致性断言通过今日作业与学习产出提交代码将producer_consumer.php、http_context.php、defer_demo.php以及tests/目录提交到 Git。学习笔记画一个生产者-消费者的协程时序图标出 Channel push/pop 的阻塞唤醒时机。挑战任务修改生产者-消费者代码实现动态退出当生产者生产完所有 URL 后消费者还能从 Channel 接收到一个特殊的结束信号如字符串EOF再退出循环。对比close()的方式哪种更好挑战任务在 HTTP 上下文传递的基础上添加defer来记录每个请求的结束时间在 defer 中计算耗时并输出。通过今天的课程你已经掌握了构建复杂协程应用的必备工具为明天正式迈入 Hyperf 框架打下了最坚实的基础。