기초스레드

스레드

지금까지 우리의 모든 도구는 단 하나의 운영체제 스레드 안에서 살았어요. 코루틴은 여러 일이 한꺼번에 일어나는 듯한 인상을 주었지만, 어느 순간이든 실제로 실행되는 것은 정확히 하나뿐이었죠. 임포트 작업과 GeoDirectory로의 요청에는 그것으로 차고 넘쳤어요. 작업은 대부분 기다렸고, 기다림은 아름답게 나눠지니까요.

이제 다른 종류의 작업이 있어요. 하루에 한 번, ProfileService는 연간 보고서를 만듭니다. 네트워크나 데이터베이스 호출 하나 없이 온전히 계산만 하는 꼬박 1분짜리 작업이에요. 이것을 익숙한 티커와 나란히 코루틴에서 실행해 봐요.

php
spawn(function () {
    while (true) {
        echo "tick\n";
        delay(1000);
    }
});

spawn(function () {
    $report = buildYearlyReport($rows); // 온전히 계산만 하는 1분
    echo "report ready\n";
});

티커가 꼬박 1분 동안 침묵합니다. 첫 번째 장을 떠올려 보세요. 코루틴은 await 지점, sleep, delay, I/O에서 전환합니다. 하지만 buildYearlyReport에는 await 지점이 단 하나도 없어요. 그저 반복문과 산술뿐이죠. 스케줄러는 결코 제어를 돌려받지 못하고, 전체 프로세스, 즉 티커, 워커, 요청 처리, 그 모든 것이 코루틴 하나가 숫자를 빻는 것을 그저 지켜보며 앉아 있어요.

코루틴은 병렬성이 아니라 동시성을 줍니다. 작업이 기다림이 아니라 CPU에 묶여 있을 때는 두 번째 프로세서가 필요해요. 더 정확히는, 두 번째 운영체제 스레드가 필요하죠.

spawn_thread

php
use function Async\spawn_thread;
use function Async\await;

$thread = spawn_thread(fn() => buildYearlyReport($rows));

$report = await($thread);

spawn_thread는 클로저를 별도의 운영체제 스레드에서, 다른 CPU 코어 위에서 실행합니다. 이제 보고서는 진정으로 병렬로 계산돼요. 티커는 계속 똑딱거리고, 워커는 계속 일하며, 스케줄러는 바로 옆에서 무거운 일이 벌어지는 줄도 몰라요.

바깥에서 보면 스레드는 거의 코루틴처럼 보입니다. 익숙한 await에 넘길 수 있고, 그것은 기다리는 코루틴만 일시 중단시켜요. 스레드에서 나온 예외도 Async\RemoteException으로 감싸여 await에 도달합니다.

공유하는 것이 없다

거의 코루틴 같지만, 한 가지 근본적인 차이가 있어요. 채널 장에서 우리는 코루틴이 공유 메모리에 살고, use를 통해 객체를 그냥 넘겨줄 수 있으며, 단일 스레드 안에서는 데이터 경쟁이 일어나지 않는다고 했어요. 그런데 그 요령은 스레드에는 통하지 않아요. 각 스레드는 자기만의 PHP 환경을 가져요. 자기만의 변수, 자기만의 클래스, 자기만의 정적 속성이죠. 공유 메모리는 아예 없습니다.

그래서 스레드에 전달되는 모든 것은 온전히 복사돼요.

php
$rows = loadRows();

$thread = spawn_thread(function () use ($rows) {
    // 이것은 $rows의 복사본이다: 여기서의 변경은 바깥에서 보이지 않는다
    return buildYearlyReport($rows);
});

이 규칙에는 제약도 따라와요. PHP 참조(&$var), 열린 파일 같은 리소스, 또는 동적 속성을 가진 객체는 스레드에 전달할 수 없어요. 그렇게 하려고 시도하면 나중에 알 수 없는 동작을 일으키는 대신, 시작 시점에 곧바로 ThreadTransferException을 던집니다.

규칙은 엄격하지만 정직해요. 공유 상태가 없으니 경쟁도, 락도, 뮤텍스도, 그 밖에 고전적 멀티스레딩의 온갖 악몽도 없어요. TrueAsync 스레드는 코루틴과 똑같은 방식으로 통신합니다. 메모리를 공유하는 것이 아니라 값을 전달함으로써요.

