Multi-worker
(PHP 8.6+, true_async_server 0.6+)
TrueAsync Server 默认运行在单线程模式:一个 event-loop、一个线程,整个流水线 (accept → parse → dispatch → respond)都在同一颗 CPU 上。对典型的 IO 密集型负载这是最快的模型, 但它无法按核数横向扩展。
setWorkers(N) 通过 Async\ThreadPool 启动一个 N 线程的 内置池。每个 worker 在相同的 listener 上重新 bind,内核(Linux/BSD)通过 SO_REUSEPORT 分发 accept。每个 worker 拥有独立的 event-loop、独立的 opcache、独立的连接池。
基础示例
use TrueAsync\HttpServer;
use TrueAsync\HttpServerConfig;
$server = new HttpServer(
(new HttpServerConfig())
->addListener('0.0.0.0', 8080)
->setWorkers(4)
);
$server->addHttpHandler(function ($req, $res) {
$res->json(['pid' => getmypid()]);
});
$server->start(); // 阻塞直到所有 worker 都结束父进程里 HttpServer::start() 做的事:
- 起一个对应大小的
Async\ThreadPool。 - 通过
transfer_obj把 config + 处理程序集合复制到每个 worker。 - 在 worker 里启动 event-loop,重新 bind listener。
- 父进程
await所有 worker 结束。
优雅关停
HttpServer::stop() 在池的父进程上可用。它会让整个 cohort 退役,并挂起直到服务器真正停下 —— 当它返回时,worker 已经排空,池已经拆除,listen 套接字也已关闭。请从协程里调用它; 信号处理器是通常放它的地方:
use function Async\spawn;
use function Async\await;
use function Async\signal;
use Async\Signal;
spawn(function () use ($server) {
await(signal(Signal::SIGTERM));
$server->stop(); // 池真正停下之后才返回
});
$server->start();在独立服务器上(setWorkers(1),默认值),stop() 不会挂起:它通常是从请求处理程序里 调用的,而关停 drain 正是在等这个处理程序 —— 所以那里一个会阻塞的 stop() 就等于在等它自己。
Hot reload
HttpServer::reload() 在不丢弃任何连接的前提下替换 worker cohort:worker 处理完手上的活、 停止并退出,全新的 worker 线程重新执行 bootloader —— 从而拾起改动过的代码 —— 并在同一批 listen 套接字上接管。它会挂起直到旧 cohort 排空;整个过程中 start() 持续运行。仅限池的父进程。
你很少会自己调用它,而是接一个触发器:
$config
->setWorkers(4)
->setBootloader(function () {
require __DIR__ . '/app/bootstrap.php'; // 在每个全新 worker 里重新执行
})
// 开发:监视目录树,在它稳定下来时 reload
->enableHotReload([__DIR__ . '/app'], ['php'], debounceMs: 300, maxHoldMs: 2000)
// 生产:在 SIGHUP 时 reload,这正是部署脚本发送的信号
->enableReloadOnSignal();enableHotReload() 递归监视每个路径。一批稳定下来的改动会让被监视的目录树在 opcache 中失效 并调用 reload()。debounceMs 是一批改动触发一次 reload 之前的静默窗口;maxHoldMs 会在 第一次改动之后最多这么久强制一次 reload,因此一个永不安静的目录也仍然会 reload。 enableReloadOnSignal() 装上一个持久的 SIGHUP 处理器(Windows 不支持)。
两者都仅限池模式。无论用哪种触发器,新 worker 拾起的代码就是 bootloader 加载的代码 —— 所以任何你想让它被 reload 的东西都必须加载在那里,而不是入口脚本的顶部(那里只在父进程里 运行一次,之后不再运行)。
如果你手动调用
reload(),请先让改动过的文件失效(opcache_invalidate())或依赖 opcache 的时间戳校验 —— 否则全新的 worker 会编译旧代码。
Bootloader
worker 的重型初始化(autoload、连接池预热、JIT 预热)应该在启动时做一次,而不是每请求做。 为此提供了 setBootloader(?\Closure $cb):
$config
->setWorkers(4)
->setBootloader(function () {
// 每个 worker 在任务循环之前执行一次
require __DIR__ . '/vendor/autoload.php';
// 预热连接池
Database::initPool(min: 4, max: 16);
// 预编译关键路由
Router::compile();
});闭包会被 deep-copy 一次,并在每个 worker 真正开始接受任务之前运行。 bootloader 中抛出的异常会使整个池失败:该 worker 不会启动。
只在 setWorkers() > 1 时生效。null 取消 bootloader。
需要 TrueAsync ABI v0.15+。测试:
server/core/021-bootloader.phpt。
Per-request scope
从 0.6.5 起,每个 handler 协程都跑在自己的 scope 中,作为服务器 scope 的子 scope。 这带来两条重要语义:
Async\request_context()是整个请求协程树 (handler 与子级spawn)共享的上下文。Async\current_context()仍然是 per-coroutine 的。
use function Async\spawn;
use function Async\await;
use function Async\request_context;
$server->addHttpHandler(function ($req, $res) {
// 整棵请求协程树都能看到的上下文
request_context()->set('request_id', $req->getHeader('X-Request-Id') ?? bin2hex(random_bytes(8)));
request_context()->set('user_id', authUser($req));
// Fan-out
[$user, $posts] = await(\Async\await_all([
spawn(fn() => fetchUser()), // 这里看得到 request_id
spawn(fn() => fetchPosts()), // 这里也看得到
]));
$res->json(['user' => $user, 'posts' => $posts]);
});对比一下:current_context() 写入的值只在当前协程可见; request_context() 给出一个绑定到请求 scope 的共享 sub-tree。
子 scope 每个请求要花两次分配。setRequestScope(false) 会把它去掉,直接复用连接 scope —— 但这样 request_context() 就会返回 null,所以关掉它的话记得用 ?->。
SO_REUSEPORT 与负载均衡
在 Linux/BSD 上,内核会把入站连接均匀(但不确定)地分发给所有在同一 (host, port) 上 带 SO_REUSEPORT 打开的 socket。每个 worker 开自己的 socket;不需要 userspace 的负载均衡器, 也不需要锁。
Windows 上 SO_REUSEPORT 的等价能力可预测性更差;可以把负载均衡上移到 LB, 或者用 single-worker + 多进程不同端口的方式。
跨线程 transfer 处理程序
如果配置在一个线程里搭建、服务器在另一个线程里启动,HttpServer 支持 transfer。从 0.2.0 起, transfer 路径会正确携带协议位掩码(修复了 "silently dropped every request" 的 bug; 参考 CHANGELOG core/007-server-transfer-handler-dispatch.phpt)。
多线程模式的调试
0.6.3 加了 worker 意外退出的高声日志。$server->start() 的未捕获异常以及在 await-loop 还在等 worker 时的 clean return,现在都会输出到 stderr(以前每出一次就静悄悄丢掉 1/N 的 accept 容量,运维毫无信号)。
打开 INFO 日志:
use TrueAsync\LogSeverity;
$config->setLogSinks([
['type' => 'stderr', 'format' => 'pretty', 'level' => LogSeverity::INFO],
]);worker 池下不要用
setLogStream()。 父进程打开的 PHP stream 资源无法跨进 worker 线程:该 sink 会在父进程上保持活跃,而在 worker 里被跳过,启动时给出一条提示。请用一个每个 worker 都能自己打开的 sink ——stderr、stdout或file(每个 worker 以 append 模式 重新打开该路径)。详见 可观测性。
该用多少 worker?
经验法则:
- IO 密集型(带数据库/HTTP 的常规 web):从
available_parallelism()起步,盯着 CPU 使用率调。 - CPU 密集型(渲染、压缩重活、大 JSON):
available_parallelism()或更少,盯着 p99 调。 - 混合:超配 1–2 个 worker(
N+1或N+2)常能在 IO-stall 时榨出更多核心利用率。
$config->setWorkers(\Async\available_parallelism());
Async\available_parallelism()返回进程可用的 CPU 数(考虑 cgroup 配额和 affinity)。 底层走uv_available_parallelism,回退到uv_cpu_info。