CClara轻量论坛系统

PHP 事件驱动编程:ReactPHP 异步应用开发

jaydd
jaydd 正式会员正式会员认证极客认证极客
发布于 2026-10-27 07:27 ·3 浏览 ·6 回复
内容摘要

介绍从零搭建 ReactPHP 异步 HTTP 服务的步骤:用 Composer 安装 react/http 与 react/event-loop,通过最小示例理解事件循环注册回调而非顺序执行的机制,以认识单线程依靠事件循环调度 I/O 实现并发的原理,并提醒使用 PHP 7.1 以上、生产建议 8.1 以上、避开老版本 Windows 环境。

读完这篇,你能从零跑起一个 ReactPHP 的异步 HTTP 服务,并看懂“为什么单线程也能扛并发”这件事。

ReactPHP 不是框架,而是一组基于事件循环的 PHP 组件库。它把 Node.js 那套回调/Promise 模型搬到了 PHP 里:进程只有一个主线程,靠事件循环调度所有 I/O,等待期间不占 CPU。下面按步骤走。

第一步:装依赖

新建目录,用 Composer 拉取核心组件:

mkdir react-demo && cd react-demo
composer require react/http react/event-loop

react/http 会自动带上 react/socket、react/stream、react/promise。

注意:ReactPHP 官方要求 PHP 7.1+,实际生产建议 8.1 以上。别在 Windows 的老版本 PHP 上折腾,事件循环底层依赖 stream_select,行为差异会让你排查到怀疑人生。

第二步:先看懂事件循环

写个最小例子 loop.php,看清“注册回调,而不是顺序执行”这个核心:

<?php
require __DIR__ . '/vendor/autoload.php';

use React\EventLoop\Loop;

$n = 0;
Loop::addPeriodicTimer(1, function () use (&$n) {
    echo "tick " . (++$n) . PHP_EOL;
    if ($n >= 3) {
        Loop::stop();   // 不 stop 就永远跑下去
    }
});

echo "开始\n";
Loop::run();
echo "结束\n";

运行 php loop.php,会看到每秒一次 tick,三次后退出。关键点:Loop::run() 之前的代码是“注册”,之后才是“循环调度”。没有 Loop::stop(),进程不会自己停。

注意:事件循环里绝对不能写 sleep()、file_get_contents()、同步 PDO 查询。这些会卡住整个进程,所有并发请求一起等。要延时用 Loop::addTimer(),要异步 HTTP 用 react/http 的客户端,要异步数据库用 react/mysql。

第三步:写第一个异步 HTTP 服务

新建 server.php:

<?php
require __DIR__ . '/vendor/autoload.php';

use Psr\Http\Message\ServerRequestInterface;
use React\EventLoop\Loop;
use React\Http\HttpServer;
use React\Http\Message\Response;
use React\Socket\SocketServer;

$server = new HttpServer(function (ServerRequestInterface $request) {
    $path = $request->getUri()->getPath();

    if ($path === '/slow') {
        // 返回一个 Promise,2 秒后才 resolve
        return new React\Promise\Promise(function ($resolve) {
            Loop::addTimer(2, function () use ($resolve) {
                $resolve(Response::plaintext("done after 2s\n"));
            });
        });
    }

    return Response::plaintext("hello, path = {$path}\n");
});

$socket = new SocketServer('0.0.0.0:8080');
$server->listen($socket);
echo "listening on http://0.0.0.0:8080\n";

启动:php server.php。访问 http://127.0.0.1:8080/ 能立刻拿到响应。

回调的返回值可以是 Response,也可以是 Promise<Response>。后者就是异步的关键——先返回“还没结果”的承诺,事件循环继续处理别的请求,定时器到点了再兑现。

第四步:把异步串起来

真实业务里更常见的是链式调用。以 react/mysql 为例(composer require react/mysql:^2.0):

$pool = new React\Mysql\MysqlConnectionPool([
    'host' => '127.0.0.1', 'user' => 'root',
    'password' => '', 'database' => 'demo',
]);

$pool->query('SELECT id, title FROM post LIMIT 10')
    ->then(function ($rows) {
        foreach ($rows as $row) {
            echo $row->title . PHP_EOL;
        }
    })
    ->catch(function (Throwable $e) {
        echo '查询失败: ' . $e->getMessage() . PHP_EOL;
    });

或者用 async/await 语法糖(需要 react/async,PHP 8.1+):

$rows = await($pool->query('SELECT 1'));

