C++ Asio post, dispatch, defer | 실행 큐 정밀 제어 [#4]

이 글의 핵심

dispatch는 호출 지연을 줄여 주지만 현재 핸들러 안에서 곧바로 다른 핸들러가 실행되기 때문에 재진입 버그와 깊은 호출 스택을 만들 수 있습니다. 실제 재진입 사례와 로깅으로 실행 순서를 추적하는 방법, 커스텀 executor와 코루틴에서의 동작, 상황별로 쓸 함수를 고르는 결정 기준을 다룹니다.

“나중에 실행”에도 종류가 있다

Asio에서 “지금 당장이 아니라 나중에 실행할 작업”을 넣을 때 쓰는 대표 API가 post, dispatch, defer입니다. 셋 다 “실행 큐에 넣는다”는 점은 비슷하지만, 언제 실행될 수 있는지에 결정적인 차이가 있어서, 지연 시간(latency)과 재진입(reentrancy)을 다룰 때 구분을 못 하면 버그나 성능 이슈가 납니다. 헷갈리기 쉬운 점: “나중에 실행”이라고 해서 post와 dispatch를 같은 것으로 쓰면 안 됩니다. post는 “절대 지금 호출 스택 위에서 실행하지 말고 큐에만 넣어라”이고, dispatch는 “지금 이 executor를 실행 중이면 곧바로 실행해도 된다”는 뜻이라, dispatch는 재진입이 발생할 수 있습니다. 따라서 “재진입이 위험한 상황(같은 객체 상태를 연속으로 건드리는 코드)“에서는 post를 쓰는 것이 안전합니다. defer는 post처럼 큐에 넣되 “이 작업은 지금 작업의 연속”이라는 힌트를 함께 주는 API입니다. 아래에서 셋의 동작을 차례로 보고, 채팅 서버와 타이머 예제에서 어떤 것을 고를지 살펴봅니다.

post: 무조건 큐의 뒤에 넣기

post의 동작

post(executor, handler)는 “이 handler를 해당 executor의 실행 큐 맨 뒤에 넣어라”는 의미입니다. 지금 이 스레드가 무슨 일을 하고 있든, handler는 절대 현재 호출 스택 위에서 곧바로 실행되지 않습니다. 반드시 “한 번 run() 루프 쪽으로 돌아간 뒤” 큐에서 꺼내 실행됩니다.

boost::asio::post(io, [] {
    std::cout << "A\n";
});
boost::asio::post(io, [] {
    std::cout << "B\n";
});
// A, B는 현재 스택에서 실행되지 않음. run()이 큐에서 꺼낼 때 순서대로 실행됩니다.

post가 재진입을 막는 이유

지금 실행 중인 핸들러 안에서 post로 작업을 넣으면, 그 작업은 현재 핸들러가 반환한 뒤에 실행됩니다. 그래서 실행 중인 함수가 끝나기 전에 같은 객체의 다른 핸들러가 끼어드는 재진입이 일어나지 않습니다. 또 “다음 단계”를 계속 post로 넘기면 각 단계가 run() 루프에서 하나씩 꺼내 실행되므로, 단계가 아무리 이어져도 호출 스택이 깊어지지 않습니다.


dispatch: 즉시 실행 기회가 있으면 실행

dispatch의 동작

dispatch(executor, handler)는 “지금 이 executor의 맥락 안이라면 handler를 곧바로 실행해도 된다”는 뜻입니다. io_context의 executor는 현재 스레드가 그 io_context의 run()을 실행 중일 때, strand는 현재 스레드가 그 strand의 핸들러를 실행 중일 때 handler를 같은 호출 스택에서 바로 호출합니다. 그 밖의 경우에는 post처럼 큐에 넣습니다.

boost::asio::dispatch(io, [] {
    std::cout << "running on executor\n";
    // 이 안에서 또 dispatch하면, "즉시 실행"될 수 있어 재진입 가능
    boost::asio::dispatch(io, [] { std::cout << "maybe reentrant\n"; });
});

지연을 줄이는 대신 재진입을 허용

같은 strand에서 이미 실행 중일 때 dispatch하면 큐를 거치지 않고 바로 실행되므로 지연이 줄어듭니다. 대신 그 실행은 현재 핸들러가 끝나기 전, 같은 호출 스택 위에서 일어납니다. 현재 핸들러가 객체 상태를 바꾸는 도중이었다면 dispatch된 핸들러는 중간 상태를 보게 되고, dispatch가 연쇄되면 스택도 계속 깊어집니다. 그래서 dispatch는 재진입해도 불변식이 깨지지 않도록 설계된 코드에서만 쓰는 것이 안전합니다.


defer: 다음 턴으로 미루기

defer의 동작

defer(executor, handler)도 post와 마찬가지로 handler를 호출한 함수 안에서 절대 실행하지 않습니다. Asio 문서가 설명하는 차이는 의도입니다. defer는 “제출하는 작업이 현재 작업의 연속(continuation)“이라는 정보를 executor에 전달하고, executor는 이를 이용해 실행 방식을 최적화할 수 있습니다. io_context는 run()을 실행 중인 스레드에서 defer가 호출되면 핸들러를 그 스레드 전용 큐에 넣어 두었다가 현재 핸들러가 반환한 뒤 같은 스레드에서 실행합니다. 공유 큐의 락을 잡지 않고 다른 스레드를 깨우지도 않으므로 연속 작업에 유리합니다.

반대로 post는 “새로운 독립 작업”이라는 의도를 나타내므로, 멀티 스레드 io_context라면 다른 스레드가 가져가 병렬로 실행할 수 있습니다. 관찰 가능한 순서 보장(현재 호출 안에서 실행되지 않음)은 둘이 같고, 성능 특성과 실행 스레드에서 차이가 날 수 있습니다.


세 API 비교와 선택 기준

API즉시 실행 가능?재진입 가능?용도
post아니오 (항상 큐에 넣음)없음재진입/스택 폭발 방지, “다음 턴”에 실행
dispatch예 (같은 executor 실행 중이면)있음지연 최소화, 단 재진입 주의
defer아니오 (항상 큐에 넣음)없음현재 작업의 연속을 예약 (executor가 최적화 가능)

상황별 선택

  • 지연을 최소화하며, 같은 Strand에서 “바로 이어서” 실행해도 논리적으로 안전할 때 → dispatch (예: Strand 내부에서 상태 머신 한 단계 진행).
  • 재진입을 피하고 스택을 평탄하게 유지하고 싶을 때 → post (예: 완료 핸들러 안에서 “다음 읽기”를 예약할 때 post로 넣어서 현재 핸들러가 끝난 뒤에 실행).
  • 핸들러 안에서 자기 작업의 다음 단계를 예약하는데 재진입은 피하고 싶을 때 → defer (예: 비동기 작업 체인의 다음 단계). 의미상 post로 바꿔도 순서는 같습니다.

Strand에 적용하면, post(strand, …)는 strand의 큐 맨 뒤에 넣어 순서를 보장하고 재진입이 없으며, dispatch(strand, …)는 지금 이 strand의 핸들러를 실행 중이라면 바로 실행해 지연을 줄이는 대신 재진입 가능성을 받아들입니다.


채팅 서버·타이머·브로드캐스트에 적용하기

채팅 서버에서 post와 dispatch 선택

채팅 서버는 보통 한 방(room)당 하나의 Strand(또는 단일 스레드 직렬화)로 메시지 순서와 멤버십을 보장합니다. 여기서 흔한 패턴은 다음과 같습니다.

  • 브로드캐스트: 한 클라이언트의 읽기 완료 핸들러 안에서 “모든 소켓에 async_write”를 걸 때, 같은 Strand에서 dispatch로 연쇄하면 버퍼 수명·완료 순서를 잘 설계한 경우 지연을 줄일 수 있습니다. 반면 같은 컨테이너(예: std::vector<session*>)를 순회하며 콜백 안에서 멤버를 제거·추가하는 구조라면, dispatch로 인한 재진입이 iterators 무효화나 이중 erase로 이어질 수 있어 post가 안전합니다.
// 패턴 A: "현재 핸들러가 끝난 뒤" 브로드캐스트 큐를 처리 (재진입 회피)
void room::on_chat_message(const std::string& text) {
    pending_broadcasts_.push_back(text);
    boost::asio::post(strand_, [self = shared_from_this()] {
        self->flush_pending_broadcasts();  // 여기서만 컨테이너·소켓 일괄 처리
    });
}
// 패턴 B: Strand 안에서 상태 머신이 재진입에 안전할 때만 dispatch
void room::tick_state_machine() {
    boost::asio::dispatch(strand_, [self = shared_from_this()] {
        self->advance_one_step();  // 내부에서 동일 객체지만 단일 경로만 수정
    });
}

같은 strand 핸들러 안에서 다른 세션 객체를 건드린다면 기본은 post로 한 턴 미루고, 같은 객체의 로컬 상태만 한 단계 진행할 때만 dispatch를 검토하는 것이 무난한 기준입니다.

타이머 + Strand에서의 활용

steady_timer의 async_wait 콜백은 이미 io_context에서 실행되지만, Strand로 묶인 객체와 함께 쓸 때는 bind_executor(strand_, handler) 또는 람다 안에서 post(strand_, …)로 “객체의 직렬화된 큐”에 넣는 패턴이 많습니다. 타이머 콜백에서 바로 무거운 작업을 하지 않으며, post로 “다음 턴에 heartbeat·타임아웃 정리”를 넣으면 현재 콜백 스택이 짧아지고, 같은 타이머를 재시작할 때 이전 핸들러와의 순서가 명확해집니다.

timer_.async_wait(
    boost::asio::bind_executor(strand_,
        [this](const boost::system::error_code& ec) {
            if (ec) return;
            // 반드시 strand 큐의 "뒤"에 넣어 재진입·중첩 타이머 처리 분리
            boost::asio::post(strand_, [this] { on_timer_tick(); });
        }));

이 예제의 post는 타이머 처리가 현재 콜백의 연속이므로 defer로 바꿔도 의미가 맞습니다. 둘 다 현재 콜백이 반환된 뒤에 실행되며, defer는 executor에 연속 작업이라는 힌트를 추가로 줄 뿐입니다.

브로드캐스트 중 재진입 버그와 해결

strand 핸들러 안에서 연결 목록 sessions_를 순회하며 dispatch(strand_, ...)로 각 세션에 알림을 보낸다고 해 봅시다. 이미 strand 안이므로 dispatch된 알림은 그 자리에서 바로 실행되고, 알림 처리 중에 세션이 끊겨 sessions_에서 제거되면 순회 중인 반복자가 무효화됩니다. 해결 방법은 auto copy = sessions_;처럼 스냅샷을 떠서 순회하거나, 인덱스 기반으로 순회하며 매번 유효성을 다시 확인하거나, 가장 단순하게 post(strand_, …)로 알림 전송을 현재 핸들러가 끝난 뒤로 미루는 것입니다.

// 버그 가능: dispatch 체인이 같은 스택에서 연속 실행되며 컨테이너 변경
for (auto& s : sessions_) {
    boost::asio::dispatch(strand_, [&s] { s->notify(); });  // notify 안에서 erase 가능
}
// 안전한 한 방향: 알림을 큐에 쌓고 한 번에 post로 처리
boost::asio::post(strand_, [this] { broadcast_unlocked(); });

post와 dispatch의 지연 차이 측정

post vs dispatch 벤치마크 관점

이미 그 executor를 실행 중인 스레드에서 호출한다면, dispatch는 큐를 거치지 않고 일반 함수 호출처럼 실행되고, post는 큐 삽입과 run() 루프의 꺼내기를 거칩니다. 핸들러 하나당 차이는 작지만 호출 빈도가 매우 높은 경로에서는 누적됩니다. 벤치마크를 직접 돌릴 때는 다음 조건을 고정합니다.

  • 단일 스레드 io_context::run vs 멀티 스레드 풀
  • 핸들러가 같은 strand 안에서 호출되는지 여부
  • 측정 구간에 OS 스케줄러·전력 절전 영향을 줄이도록 워밍업 루프 구조상 같은 스레드·같은 executor 맥락에서는 dispatch가 큐 왕복을 생략하므로 지연이 작아야 하고, executor 밖의 다른 스레드에서 호출할 때는 dispatch도 큐에 넣으므로 차이가 거의 없어야 합니다. 측정 결과가 이와 다르다면 측정 조건부터 의심해 볼 만합니다.

지연 시간 측정 방법

  1. 고해상도 시계: std::chrono::steady_clock 또는 플랫폼별 clock_gettime으로, 스케줄 직전 타임스탬프와 핸들러 첫 줄 타임스탬프의 차이를 기록합니다.
  2. 퍼센타일: p50 / p95 / p99를 같이 보며, 꼬리가 길면 큐 적체·락 경합을 의심합니다.
  3. Strand 사용 시: “strand에 넣은 시점 → 실제 실행 시점”만 따로 로깅하면, post만 과다 사용으로 인한 추가 한 턴 지연을 확인하기 좋습니다.

언제 무엇을 쓸지 결정 트리

flowchart TD
    A[작업을 executor에 맡길 차례] --> B{같은 executor/ strand에서\n이미 실행 중인가?}
    B -->|아니오| C["post 또는 defer\n큐에 넣기"]
    B -->|예| D{재진입해도\n객체 불변식이 안전한가?}
    D -->|아니오| E[post로 한 턴 미루기]
    D -->|예| F{지연을 최대한\n줄여야 하는가?}
    F -->|예| G[dispatch]
    F -->|아니오| H[안전 우선이면 post]

다른 스레드/맥락에서 오면 post가 기본이며, 같은 strand 안에서만 dispatch vs post를 재진입·지연 트레이드오프로 고릅니다.


재진입과 실행 순서 디버깅

재진입 버그를 찾는 방법

  1. 같은 Strand 핸들러 안에서 dispatch로 같은 객체의 메서드를 다시 부르는지 검색합니다.
  2. 스택 깊이가 비정상적으로 깊어지면, dispatch 연쇄를 의심합니다. 디버거에서 중단점을 걸고 호출 스택을 확인합니다.
  3. 정적 분석: “콜백 안에서 컨테이너 수정 + 외부 순회” 패턴을 코드 리뷰 체크리스트에 넣습니다.

로깅으로 실행 순서 추적

  • 전역 또는 스레드 로컬 시퀀스 번호를 ++seq로 찍으며, post/dispatch/defer 각각에 “enqueue seq=…”, 핸들러 입구에 “run seq=…”를 남깁니다.
  • 스레드 ID(std::this_thread::get_id())를 함께 찍으면, 멀티 스레드 io_context에서 잘못된 직렬화가 있는지 구분에 도움이 됩니다.
  • 운영 환경에서는 스팸 방지를 위해 샘플링하거나, 특정 connection id만 verbose 로깅합니다.

흔한 실수와 대응

실수증상대응
dispatch로 공유 컨테이너 순회 중 수정간헐적 크래시, iterator 무효화post 또는 스냅샷 순회
타이머 콜백에서 동기 콜백 연쇄긴 스택, 지연 증가작업을 post로 쪼개기
strand 없이 여러 스레드에서 dispatch 혼용데이터 레이스strand 또는 mutex로 직렬화
defer를 dispatch로 착각순서 가정이 어긋남API 문서 재확인, 테스트로 순서 고정

executor·커스텀 executor·코루틴과의 관계

executor 위에 올라가는 세 API

Asio에서 post / dispatch / defer는 모두 Executor 개념 위에 올라갑니다. io_context는 executor이고, strand도 executor입니다. any_io_executor로 타입을 지우면, 동일한 연산이 “어떤 직렬화·스레드 어피니티로 갈지”만 바뀌고, post vs dispatch의 의미(즉시 가능한지 vs 큐에만 넣는지)는 해당 executor의 규칙을 따릅니다.

커스텀 executor에서의 동작

사용자 정의 executor를 만들면, execute() 구현에 따라 “즉시 호출” vs “큐에 넣기”를 나눌 수 있습니다. Asio의 기본 io_context executor는 문서화된 대로 dispatch가 “현재 맥락에서 실행 중이면” 즉시 실행될 수 있습니다. 커스텀 executor를 쓸 때는 팀 내 계약으로 post/dispatch가 각각 어떻게 매핑되는지를 문서화하지 않으면, 라이브러리 코드와 혼합 사용 시 순서 버그가 나기 쉽습니다.

C++20 코루틴과의 조합

Asio의 C++20 코루틴 지원(co_spawn, use_awaitable)에서 코루틴은 co_spawn에 넘긴 executor 위에서 실행되고, 각 co_await가 끝나면 그 executor를 통해 재개됩니다. 그래서 공유 상태를 strand로 직렬화하려면 코루틴을 처음부터 co_spawn(strand_, ...)으로 strand 위에서 시작하는 것이 가장 단순하며, 그러면 모든 재개 지점이 같은 strand를 거칩니다.

// 코루틴 전체를 strand 위에서 실행: 모든 co_await 재개가 strand를 거친다
auto strand = boost::asio::make_strand(io);
boost::asio::co_spawn(strand, session->loop(), boost::asio::detached);

boost::asio::awaitable<void> session::loop() {
    char buf[1024];
    for (;;) {
        std::size_t n = co_await socket_.async_read_some(
            boost::asio::buffer(buf), boost::asio::use_awaitable);
        handle(buf, n);  // strand 위에서 실행되므로 다른 핸들러와 겹치지 않음
    }
}

코루틴은 코드가 순차적으로 보여 재진입이 덜 눈에 띄지만, co_await마다 다른 핸들러가 끼어들 수 있는 재개 지점이 생기므로 직렬화 규칙은 콜백 기반과 같습니다. co_await 전후로 공유 상태의 불변식이 유지되는지를 콜백 코드와 같은 기준으로 확인해야 합니다.


자주 묻는 질문 (FAQ)

Q. 핸들러 안에서 dispatch를 연쇄적으로 호출해도 문제가 없나요?

A. dispatch는 이미 해당 실행기 안에서 실행 중이면 핸들러를 현재 호출 스택 위에서 바로 호출하므로, 핸들러가 다시 dispatch를 부르는 체인이 길어지면 재귀 호출처럼 스택이 계속 쌓입니다. 체인이 끝없이 이어지면 스택 오버플로의 위험이 있고, 그동안 큐에 있는 다른 핸들러는 실행 기회를 얻지 못해 공정성도 떨어집니다. 자기 자신을 반복해서 다시 스케줄하는 루프나 긴 작업 체인은 post로 큐 뒤에 넣어 스택을 비우고 다른 핸들러에게 차례를 넘기는 것이 안전합니다.


같이 보면 좋은 글