본문으로 건너뛰기
뒤로가기

[C++] Actor Scheduling: Lost Wakeup을 막는 Admission Protocol

업데이트:

TL;DR — Bounded MPSC로 구현된 Mailbox는 여러 Producer가 경합 문제 없이 안전하게 메시지를 저장할 수 있음을 보장합니다. 그러나 Mailbox에 메시지가 추가되었다는 이벤트를 받고 실행되는 Actor Worker 구조에서는 Actor가 안전하게 메시지를 실행할 수 있게 하려면 Mailbox에 메시지를 추가하고, 그 결과를 알려주는 시점과 Actor Worker의 Mailbox Drain 종료를 하나의 상태 전이 규칙으로 관리해야 합니다.

Table of contents

Open Table of contents

들어가며

이전 글에서는 여러 Producer가 하나의 Consumer에게 메시지를 전달하는 bounded MPSC Queue를 살펴봤습니다.

여러 IOCP Worker가 Windows 커널에서 처리해주는 IO Completion 결과를 빠르게 Actor Worker에게 전달해주기 위해 적합한 자료구조로 생각하여, 이를 각 Actor는 MPSC Queue로 구현된 Mailbox를 가지고 있고, 이를 Drain 하는 방식으로 메시지를 처리할 수 있게 하려고 했습니다.

하지만 Mailbox가 안전하게 메시지를 수용할 수 있음과는 별개로, Mailbox에 메시지가 있음에도 불구하고 Actor Worker가 스케줄링이 되지 않는 문제가 발생할 수 있다는 것을 알게 됐습니다.

이번에 확인한 문제는 다음과 같습니다.

Mailbox에는 메시지가 남아 있음
Actor는 Idle 상태임
Ready Queue에는 Actor 실행 요청이 없음

즉, Mailbox에 메시지를 Push 하는 작업은 정상적으로 진행됐지만, Actor가 이 메시지를 처리할 수 있도록 ReadyQueue에 token을 추가하는 작업이 진행되지 않아 이후 해당 Actor에게 들어오는 메시지가 없다면 메시지가 처리되지 않는 상태가 발생한 것입니다.

이 글에서 다루는 내용:

사전 지식: MPSC Queue의 Producer/Consumer 구조와 기본적인 thread synchronization 개념을 전제로 합니다. Queue의 CAS 예약과 slot publication 과정은 이전 글에서 다뤘으므로 여기서는 반복하지 않습니다.


1. 메시지 저장과 Actor 실행은 다른 문제

Actor는 자신의 mutable state와 그 상태를 변경하는 behavior를 하나의 실행 단위로 묶습니다. 여러 thread가 같은 state를 직접 수정하는 대신, Actor Worker의 단일 실행 흐름으로 이러한 작업을 처리할 수 있게 만들었습니다.

Private Server에서는 다음 네 구성요소로 이 경계를 나눴습니다.

구성요소책임
Mailbox여러 Producer(= IOCP Worker)가 보낸 메시지를 보존합니다.
ActorMailbox를 소비하며 자신이 소유한 session state를 변경합니다.
ExecutorActor의 일시적인 drain ownership을 얻어 실행합니다.
Gate새 메시지 publication과 Actor 실행 종료의 상태 전이를 조정합니다.

이 네 구성 요소는 다음과 같은 흐름으로 실행됩니다.

Mailbox는 여러 Producer가 동시에 메시지를 게시해도 메시지가 손상되지 않도록 합니다. 반면 Executor는 하나의 실행 흐름만 Actor의 mutable state를 변경하도록 제한합니다.

이 두 보장은 서로 다릅니다.

Mailbox의 보장
-> 메시지가 안전하게 저장됩니다.

Executor의 보장
-> Actor state가 동시에 여러 실행 흐름에서 변경되지 않습니다.

그러나 이 두 보장만으로는 Mailbox에 추가된 메시지를 처리할 실행 기회에 대한 상태 관리를 온전히 할 수 없습니다.

Producer가 메시지를 추가하려고 하는 시점에서