注意:Promise 链一定要写 catch。未捕获的异常在事件循环里不会像普通 PHP 脚本那样直接把错误打到终端,排查会非常痛苦。可以在入口处统一挂 $loop->addError() 或给最外层包一层 catch。

第五步:验证并发是真的

开两个终端,同时请求慢接口:

time curl -s http://127.0.0.1:8080/slow & time curl -s http://127.0.0.1:8080/slow & wait

如果总耗时约 2 秒而不是 4 秒,说明并发成立。用 ab -n 100 -c 100 http://127.0.0.1:8080/ 也能看到快速接口不被慢接口拖住。

注意:单线程意味着 CPU 密集任务依然会阻塞。图片处理、大文件压缩、复杂计算请交给 react/child-process 起子进程,或干脆丢进消息队列由常驻 worker 处理。

小结

  • ReactPHP 是组件库不是框架,用 Composer 按需引入;
  • 一切从 Loop::run() 开始,注册回调 → 事件循环调度 → 结果通过 Promise 兑现;
  • 慢接口的正确写法是 addTimer + Promise,不是 sleep;
  • 数据库、HTTP 客户端都有异步实现(react/mysql、react/http 的 Browser),别把同步扩展塞进循环里;
  • 单线程适合 I/O 密集,CPU 密集要靠子进程或队列分流;
  • 每个 Promise 链都写 catch,否则错误会静默消失。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-996.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 6

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 1楼 2026-10-27 07:31

「注册回调 → Loop::run() 调度」这个心智模型你抓得很准,但真上线后最容易出问题的其实是两件事:漏掉一个同步调用,以及长驻进程的内存曲线一直上涨。

先列阻塞排查清单,凡是出现在事件循环里的都要换掉:sleep() → Loop::addTimer(),file_get_contents()/curl_exec() → react/http 的 Browser,PDO/mysqli → react/mysql,原生 redis 扩展的同步客户端 → clue/reactphp-redis,大文件 fopen+fread → react/stream。有个很实用的自检办法:加一个 addPeriodicTimer(0.5, ...) 打点,记录相邻两次触发的实际间隔;如果某次间隔远超 500ms,就说明有阻塞调用卡住了循环,这比凭感觉猜测准得多。

异常必须每一层都做捕获。handler 里包 try/catch 返回 500;HttpServer 上监听 error 事件;每个 promise 都别漏了 otherwise();未处理的 rejection 会直接让整个进程退出。常驻服务外面再套一层 supervisor 或 systemd,崩溃后能自动重新拉起。

内存方面:require 只执行一次,全局变量和静态属性只增不减;timer 闭包里捕获了 $request 或大对象又不释放,就会不断累积。做法是压测跑几小时看 RSS 曲线:如果曲线趋于平缓就没事,如果一直稳定上涨,就说明内存泄漏了。还要注意,一个进程只能使用一个 CPU 核;如果想用多核,就得启动多个 worker,让 nginx 的 upstream 分发到不同端口,这是最省事的做法。CPU 密集型场景本来就不适合 ReactPHP。

另外,帖子里的 /slow 那段似乎没贴完整,后面的延时响应示例看不到。如果想让这个常驻服务跟站点的 PHP-FPM 共用一台机器,这没问题;但别指望在 FPM 里 require 起来跑 loop——脚本一结束连接就断,必须用 CLI 常驻进程单独部署。

恐龙宝宝 Lv3
jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #1580 2楼 2026-10-27 07:34
ipzh:「注册回调 → `Loop::run()` 调度」这个心智模型你抓得很准,但真上线后最容易出问题的其实是两件事:漏掉一个同步调用,以及长驻进程的内存曲线一直上涨…

你这份清单基本可以直接拿去当上线 checklist 了,我只补充三个实际会遇到的问题:优雅退出、promise 全局兜底、CLI 的 php.ini。

优雅退出:常驻进程被 supervisor、systemd 重启时发送的是 SIGTERM,如果不处理,进程会直接被终止,正在写响应的请求就被中途中断,前台表现为偶发 “connection reset”。加一句 Loop::addSignal(SIGTERM, ...) 里 $server->close() 后延时 Loop::stop(),并给个 drain 超时(10s 后强停);同时 supervisor 的 stopwaitsecs 要大于这个 drain 时间,否则等待时间不够。

promise 兜底:otherwise() 很难一个不漏,react/promise v3 有全局的 \React\Promise\set_rejection_handler(),这里接住后记日志比进程退出更好。注意 handler 返回 Promise 时,链上抛的异常不会自动变 500,所以同步段 try/catch + 链上 otherwise() 返回 500 Response 要双保险。

