TrueAsync ServerTrueAsync Server:多 worker 与 bootloader

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、独立的连接池。

基础示例

php
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() 做的事:

  1. 起一个对应大小的 Async\ThreadPool
  2. 通过 transfer_obj 把 config + 处理程序集合复制到每个 worker。
  3. 在 worker 里启动 event-loop,重新 bind listener。
  4. 父进程 await 所有 worker 结束。

优雅关停

HttpServer::stop() 在池的父进程上可用。它会让整个 cohort 退役,并挂起直到服务器真正停下 —— 当它返回时,worker 已经排空,池已经拆除,listen 套接字也已关闭。请从协程里调用它; 信号处理器是通常放它的地方:

php
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() 持续运行。仅限池的父进程。

你很少会自己调用它,而是接一个触发器:

php
$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)

php
$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。 这带来两条重要语义:

php
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 日志:

php
use TrueAsync\LogSeverity;

$config->setLogSinks([
    ['type' => 'stderr', 'format' => 'pretty', 'level' => LogSeverity::INFO],
]);

worker 池下不要用 setLogStream() 父进程打开的 PHP stream 资源无法跨进 worker 线程:该 sink 会在父进程上保持活跃,而在 worker 里被跳过,启动时给出一条提示。请用一个每个 worker 都能自己打开的 sink —— stderrstdoutfile(每个 worker 以 append 模式 重新打开该路径)。详见 可观测性

该用多少 worker?

经验法则:

  • IO 密集型(带数据库/HTTP 的常规 web):从 available_parallelism() 起步,盯着 CPU 使用率调。
  • CPU 密集型(渲染、压缩重活、大 JSON):available_parallelism() 或更少,盯着 p99 调。
  • 混合:超配 1–2 个 worker(N+1N+2)常能在 IO-stall 时榨出更多核心利用率。
php
$config->setWorkers(\Async\available_parallelism());

Async\available_parallelism() 返回进程可用的 CPU 数(考虑 cgroup 配额和 affinity)。 底层走 uv_available_parallelism,回退到 uv_cpu_info

也可参考