이런 상황이 발생하여 메시지만 남는 상황이 발생하는 것입니다.(여기에서 Mailbox Push와 Executor 실행은 온전히 보장되었습니다.)


2. IOCP Worker와 Actor Worker

Private Server에서는 IOCP Worker와 Actor Worker를 별도의 thread 역할로 구분했습니다.

flowchart LR
    K["Windows IOCP"] --> I["IOCP Worker(Producer)"]
    I -->|"completion을 메시지로 변환"| M["Actor Mailbox"]
    I --> G["Schedule Gate"]
    G -->|"실행이 필요하면 token 게시"| Q["Ready Queue"]
    Q --> W["Actor Worker"]
    W --> E["Executor"]
    E --> A["Actor"]
    A -->|"메시지 drain"| M
    E -->|"실행 종료 판단"| G

IOCP Worker는 운영체제(Windows) completion을 받아 Actor가 처리할 메시지로 변환합니다. Session의 receive buffer, pending I/O, send queue와 close state를 직접 변경하지 않고 Mailbox에 작업을 전달합니다.

Actor Worker는 Ready Queue를 기다리다가 실행 요청을 받은 Actor만 처리합니다. (이 과정에서 Actor Worker 는 매번 Ready Queue를 Polling 하거나 Actor의 Mailbox를 순회하며 확인하지 않고, Ready Queue에 token이 들어왔다는 notify를 받는 구조입니다.)

Ready Queue에서 Actor Token을 받아 작업을 처리하는 Actor Worker의 실행 루프는 다음과 같습니다.

Actor Worker loop
-> Ready Queue에 실행할 Actor가 있는지 확인
-> 없으면 대기(Wait)
-> Ready Queue에 Actor가 Push되면 Notify 를 받아 wait 해제
-> token을 통해 Actor 식별
-> Executor를 통해 Actor 실행
-> 다시 Ready Queue 확인

이 구조에서 핵심 경쟁은 여러 Actor Worker가 하나의 Actor를 차지하는 경쟁보다 다음 두 흐름 사이에서 발생합니다.

Producer thread
-> Mailbox에 새 메시지를 게시합니다.

담당 Actor Worker
-> Mailbox drain을 끝내고 Actor 실행을 종료합니다.

2.1 notify는 작업 상태가 아닙니다

현재 Producer가 Mailbox에 메시지를 넣은 뒤 Actor Worker를 바로 깨울 수 있습니다. 하지만 notify만으로는 실행을 보장할 수 없습니다.

Mailbox, ReadyQueue, Worker Notification 의 역할을 구분하면 다음과 같습니다.

대상보존하는 것
MailboxActor가 처리할 실제 메시지
Ready Queue실행해야 할 Actor의 식별 token
Worker notification이미 게시된 실행 상태를 Worker가 확인하게 하는 신호

Notification은 상태 변경에는 관여를 하지 않고, 단순히 Actor Worker를 깨워 실행 상태를 확인하게 만드는 용도로만 사용됩니다. (Ready Queue에 Push가 선행되어야 함)


3. Lost Wakeup

앞서 말한 것처럼 메시지는 존재하지만, 실행할 Actor Token이 없는 Lost Wakeup이 발생하는 상황을 코드로 살펴봅니다.

먼저 가장 단순한 스케줄링을 다음처럼 생각해볼 수 있습니다

// Producer 측의 코드
mailbox.Enqueue(message);

if (actorState == Idle)
{
    actorState = Runnable;
    readyQueue.Push(actorToken);
}

Executor는 Mailbox를 비운 뒤 Actor를 다시 실행할 필요가 없으면 Idle로 전환합니다.

// Executor 측의 코드
actor.DrainMailbox();

if (mailbox.IsEmpty())
{
    actorState = Idle;
}

보기에는 자연스러워 보이지만, 다음과 같은 문제가 발생할 수 있습니다.

sequenceDiagram
    participant P as Producer
    participant M as Mailbox
    participant G as Actor state
    participant E as Executor

    E->>M: Mailbox가 비었는지 확인
    M-->>E: 비어 있음
    P->>M: 새 메시지 게시
    P->>G: Actor 상태 확인
    G-->>P: Running
    Note over P: 이미 실행 중이므로 새 실행 요청을 만들지 않음
    E->>G: Actor를 Idle로 전환
    Note over M,G: Mailbox는 non-empty, Actor는 Idle, Ready token은 없음