그런데 오래된 지인 하나가 스레드 경계를 의미 있게 넘어가는 법을 알고 있어요. 여섯 번째 장에서 나온 FutureState는 그 Future를 뒤에 남겨 둔 채 스레드에 전달할 수 있습니다.

php
$state  = new FutureState();
$future = new Future($state);

spawn_thread(function () use ($state) {
    $state->complete(buildYearlyReport(loadRows()));
});

$report = await($future);

생산자는 한 스레드에, 소비자는 다른 스레드에 있고, 계약은 정확히 똑같아요. 애초에 Future가 두 개의 별개 객체로 나뉜 것이 바로 이 때문입니다. 둘 사이의 경계가 스레드 경계를 바로 그 위에 얹을 만큼 튼튼한 것으로 드러났으니까요.

ThreadPool

스레드는 비싼 존재예요. 자기만의 PHP 환경을 만들고, 초기화하고, 예열해야 하니까요. 1분에 한 작업이라면 물론 문제없지만, 작디작은 작업마다 스레드를 띄우는 것은 요청마다 데이터베이스 연결을 여는 것만큼이나 낭비입니다.

비싼 리소스를 어떻게 다루는지는 이미 알고 있죠. 맞아요, 풀로 만드세요.

php
use Async\ThreadPool;

$pool = new ThreadPool(workers: 8);

$futures = [];
foreach ($uploads as $path) {
    $futures[] = $pool->submit(makeThumbnail(...), $path);
}

foreach ($futures as $future) {
    echo await($future), "\n";
}

$pool->close();

여덟 개의 워커 스레드가 한 번 생성되어 풀이 사는 동안 함께 삽니다. submit은 작업을 큐에 넣고, 비어 있는 워커가 그것을 집어 결과를 돌려줘요. 그리고 submit이 무엇을 반환하는지 보세요. 다른 누군가가 만들어낼 결과에 대한 약속인 Future입니다. 여섯 번째 장에서 그 "다른 누군가"는 코루틴이었지만, 이제는 완전히 별개의 스레드예요. 그런데도 계약은 조금도 바뀌지 않았습니다.

풀의 큐에는 제한이 있고, 그것이 가득 차면 submit은 호출한 코루틴을 일시 중단시켜요. 알아보시겠어요? 채널 장에서 나온 배압입니다. 작업 생산자가 워커의 속도에 맞춰 자동으로 스스로 페이스를 조절하는 것이죠.

워커 수는 CPU 코어 수에 가깝게 유지하세요. 기다림과 달리 계산은 실제 물리 코어를 필요로 하며, 여덟 코어 위의 스무 스레드는 그저 서로 밀쳐낼 뿐이에요. 기본적으로 workers 매개변수 없이도 풀은 컨테이너 쿼터까지 고려해서 사용 가능한 병렬성을 스스로 알아냅니다.

그리고 가장 흔한 경우, "목록을 병렬로 처리하기"를 위해서는 여러분이 이미 iterate에서 아는 한 줄짜리 형태가 있어요.

php
$thumbs = $pool->map($uploads, makeThumbnail(...));

map은 요소들을 워커들에게 분배하고, 그 모두를 기다린 다음, 결과를 원래 순서대로 돌려줍니다.

그러니 알고 보면 동시성에는 두 차원이 있어요. 코루틴은 기다림을 압축합니다. 수천 개의 작업이 단일 스레드를 공유하면서 기다리는 동안 서로 방해하지 않죠. 스레드는 진정한 병렬성을 더합니다. 계산이 CPU 코어들에 걸쳐 펼쳐지는 것이죠. 이 도구들은 서로 경쟁하지 않고 서로를 보완합니다. 코드가 기다리는 곳에서는 코루틴을, 계산하는 곳에서는 스레드를 손에 잡으세요. 그리고 둘 중 어느 것이든 많아지면 풀이 구원하러 옵니다.

마지막으로, 우리의 프로세스가 무엇으로 변했는지 보세요. 수백 개의 코루틴이 모두 뒤섞여 서로 다른 사용자를 섬기고, 작업이 워커와 스레드 사이를 뛰어다닙니다. 그리고 그 군중 속에서 단순한 질문 하나가 뜻밖에도 어려운 것으로 드러나요. "현재의 것"을 어디에 두느냐죠. 현재 사용자, 현재 언어, 요청 ID를요? 모두를 위한 전역 변수는 하나인데, 코루틴은 수천 개예요. 바로 그것이 마지막 장의 주제입니다.