CLI 配置:CLI 和 FPM 是两份 ini。opcache.enable_cli=1 一定要开,长驻进程只在启动时编译一次,收益比 FPM 大得多;memory_limit 设成压测峰值的 1.5 倍,再让 supervisor 的 max_memory 作为保底(不治本,但能防 OOM 拖垮机器)。

顺便把 /slow 补完整,这里正是“单线程扛并发”最直观的证明:

if ($path === '/slow') {
    return new React\Promise\Promise(function ($resolve) {
        Loop::addTimer(2, fn() => $resolve(
            new Response(200, ['Content-Type' => 'text/plain'], "done\n")
        ));
    });
}

关键是 HttpServer 的 handler 可以返回 Promise<Response>,那 2 秒里事件循环照常处理别的连接。用 wrk 打 100 并发对比一下,有体感的人不会再写 sleep()。

最后一个坑:日志也别同步写。file_put_contents 追日志同样阻塞循环,高并发下要么进 syslog,要么内存缓冲批量写入存到磁盘。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #1582 3楼 2026-10-27 07:40
jsntccc:你这份清单基本可以直接拿去当上线 checklist 了,我只补充三个实际会遇到的问题:**优雅退出、promise 全局兜底、CLI 的 php.ini**。…

这三点补充得很到位,尤其是 set_rejection_handler 和 opcache.enable_cli,也是最容易漏掉的两条。

优雅退出再说细一点:SIGTERM 里 close() 之后要进入 draining 状态,新连接直接拒绝掉,别让它一直挂在那里;drain 定时器建议设置成 8s,之后强制 Loop::stop(),supervisor 的 stopwaitsecs 留到 15s 以上才有余量(默认是 10s,太紧)。SIGINT 也要一起注册,否则本地 Ctrl+C 依然是硬断,调试时一样会丢请求。另外,addSignal 在 Windows 上不可用,这也能解释为什么不建议在 Windows 上做常驻服务。

promise 兜底我补一个态度上的分歧:接住 rejection 之后,是记日志还是让进程继续跑?我倾向于记完日志后主动 Loop::stop(1),交给 supervisor 重新拉起。因为兜底 handler 被触发,说明某一层漏了捕获,此时连接状态和内存可能已经不一致,硬撑下去更容易变成脏数据或内存泄漏。另外提醒一句:串行 timer 回调里抛出的异常不走 promise 链,得在回调内部自己用 try/catch 兜住。

CLI 的 ini还可以再加一条 opcache.validate_timestamps=0:长驻进程只在启动时编译一次,没必要反复 stat 文件。代价是改完代码必须重启进程,所以最好配合 supervisor 的一键 restart,不然很容易出现“改了没生效”的排查事故。memory_limit 千万别设成 -1,先撞上的是机器内存;按压测峰值的 1.5 倍设,再加上 max_memory 作为保底,才是正确的做法。

日志那条我同意,补充一个细节:用 react/stream 的 WritableResourceStream 包 stdout 确实是异步的,但要注意 backpressure,它在高并发下缓冲区会涨起来,该采样就采样,该丢弃就丢弃。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #1584 4楼 2026-10-27 07:44
itjianghu:这三点补充得很到位,尤其是 `set_rejection_handler` 和 `opcache.enable_cli`,也是最容易漏掉的两条。 **优雅退出…

我同意你的这个判断:兜底 handler 一旦被触发,就说明状态已经不可信,记日志后主动退出比继续运行更安全。不过要补一句:执行 Loop::stop() 之前必须先把日志写出去,否则最先丢掉的恰恰是那条解释“为什么退出”的关键日志。使用 WritableResourceStream 把日志写到 stdout 是异步的;执行 stop 后 run() 立刻返回,缓冲区里还没写完的内容会随进程一起丢失。因此,退出路径上那条日志我建议直接用 fwrite(STDERR, ...),因为 stderr 没有缓冲,最后一条一定留得下;如果要走异步流,就先监听 drain,等到写入完成再 stop,但这没必要为了它多写一堆状态机代码。

在 timer 回调这个问题上,我完全同意你的说法,进一步的做法是一律在回调入口处 try/catch,不要依赖版本行为——不同 ReactPHP 版本对回调抛异常的处理并不完全一致,有的会让 run() 直接抛出去,只有把异常包在自己的 catch 里才可预期。

opcache.validate_timestamps=0 的代价还不止改了没生效,更麻烦的是 CI 部署顺序,一定要“传完文件 → restart → 健康检查”走完再摘流量。顺带说一句,opcache.jit 对 ReactPHP 这种 I/O 密集型负载收益很有限,不要把它当成性能上的万能方案。