이 상태에서는 다음 메시지가 우연히 도착해 Actor를 다시 깨우기 전까지 기존 메시지가 처리되지 않습니다. 다음 메시지조차 없다면 Actor는 계속 잠든 상태로 남습니다.

문제는 actorState를 Atomic 변수로 바꾸는 것만으로 해결되지 않습니다. 개별 load와 store가 Atomic이어도 다음 두 판단은 여전히 서로 다른 시점에 일어납니다.

Producer
[메시지 게시 -> Actor의 다음 실행이 필요한지 판단]

Executor
[Mailbox가 비었다고 판단 -> Actor 실행 종료를 확정]

그래서 Producer 측에서 메시지를 게시하는 관점에서는 Actor의 실행을 위한 notification이 필요한지 확실하게 알아야 하고, 마찬가지로 Executor 입장에서는 Producer가 메시지 게시를 시도하고 있는지까지 확실하게 알아야 유효한 다음 상태를 결정할 수 있게 됩니다. 그리고 Producer와 Executor 사이의 비어있는 공간을 연결하는 역할을 하는 것이 바로 Gate입니다.


4. Gate

Gate는 Actor 작업 전체를 감싸는 lock이 아닙니다. Producer가 Actor의 drain이 끝날 때까지 기다리게 만들면 IO completion 전달이 Actor 작업 시간에 묶이게 됩니다.

Gate가 연결해야 하는 범위는 더 작습니다.

Producer 측 논리적 임계영역
[메시지 게시 시작
 -> Mailbox 게시 성공 또는 실패
 -> 결과에 따른 Actor의 다음 실행 상태 결정]

Executor 측 논리적 임계영역
[현재 drain 종료 요청
 -> 새 메시지 또는 남은 작업 확인
 -> Actor를 Runnable 또는 Idle로 확정]

Producer에서 Mailbox에 메시지를 게시하는 구간을 일종의 트랜잭션처럼 묶어, Mailbox에 게시하는 작업 과정 중에 Executor가 Producer의 중간 상태를 Gate를 통해 확인하여 필요한 상태 전이를 진행하게 됩니다.

개념적으로는 하나의 mutex로 이 판단을 직렬화할 수 있습니다. 하지만 핵심은 mutex라는 구현 수단이 아니라 어디부터 어디까지를 하나의 상태 전이로 취급해야 하는가입니다.

Private Server의 Gate는 이 논리적 임계영역을 Actor별 scheduling state로 표현합니다. 현재 구현은 phase와 진행 중인 publication 수, drain 중 도착한 작업과 재실행 요구를 하나의 packed Atomic state에 담고 CAS로 전이합니다.


5. Admission 기반 publication 과정 추적

이 글에서 Admission은 클라이언트의 서버 접속 승인이나 게임 참가 승인에 대한 의미가 아닙니다.

앞에서 이야기한 Producer의 메시지 게시의 시작과 종료 구간에 대하여 성공/실패 확정까지 이를 추적하기 위한 논리적 절차를 의미합니다. (Actor Scheduling Admission)

게시(Publication) 시작
-> Mailbox에 메시지 게시 시도
   -> 성공: commit
   -> 실패: reject
-> 게시 결과를 scheduling state에 반영

앞서 말한 트랜잭션이라는 표현은 실제 DB의 트랜잭션처럼 Mailbox에 메시지를 게시하는 과정 자체를 동시성 문제 없이 보장하고, 실패시 롤백을 한다는 의미가 아닙니다. 게시 결과와 Actor 실행 상태 전이가 서로 같은 결론으로 향할 수 있도록 묶어준다는 의미에서 트랜잭션이라고 부른 것입니다.

Executor에서는 publication 과정을 state 라는 단일 atomic 변수 하나가 아닌 Admission이라는 위의 절차를 감싸는 대상을 이용해 중간 상태를 관찰할 수 있습니다.

