第一周·周一内容可以照着这个步骤学完一天,并在当天结束时完成一个具备并发能力的 TCP 回声服务器,同时深刻理解 Swoole 的核心——Reactor 事件循环。
今日目标
- 用 Docker 一键搭建 Swoole 5.x 开发环境,无需折腾编译。
- 吃透 Reactor 模型,理解 Swoole 如何用单进程处理海量并发连接。
- 动手编写一个纯 TCP 回声服务器,并观察同步阻塞与协程化的巨大差异。
- 能使用
telnet和多个终端窗口对自己的服务器进行并发交互测试。
一、环境搭建:Docker 与 Swoole 5.x (约 1 小时)
我们使用官方镜像,省去安装 PHP 和 Swoole 的繁琐过程。
1. 准备项目目录
在你的工作目录下新建文件夹,例如swoole-course,后续所有代码都放在这里。
mkdirswoole-course&&cdswoole-course2. 编写docker-compose.yml
这个文件定义了我们的 PHP-Swoole 容器,它会挂载当前目录到容器内的/var/www,方便我们直接在宿主机写代码,容器内运行。
version:"3.8"services:swoole:# 使用官方 Swoole 5.1 + PHP 8.2 镜像image:phpswoole/swoole:5.1-php8.2container_name:swoole-labvolumes:# 把当前目录映射到容器的 /var/www-.:/var/wwwworking_dir:/var/wwwports:# 将容器的 9501 端口映射到宿主机的 9501,用于后续 TCP 连接-"9501:9501"# 让容器持续运行,不至于立刻退出tty:true3. 启动环境
在swoole-course目录下执行:
docker-composeup-d4. 进入容器并验证 Swoole
# 进入容器内的 Bashdocker-composeexecswoolebash# 检查 PHP 版本php-v# 检查 Swoole 扩展及版本php-m|grepswoole php--riswoole当你看到Swoole => enabled和Version => 5.1.x时,环境就 OK 了。退出容器用exit即可,但我们下面的操作都在容器内进行。
二、知识核心:彻底搞懂 Reactor 事件循环 (约 1.5 小时)
类比理解(餐厅服务员):
一个传统 PHP-FPM 餐厅里,每来一个客人(请求),老板就临时雇一个服务员(进程/线程),服务完就解雇。1000 个客人同时来,就得雇 1000 个服务员,系统负担极重。
而 Swoole 的 Reactor 模型,就像一个超级服务员。他手里有一个记录板(事件循环),时刻关注所有客人的桌子(连接)。客人招手(有数据到达)时,他才过去处理一下,然后立刻回来继续看记录板。一个人就能同时服务成百上千个客人。
技术原理:
- 同步阻塞 (Blocking I/O):传统的
socket_read,如果没有数据,整个进程就卡住傻等,什么也做不了。 - I/O 多路复用 (epoll):操作系统提供一个机制,能同时监听成百上千个 socket。Swoole 的事件循环底层就是基于
epoll,它不断询问操作系统:“哪个 socket 有新数据?”,然后只去处理那些有事件的 socket。 - Reactor 模式:
- 事件循环 (EventLoop):一个无限循环,不停地等待事件发生。
- 事件分发:事件发生(新连接、数据到达、连接关闭),循环根据注册好的回调函数去执行相应逻辑。
- 非阻塞:回调函数必须快速执行,绝对不能有
sleep或阻塞的数据库查询,否则这个超级服务员就会被卡在那个客人的桌前,其他所有客人都被晾着了。
这就是今天我们要在代码里亲手验证的道理。
三、实战:纯 TCP 回声服务器与协程初体验 (约 2 小时)
所有代码都在宿主机swoole-course目录下编写,然后在容器内执行。
1. 基础版:单连接阻塞回声服务器
创建文件tcp_echo_blocking.php:
<?php// 创建一个 TCP 服务器,监听 0.0.0.0:9501$server=newSwoole\Server('0.0.0.0',9501);// 配置参数$server->set(['worker_num'=>1,// 为了清晰演示阻塞效果,我们只开一个 Worker 进程]);// 监听连接建立事件$server->on('Connect',function($server,$fd){echo"新客户端接入,连接ID:{$fd}\n";});// 监听数据接收事件$server->on('Receive',function($server,$fd,$reactorId,$data){echo"收到来自连接 [{$fd}] 的数据: ".trim($data)."\n";// 模拟一个耗时业务操作,比如查询数据库(危险操作!)echo"开始处理耗时任务(阻塞5秒)...\n";sleep(5);// 这是同步阻塞的睡眠,整个进程会卡死!// 发送回声$server->send($fd,"服务器回复: ".$data);});// 监听连接关闭事件$server->on('Close',function($server,$fd){echo"连接 [{$fd}] 已关闭\n";});// 启动服务器$server->start();2. 观察基础版的阻塞
在容器内启动服务器:
docker-composeexecswoolebashphp tcp_echo_blocking.php现在打开两个终端窗口,都执行telnet localhost 9501。
- 在窗口 1 输入
hello1并回车。 - 立刻切换到窗口 2,输入
hello2并回车。 - 现象:窗口 1 会在 5 秒后收到回复。而窗口 2 没有任何反应,直到窗口 1 的 5 秒结束后,窗口 2 才开始处理并等待 5 秒。
结论:sleep(5)阻塞了整个 Worker 进程的事件循环,导致它无法响应窗口 2 的连接。这就是 “Reactor 模型必须搭配非阻塞代码” 的原因。
3. 进阶版:协程化的非阻塞回声服务器
Swoole 提供了Swoole\Coroutine\System::sleep()来替代sleep(),它会让出当前协程的控制权,Worker 进程可以立即去处理其他连接的请求。
创建文件tcp_echo_coroutine.php:
<?php$server=newSwoole\Server('0.0.0.0',9501);$server->set(['worker_num'=>1,// Swoole 5 默认开启协程,我们也可以显式开启以确保无误'enable_coroutine'=>true,]);$server->on('Connect',function($server,$fd){echo"新客户端接入,连接ID:{$fd}\n";});$server->on('Receive',function($server,$fd,$reactorId,$data){echo"收到来自连接 [{$fd}] 的数据: ".trim($data)."\n";echo"开始处理耗时任务(协程休眠5秒,非阻塞)...\n";// 使用协程版本的 sleep,让出 CPU,事件循环可处理其他请求Swoole\Coroutine\System::sleep(5);$server->send($fd,"协程服务器回复: ".$data);echo"任务完成,已回复连接 [{$fd}]\n";});$server->on('Close',function($server,$fd){echo"连接 [{$fd}] 已关闭\n";});$server->start();4. 观察非阻塞与并发处理
停止上一个tcp_echo_blocking.php(Ctrl+C),运行新的:
php tcp_echo_coroutine.php同样打开两个终端窗口,都用telnet localhost 9501连接。
- 在窗口 1 输入
hello1并回车。 - 立刻切换到窗口 2,输入
hello2并回车。 - 现象:你会看到两个窗口几乎同时收到了回声,而且都是在 5 秒后。
结论:通过协程,我们让这个单进程的超级服务员同时处理了两个耗时任务,完全没有任何阻塞。这就是 Swoole 高性能的核心秘密。
四、成果测试与方法检验 (约 1 小时)
1. 基础端口连通性测试
服务器运行时,在宿主机或容器内执行:
# 检查 9501 端口是否在监听netstat-tulnp|grep9501# 应该看到一条 LISTEN 状态,进程为 php2. telnet 并发交互测试
这是最直接的测试。分别用几个终端连接,发送不同消息,检验以下几点:
- 每个连接是否能立即收到回声?
- 回复内容是否与发送的完全一致?
- 如果一个连接断开(
Ctrl+],然后输入quit),服务器日志是否打印“已关闭”,且不影响其他连接?
3. 进阶思考与自测
- 任务:修改
tcp_echo_coroutine.php,在收到time命令时,返回当前服务器时间(date('Y-m-d H:i:s'))。 - 任务:尝试把
worker_num改为 2,重启服务器。用 4 个 telnet 连接,观察服务器日志中的进程 ID,看看连接是如何被分配到不同 Worker 进程的。 - 思考:为什么在
tcp_echo_coroutine.php的Receive回调中,即使不加Swoole\Coroutine\System::sleep,直接写sleep(5),它还会阻塞吗?会的,因为原生函数如果不做运行时协程化,就是阻塞的。我们将在后面学习Runtime::enableCoroutine()来一键让原生函数协程化。
今日作业与学习产出
- 绘制流程图:手绘或使用工具画出 Reactor 模型的事件循环流程图,包括
Connect、Receive、Close事件。 - 提交代码:将今天编写的两个
tcp_echo_*.php文件提交到 Git 仓库。 - 学习笔记:用自己的话解释同步阻塞和协程非阻塞的差异,并记录下你观察到的实验现象。