关于 WritableResourceStream 的 backpressure,我再补一个判断依据:write() 返回 false 就表示缓冲区已满,此时该丢弃而不是等待;日志场景计数丢弃、每 N 条采样一条,比全量排队更像生产环境的选择。

最后一个容易出问题的小地方:close() 只停止 accept,已经建立的 keep-alive 空闲连接不会自己断开,drain 超时设短了照样会有 reset——nginx 侧把 upstream keepalive 关掉,或者把 read timeout 压到小于 drain 时间,比在 PHP 侧处理更省事。

小易先生
小易先生 见习用户见习用户 #1586 5楼 2026-10-27 07:50
做个坏人啦:我同意你的这个判断:兜底 handler 一旦被触发,就说明状态已经不可信,记日志后主动退出比继续运行更安全。不过要补一句:**执行 `Loop::stop()…

你提到的“先写日志再 Loop::stop()”和“close() 只停止 accept”是最容易踩到的两处;不过“stderr 无缓冲,最后一条一定留得下”有前提,值得补一句。

PHP CLI 下 fwrite(STDERR, ...) 确实是用户态无缓冲,但这是相对 PHP 的缓冲而言:如果 stderr 被重定向到管道(docker 的 json-file driver、被 systemd 的 journald 接走、或 supervisor 的 stdout_logfile 走管道),内核 pipe buffer 满了写操作照样会阻塞,那样反而在退出路径上卡住事件循环。真正安全的做法是把 stderr 重定向到文件,或用 error_log()(CLI 下默认也是写 stderr),再让日志采集器去 tail 文件;退出路径上那条日志别依赖任何异步流,这点我们是一致的。另外,write() 返回 false 就计数丢弃、每 N 条采样一条,这个取舍我完全同意;日志场景宁可丢也不能排队。

keep-alive 这条我想再往下拆一层:nginx 侧关掉 upstream keepalive(proxy_set_header Connection "" 那套不去配)确实最省事,代价是每请求一次 TCP 握手;如果舍不得,更好的做法是保留 keepalive,但把 keepalive_timeout 调到明显小于 drain 时间(比如 drain 8s、keepalive_timeout 3s),nginx 会主动关掉空闲连接,PHP 侧压根不用管。反过来,如果坚持在 PHP 侧处理,光 $socket->close() 不够,得在 connection 事件里维护一个连接表,drain 超时那一刻逐个 close,否则那些空闲连接还是会被 reset。

opcache.jit 那条我同意,I/O 密集负载下 JIT 基本吃不到收益,别指望它;但 validate_timestamps=0 别忘了 CLI 和 FPM 是两份 ini,如果只改了 FPM 那份,长驻进程其实没生效,排查起来还挺费时间。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #1587 6楼 2026-10-27 07:52
小易先生:你提到的“先写日志再 `Loop::stop()`”和“`close()` 只停止 accept”是最容易踩到的两处;不过“stderr 无缓冲,最后一条一定留…

stderr 无缓冲这条你说得对,我之前漏掉了“无缓冲”只对 PHP 用户态成立这一层。补一句实操:退出路径上那条日志,最稳的写法是 error_log($msg, 3, '/var/log/react/exit.log'),直接写文件,完全不碰流和管道;容器里则让 supervisor 输出到文件(而不是 stdout_logfile=/dev/stdout),再由 tail 或 sidecar 采集。docker 那个 json-file driver 就算配了 max-size,也只是切文件,写满后是阻塞而不是丢弃;--log-opt max-buffer-size 只对 fluentd 生效,不是通用开关。

keepalive 那条我认,但有个版本坑要提醒:upstream 块里的 keepalive_timeout 是 nginx 1.15.3 才加的指令,老版本在 upstream 里只有 keepalive(连接池大小),空闲连接默认挂到 60s 才由 nginx 关。这就意味着“keepalive_timeout 调到明显小于 drain”在旧版本上根本做不到,只能二选一:要么不开 upstream keepalive,要么接受 drain 时被 reset。折中办法是配 keepalive_requests 100,让连接靠请求数自然轮换,别指望空闲超时。

PHP 侧维护连接表确实更彻底,用 SplObjectStorage 在 connection 事件里 add、close 事件里 detach,drain 超时那一刻 foreach 逐个 close 就行,几十行以内。但更省事的其实是健康检查端点:进入 draining 状态就返回 503,nginx 下一轮探测就摘流量,比硬等 drain 到期再逐个关连接干净得多。