그래서 이를 통해 Executor에서 publication 중간 상태를 기반으로 Actor의 최신 상태를 결정하게 됩니다.

Producer                         Actor Worker
--------                         ------------
publication 시작
                                 drain 완료
                                 실행 종료 요청
                                 publication 진행 중 확인
                                 Idle 확정 보류
                                 현재 drain 종료

publication 성공 또는 실패
-> 결과에 따라 Actor 스케줄링 여부 결정

Actor Worker는 Producer가 끝날 때까지 block하지 않습니다. 현재 Actor의 drain을 끝내고 다음 작업으로 이동하며, 마지막 Producer가 결과를 확정할 때 해당 Actor의 다음 상태가 최종 결정됩니다.

Publication 결과남은 Actor work최종 상태
성공있음 또는 새 메시지 commit다시 실행 가능한 상태
실패기존 work가 남아 있음다시 실행 가능한 상태
실패기존 work도 없음Idle
아직 확정되지 않음판단 불가최종 전이 보류

이 과정으로 Executor가 Mailbox를 비웠다고 관찰한 직후 시작된 publication도 실행 종료 판단에서 누락되지 않습니다.


6. Permit을 통한 Actor 실행 용량 확보

Admission을 이용해 publication의 중간 진행 상태를 Executor에서 관측하여, Producer와 Executor 사이에서 발생하는 서로 다른 상태 전이는 방지할 수 있게 됐습니다.

그러나 위의 상태 표에서 보이는 것처럼 ‘최종 전이 보류’ 상태가 결정됐을 때에는, Producer 측에서 Actor의 스케줄링 여부를 최종 결정해야 합니다.

그리고 이를 결정하기 위해선 아직 해결해야 하는 문제가 하나 더 남아있습니다.

1. Mailbox publication 성공
2. Actor를 Runnable로 전환
3. Ready Queue에 token 게시 시도
4. Ready Queue가 full이라 게시 실패

즉, Ready Queue에서 허용할 수 있는 token의 최대 허용 수를 초과한 경우가 있을 수 있다는 것입니다. 그래서 이 순간 앞서 우려했던 lost wakeup 상태가 다시 만들어집니다.

Mailbox: 메시지 있음
Actor: Runnable로 표시됨
Ready Queue: token 없음 -> 메시지 처리를 위한 Actor Worker 실행 불가

그래서 Idle Actor를 새로 runnable 집합에 넣을 때는 Mailbox에 메시지를 commit하기 전에 실행 용량을 먼저 확보합니다. 이 논리적인 용량 예약을 permit이라고 부릅니다.

Permit은 Actor를 실행할 수 있는 lock이 아닙니다. Ready Queue에 해당 Actor의 실행 token을 게시할 수 있다는 bounded capacity 예약입니다.

개념적인 순서는 다음과 같습니다.

Idle Actor에 대한 publication 시작
-> runnable capacity 예약
   -> 실패: Mailbox에 메시지를 commit하지 않고 거절
   -> 성공: Mailbox publication 시도
      -> 실패: 예약 반환
      -> 성공: Actor를 Runnable로 확정하고 token 게시

permit을 한 번 확보한 상태에서 스케줄링이 되면, 해당 Actor에 대해서는 신규 permit을 재발급하지 않고, 이미 스케줄링이 되었기 때문에 Worker에서 Actor의 처리를 기대하는 방향으로 작동하게 됩니다.

상태: Idle Actor
-> 첫 성공 publication이 permit과 token 필요

상태: Runnable 또는 Running Actor
-> 기존 실행 기회를 사용
-> 새 메시지는 pending work로 합쳐짐

이를 통해 Actor가 한 번 실행 기회를 얻어 작업을 처리할 수 있게 되면, Mailbox에 쌓인 메시지가 empty가 될 때까지 permit을 유지할 수 있으며, 한 번의 drain loop에서는 정해진 양만큼의 메시지를 처리하게 됩니다.

Actor Worker Thread 관점에서는 한 번 깨어난 뒤에 Ready Queue에 실행할 Actor가 남아있으면 최대한 많은 작업을 처리하게 됩니다.


