TrueAsync ServerTrueAsync Server: multi-worker와 bootloader

Multi-worker

(PHP 8.6+, true_async_server 0.6+)

TrueAsync Server는 기본적으로 단일 스레드 모드로 동작합니다: 하나의 event-loop, 하나의 스레드, 전체 파이프라인(accept → parse → dispatch → respond)이 하나의 CPU에서 실행됩니다. 일반적인 IO-bound 부하에 가장 빠른 모델이지만 코어 단위로 확장되지 않습니다.

setWorkers(N)Async\ThreadPool을 통해 N개의 OS 스레드로 구성된 내장 풀을 띄웁니다. 각 워커는 같은 리스너에 다시 바인드되며, 커널(Linux/BSD)이 SO_REUSEPORT로 accept를 분산합니다. 각 워커는 독립적인 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();   // 모든 워커가 끝날 때까지 블로킹

부모에서의 HttpServer::start():

  1. 필요한 크기의 Async\ThreadPool을 spawn합니다.
  2. transfer_obj로 config와 핸들러 세트를 각 워커에 복사합니다.
  3. 워커 안에서 event-loop를 시작하고 리스너를 다시 바인드합니다.
  4. 부모가 모든 워커의 종료를 await합니다.

Graceful shutdown

HttpServer::stop()은 풀 부모에서 동작합니다. 전체 cohort를 물러나게 하고 서버가 정말로 내려갈 때까지 일시 중단합니다 — 반환되는 시점에는 워커가 drain을 마쳤고, 풀이 해체되었으며, 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();

단독(standalone) 서버(setWorkers(1), 기본값)에서는 stop()이 일시 중단하지 않습니다: 보통 요청 핸들러에서 호출되며, shutdown drain은 바로 그 핸들러를 기다리기 때문에 — 거기서 블로킹하는 stop()은 자기 자신을 기다리는 꼴이 됩니다.

Hot reload

HttpServer::reload()는 연결을 끊지 않고 워커 cohort를 교체합니다: 워커들은 붙잡고 있던 작업을 마치고 멈춰서 종료하며, 새 워커 스레드가 bootloader를 다시 실행해 — 바뀐 코드를 픽업하고 — 같은 listen 소켓 위에서 인계받습니다. 예전 cohort가 drain을 마칠 때까지 일시 중단하며, 그 동안 start()는 계속 실행됩니다. 풀 부모 전용입니다.

직접 호출하는 일은 드뭅니다. 대신 트리거를 연결하세요:

php
$config
    ->setWorkers(4)
    ->setBootloader(function () {
        require __DIR__ . '/app/bootstrap.php';   // 모든 새 워커에서 다시 실행
    })

    // 개발: 트리를 감시하고, 변경이 안정되면 reload
    ->enableHotReload([__DIR__ . '/app'], ['php'], debounceMs: 300, maxHoldMs: 2000)

    // 프로덕션: 배포 스크립트가 보내는 SIGHUP에 reload
    ->enableReloadOnSignal();

enableHotReload()는 각 경로를 재귀적으로 감시합니다. 안정된 변경 burst가 감시 대상 트리를 opcache에서 무효화하고 reload()를 호출합니다. debounceMs는 burst가 하나의 reload를 발생시키기 전의 정적 대기 구간입니다. maxHoldMs는 첫 변경 이후 최대 그만큼의 시간이 지나면 reload를 강제하므로, 절대 조용해지지 않는 디렉터리도 여전히 reload됩니다. enableReloadOnSignal()은 지속적인 SIGHUP 핸들러를 arm합니다(Windows 미지원).

둘 다 풀 모드 전용입니다. 어떤 트리거든, 새 워커가 픽업하는 코드는 bootloader가 로드하는 그것이므로 — reload되기를 원하는 것은 무엇이든 진입 스크립트 최상단이 아니라 거기서 로드해야 합니다. 진입 스크립트는 부모에서 한 번만 실행되고 다시는 실행되지 않습니다.

reload()를 손수 호출한다면, 바뀐 파일을 먼저 무효화하거나(opcache_invalidate()) opcache 타임스탬프 검증에 의존하세요 — 그렇지 않으면 새 워커가 예전 코드를 컴파일합니다.