7. 상태 전이로 보는 전체 흐름

다음 상태도는 실제 enum을 그대로 옮긴 것이 아니라 핵심 전이를 설명하기 위한 개념 모델입니다. Publication 진행 여부는 Actor phase와 별도로 추적되지만, drain 종료 시 두 정보를 함께 판단합니다.

stateDiagram-v2
    [*] --> Idle
    Idle --> Runnable: 첫 메시지 commit
    Runnable --> Running: Executor가 실행 시작
    Running --> Runnable: 남은 work 또는 새 commit 존재
    Running --> Idle: work 없음, 진행 중 publication 없음
    Running --> Finalizing: publication 진행 중
    Finalizing --> Runnable: 마지막 publication commit
    Finalizing --> Runnable: 기존 work 또는 재실행 요구 존재
    Finalizing --> Idle: 모두 reject, 기존 work 없음
    Runnable --> Running: 후속 drain 시작

각 상태에서 필요한 보장은 다음과 같습니다.

상태의미허용되는 다음 판단
Idle알려진 work와 실행 token이 없습니다.첫 commit이 새 실행 기회를 만듭니다.
Runnable실행 token이 게시되었거나 실행 예정입니다(ready queue에 들어감).Executor가 drain ownership을 얻습니다.
RunningExecutor가 Actor를 실행하고 있습니다.새 메시지는 기존 실행 기회에 합쳐집니다.
Finalizingdrain은 끝났지만 publication 결과가 남았습니다.마지막 결과가 Runnable 또는 Idle을 확정합니다.

이 상태 전이에서 지켜야 할 invariant를 정리하면 다음과 같습니다.

  1. 성공한 Mailbox publication은 Actor의 현재 또는 다음 drain으로 연결됩니다.
  2. 하나의 Actor mutable state는 한 번에 하나의 Executor만 변경합니다.
  3. Actor가 Idle로 확정될 때는 실행이 필요한 commit과 진행 중인 publication이 없어야 합니다.
  4. Idle Actor의 첫 commit은 Ready Queue 용량이 확보된 뒤에만 이루어집니다.(= permit이 있어야 함)
  5. Worker notification은 Ready Queue와 scheduling state가 확정된 뒤에 발생합니다.

7.1 재스케줄링 결정의 직렬화

Idle Actor의 최초 스케줄링은 Producer가 시작합니다. Producer는 Admission을 시작하고 permit을 확보한 뒤 Mailbox publication을 시도합니다. Mailbox commit까지 성공하면 Actor를 Runnable로 확정하고 Ready Queue에 token을 게시합니다.

Idle Actor에 대한 첫 publication
-> Admission 시작
-> permit 확보
-> Mailbox commit
-> Actor를 Runnable로 확정
-> Ready Queue에 token 게시

Actor가 이미 Running 상태라면 핵심 경쟁은 Worker가 drain을 끝내는 시점과 Producer가 publication 결과를 확정하는 시점 사이에서 발생합니다. 두 작업의 완료 순서에 따라 재스케줄링을 최종 확정하는 주체가 달라집니다.

Producer의 Mailbox commit이 먼저 끝나면 새 pending work가 scheduling state에 기록됩니다. 이후 Executor가 drain을 종료하면서 pending work 또는 기존 재실행 요구를 확인하고 Actor를 다시 Runnable로 전환합니다.

Producer가 먼저 publication 완료
-> Mailbox commit과 pending work 기록
-> Executor가 drain 종료
-> Executor가 재스케줄링 확정

반대로 Executor가 drain을 먼저 끝냈는데 아직 publication 결과가 확정되지 않은 Producer가 있다면 Actor를 곧바로 Idle로 전환하지 않습니다. Executor는 최종 전이를 보류하고 현재 drain을 종료합니다. 이후 마지막 Producer가 commit 또는 reject 결과를 반영하면서 Actor를 Runnable 또는 Idle로 확정합니다.

Executor가 먼저 drain 종료
-> 진행 중인 Producer 확인
-> Actor의 최종 전이를 보류
-> 마지막 Producer가 publication 결과 확정
-> Producer가 재스케줄링 또는 Idle 확정

따라서 재스케줄링 판단 코드는 Producer와 Executor 양쪽에 존재하지만, 두 실행 흐름이 각각 Ready token을 게시하지는 않습니다. 진행 중인 Producer 수, drain 중 commit된 pending work와 재실행 요구를 하나의 packed Atomic scheduling state에 저장하고 CAS로 전이하기 때문에, 최종 상태 전이에 성공한 한쪽만 Runnable 전환과 Ready token 게시를 요청합니다.

Executor의 drain ownership도 같은 방식으로 직렬화됩니다. Ready Queue에서 token을 얻었더라도 Runnable에서 Running으로 전환하는 데 성공한 하나의 Executor만 Actor의 Mailbox를 처리합니다. 즉, 재스케줄링 판단 위치는 분산되어 있지만 재스케줄링 결정권과 Actor 실행권은 각각 하나의 실행 흐름에만 부여됩니다.


8. notify는 확정된 실행 기회를 Actor Worker에 전달합니다

notify는 Actor의 실행 가능 여부를 결정하는 수단이 아닙니다. Admission이 성공으로 끝나 Actor의 실행 기회가 확정된 뒤, Ready Queue를 기다리는 Actor Worker의 대기를 풀기 위해 사용합니다.

Idle Actor에 대한 첫 publication은 다음 순서로 진행됩니다.

permit 확보
-> Mailbox에 message commit
-> Actor를 Runnable로 확정
-> Ready Queue에 Actor token 게시
-> Actor Worker에 notify

Ready Queue에 token이 게시되었다는 것은 단순히 메시지가 도착했다는 알림보다 더 강한 의미를 가집니다. Admission 과정에서 runnable capacity를 위한 permit을 확보했고, Mailbox publication이 성공했으며, 해당 Actor를 실행 가능한 상태로 연결하는 scheduling 결과까지 확정되었다는 뜻입니다.

따라서 notify를 받고 깨어난 Actor Worker는 Ready Queue에서 token을 꺼내 실행할 Actor를 식별할 수 있습니다. 이후 Executor가 Gate를 통해 drain ownership을 얻고 Mailbox를 처리합니다. 즉, notify가 Actor 작업의 소유권을 직접 넘기는 것이 아니라, 이미 확정되어 Ready Queue에 게시된 실행 기회를 Worker가 점유할 수 있도록 대기를 해제합니다.

Actor Worker wakeup
-> Ready Queue의 조건을 다시 확인
-> Actor token 획득
-> Executor를 통해 drain ownership 획득
-> Actor Mailbox 처리

Actor가 이미 Runnable 또는 Running 상태라면 새 메시지마다 permit과 token을 다시 만들지는 않습니다. 새로운 publication은 기존 permit이 유지되는 실행 생명주기에 합쳐지고, 현재 drain 이후 추가 실행이 필요하면 Actor가 다시 Ready Queue에 게시됩니다.

Condition variable의 wait/notify 경합은 Ready Queue를 기다리는 구현 계층에서 별도로 처리합니다. Worker는 깨어난 뒤 Ready Queue가 비어 있지 않은지 다시 확인하지만, 이번 구조에서 실행 안전성을 만드는 핵심은 notification 자체가 아니라 Admission, permit, Mailbox commit과 Ready Queue token 게시가 먼저 확정된다는 점입니다.


정리하며

핵심 요약:

이번 학습을 통해 thread-safe queue를 사용했다는 사실과 event-driven consumer가 결국 실행된다는 보장이 다르다는 점을 확인했습니다. Actor scheduling에서는 Mailbox, Ready Queue와 Actor state를 각각 안전하게 만드는 것뿐 아니라, 세 요소가 하나의 결과로 수렴하도록 연결하는 protocol이 필요합니다.

참고 자료


이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.


공유하기:

이전 글
[C++] C++ 객체를 C#에 전달하기
다음 글
[C++] 고정 블록 메모리 풀: 메모리 풀은 더 빠를까?