Bootloader

워커의 무거운 초기화(autoload, 풀 워밍, JIT 워밍업)는 매 요청이 아니라 시작 시 한 번 수행되어야 합니다. 이를 위해 setBootloader(?\Closure $cb)가 있습니다.

php
$config
    ->setWorkers(4)
    ->setBootloader(function () {
        // task loop 전에 각 워커에서 한 번 실행됨
        require __DIR__ . '/vendor/autoload.php';

        // 커넥션 풀 워밍
        Database::initPool(min: 4, max: 16);

        // 핵심 라우트 사전 컴파일
        Router::compile();
    });

클로저는 한 번 deep-copy되며 각 워커에서 task를 받아들이기 전에 실행됩니다. bootloader에서 던져진 예외는 전체 풀을 실패시킵니다: 워커가 시작하지 않습니다.

setWorkers() > 1일 때만 적용됩니다. null은 bootloader를 제거합니다.

TrueAsync ABI v0.15+가 필요합니다. 테스트: server/core/021-bootloader.phpt.

요청별 scope

0.6.5부터 각 핸들러 코루틴은 서버 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에 묶인 공유 서브트리를 제공합니다.

자식 scope는 요청당 할당 두 번의 비용이 듭니다. setRequestScope(false)는 이를 없애고 연결 scope를 그대로 재사용합니다 — 하지만 그러면 request_context()null을 반환하므로, 끄는 경우 ?->를 사용하세요.

SO_REUSEPORT와 부하 분산

Linux/BSD에서 커널은 같은 (host, port)SO_REUSEPORT로 열린 모든 소켓에 들어오는 연결을 균등하게(하지만 비결정적으로) 분산합니다. 각 워커는 자신의 소켓을 엽니다. userspace 로드 밸런서가 필요 없고, 락도 없습니다.

Windows에서는 SO_REUSEPORT 등가물이 덜 예측 가능합니다. 부하 분산을 더 위(LB)로 옮기거나, 서로 다른 포트의 단일 워커 + N 프로세스를 사용하세요.

핸들러의 cross-thread transfer

구성을 한 스레드에서 만들고 다른 스레드에서 서버를 시작하는 경우 HttpServer는 transfer를 지원합니다. 0.2.0부터 transfer 경로는 프로토콜 마스크를 올바르게 옮깁니다 ("모든 요청이 조용히 누락"되는 버그가 수정됨. CHANGELOG의 core/007-server-transfer-handler-dispatch.phpt 참고).

멀티스레드 모드 디버깅

0.6.3에서 예기치 않은 워커 종료에 대한 loud 로깅이 추가되었습니다. $server->start()에서 잡히지 않은 예외와 await-loop가 아직 워커를 기다리는 동안의 clean return이 stderr에 표시됩니다 (이전에는 각 케이스가 1/N accept 용량을 운영자에게 신호 없이 조용히 떨어뜨렸습니다).

INFO 로깅을 켜세요.

php
use TrueAsync\LogSeverity;

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

워커 풀에서는 setLogStream()을 쓰지 마세요. 부모가 연 PHP 스트림 리소스는 워커 스레드로 넘어갈 수 없습니다: sink는 부모에서 활성 상태로 남고 워커에서는 시작 시 알림과 함께 건너뜁니다. 워커가 스스로 열 수 있는 sink를 사용하세요 — stderr, stdout, 또는 file(각 워커가 append 모드로 경로를 다시 엽니다). 관측성을 참고하세요.

워커 수는?

경험 법칙:

  • IO-bound (DB/HTTP가 있는 일반 web): available_parallelism()로 시작하고 CPU util을 관찰.
  • CPU-bound (렌더링, 압축 위주, 큰 JSON): available_parallelism() 또는 그 이하, p99 latency를 관찰.
  • Mixed: 1–2개 워커 오버커밋(N+1 또는 N+2)이 IO-stall 시 더 나은 코어 활용을 줄 때가 많음.
php
$config->setWorkers(\Async\available_parallelism());

Async\available_parallelism()은 프로세스가 사용할 수 있는 CPU 수를 반환합니다 (cgroup quota와 affinity 고려). uv_available_parallelism이 백엔드이고 uv_cpu_info 폴백.

참고