C++ Asio post, dispatch, defer | 실행 큐 정밀 제어 [#4]
이 글의 핵심
C++ Asio post, dispatch, defer에 대한 실전 가이드입니다. 실행 큐 정밀 제어 [#4] 등을 예제와 함께 설명합니다.
들어가며: “나중에 실행”에도 종류가 있다
세 가지 스케줄링
Asio에서 “지금 당장이 아니라 나중에 실행할 작업”을 넣을 때 쓰는 대표 API가 post, dispatch, defer입니다. 셋 다 “실행 큐에 넣는다”는 점은 비슷하지만, 언제 실행될 수 있는지에 결정적인 차이가 있어서, 지연 시간(latency)과 재진입(reentrancy)을 다룰 때 구분을 못 하면 버그나 성능 이슈가 납니다.
헷갈리기 쉬운 점: “나중에 실행”이라고 해서 post와 dispatch를 같은 것으로 쓰면 안 됩니다. post는 “절대 지금 호출 스택 위에서 실행하지 말고 큐에만 넣어라”이고, dispatch는 “지금 이 executor를 실행 중이면 곧바로 실행해도 된다”는 뜻이라, dispatch는 재진입이 발생할 수 있습니다. 그래서 “재진입이 위험한 상황(같은 객체 상태를 연속으로 건드리는 코드)“에서는 post를 쓰는 것이 안전합니다.
목표:
- post: 무조건 큐에 넣고, 현재 호출 스택에서 즉시 실행되지 않음
- dispatch: “지금 이 executor에서 실행 중이면” 즉시 실행 가능할 수 있음
- defer: post와 비슷하지만, “다음에 실행될 기회”로 미루는 뉘앙스 (구현에 따라 post와 동일할 수 있음)
- 실전에서 지연을 최소화하려면 언제 무엇을 쓸지
목차
- post: 무조건 큐의 뒤에 넣기
- dispatch: 즉시 실행 기회가 있으면 실행
- defer: 다음 턴으로 미루기
- 비교와 실전 선택
- 실전 코드 예제
- 성능 비교
- 디버깅
- 고급 주제
- 정리
- 실전 체크리스트
1. 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로 넘기면, 호출 스택이 깊어지지 않고 큐 기반으로 평탄하게 실행됩니다.
2. dispatch: 즉시 실행 기회가 있으면 실행
동작
dispatch(executor, handler) 는 “가능하면 지금 이 executor를 실행 중인 맥락에서 곧바로 handler를 실행해도 된다”는 힌트를 줍니다. 구현에 따라, 현재 스레드가 해당 executor의 작업을 실행 중이면 같은 스택에서 handler를 호출할 수 있습니다. 그렇지 않으면 큐에 넣어 나중에 실행합니다.
boost::asio::dispatch(io, {
std::cout << "running on executor\n";
// 이 안에서 또 dispatch하면, "즉시 실행"될 수 있어 재진입 가능
boost::asio::dispatch(io, { std::cout << "maybe reentrant\n"; });
});
장단점
- 장점: “가능한 한 빨리” 실행되므로 지연 시간을 줄일 수 있습니다. 같은 Strand에서 이미 실행 중일 때 dispatch하면, 별도 큐 round trip 없이 바로 실행될 수 있습니다.
- 주의: 같은 executor(또는 strand)에서 실행 중인 핸들러 안에서 dispatch를 쓰면 재진입이 발생할 수 있습니다. 스택이 깊어지거나, 같은 객체 상태를 다시 건드리면서 논리 오류가 나기 쉬우므로, 재진입에 강하게 설계된 경우에만 사용하는 것이 안전합니다.
3. defer: 다음 턴으로 미루기
동작
defer(executor, handler) 는 “이 작업을 다음에 실행될 기회로 미뤄라”는 의미입니다. Asio 문서에 따르면, defer는 post와 비슷하게 “현재 호출 체인에서 즉시 실행하지 않고” 큐에 넣지만, 스케줄러가 “이번에 실행할 것만 처리하고 나머지는 다음에” 할 때 defer된 작업이 그 “다음에”로 밀리는 뉘앙스를 가질 수 있습니다. 구현체에 따라 post와 동일하게 동작할 수도 있습니다.
- post: “큐의 맨 뒤에 넣는다” (즉시 실행 안 함)
- defer: “이번 실행 사이클에서 바로 실행하지 않고 미룬다” (보통 post와 유사하게 큐에 넣음)
실제로 많은 경우 defer는 post처럼 “현재 스택에서 실행하지 않고 큐에 넣는” 동작을 하며, dispatch만 “즉시 실행 가능”의 의미가 강합니다.
4. 비교와 실전 선택
| API | 즉시 실행 가능? | 재진입 가능? | 용도 |
|---|---|---|---|
| post | 아니오 (항상 큐에 넣음) | 없음 | 재진입/스택 폭발 방지, “다음 턴”에 실행 |
| dispatch | 예 (같은 executor 실행 중이면) | 있음 | 지연 최소화, 단 재진입 주의 |
| defer | 보통 아니오 (post와 유사) | 없음 | ”다음 기회”로 미루기 (구현에 따름) |
실전 가이드
- 지연을 최소화하고, 같은 Strand에서 “바로 이어서” 실행해도 논리적으로 안전할 때 → dispatch (예: Strand 내부에서 상태 머신 한 단계 진행).
- 재진입을 피하고 스택을 평탄하게 유지하고 싶을 때 → post (예: 완료 핸들러 안에서 “다음 읽기”를 예약할 때 post로 넣어서 현재 핸들러가 끝난 뒤에 실행).
- defer는 문서/구현을 확인한 뒤, “다음 실행 기회로 미룬다”는 의미가 필요할 때 사용. 대부분 post로 대체 가능합니다.
Strand와 함께 쓸 때:
- post(strand, …) → 이 Strand의 큐 맨 뒤에 넣음. 순서 보장, 재진입 없음.
- dispatch(strand, …) → 지금 이 Strand의 핸들러를 실행 중이면 즉시 실행 가능 → 지연 감소, 재진입 가능성 있음.
5. 실전 코드 예제
채팅 서버에서 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(); });
}));
defer를 쓰는 경우는 “이번 async_wait 완료 직후가 아니라, 같은 executor에서 한 번 더 스케줄링 사이클을 거친 뒤” 같은 미묘한 순서가 필요할 때입니다. 구현별로 post와 거의 동일할 수 있으므로, 팀·버전에 맞춰 문서를 확인하는 것이 좋습니다.
재진입 버그: 실제 사례와 해결
상황: 연결 목록 sessions_를 순회하며 dispatch(strand_, ...)로 각 세션에 알림을 보냅니다. 어떤 알림 처리 중에 세션이 끊기며 같은 Strand 핸들러 안에서 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(); });
6. 성능 비교
post vs dispatch 벤치마크 관점
동일 io_context 스레드에서 “이미 그 executor를 실행 중인” 경로에 대해:
- dispatch: 큐 왕복 없이 동기에 가깝게 호출될 수 있어, 마이크로초 단위 지연에서 차이가 날 수 있습니다.
- post: 항상 큐 삽입 + 이후 디스패치를 거치므로, 초당 수백만 번 호출되는 극한 핫패스에서는 오버헤드가 누적될 수 있습니다.
벤치마크를 직접 돌릴 때는 다음을 고정합니다.
- 단일 스레드
io_context::runvs 멀티 스레드 풀 - 핸들러가 같은 strand 안에서 호출되는지 여부
- 측정 구간에 OS 스케줄러·전력 절전 영향을 줄이도록 워밍업 루프
상대적인 결과의 경향(환경마다 숫자는 다름): 같은 스레드·같은 executor 실행 맥락에서 dispatch가 post보다 평균·꼬리 지엘이 작게 나오는 경우가 많습니다. 반대로 다른 스레드에서 post만 할 때는 둘 다 큐에 들어가므로 차이가 줄어듭니다.
지연 시간 측정 방법
- 고해상도 시계:
std::chrono::steady_clock또는 플랫폼별clock_gettime으로, 스케줄 직전 타임스탬프와 핸들러 첫 줄 타임스탬프의 차이를 기록합니다. - 퍼센타일: p50 / p95 / p99를 같이 보고, 꼬리가 길면 큐 적체·락 경합을 의심합니다.
- Strand 사용 시: “strand에 넣은 시점 → 실제 실행 시점”만 따로 로깅하면, post만 과다 사용으로 인한 추가 한 턴 지연을 확인하기 좋습니다.
언제 무엇을 쓸지 결정 트리
flowchart TD
A[작업을 executor에 맡길 차례] --> B{같은 executor/ strand에서<br/>이미 실행 중인가?}
B -->|아니오| C[post 또는 defer<br/>큐에 넣기]
B -->|예| D{재진입해도<br/>객체 불변식이 안전한가?}
D -->|아니오| E[post로 한 턴 미루기]
D -->|예| F{지연을 최대한<br/>줄여야 하는가?}
F -->|예| G[dispatch]
F -->|아니오| H[안전 우선이면 post]
한 줄 요약: 다른 스레드/맥락에서 오면 post가 기본이고, 같은 strand 안에서만 dispatch vs post를 재진입·지연 트레이드오프로 고릅니다.
7. 디버깅
재진입 버그를 찾는 방법
- 같은 Strand 핸들러 안에서
dispatch로 같은 객체의 메서드를 다시 부르는지 검색합니다. - 스택 깊이가 비정상적으로 깊어지면, dispatch 연쇄를 의심합니다. 디버거에서 중단점을 걸고 호출 스택을 확인합니다.
- 정적 분석: “콜백 안에서 컨테이너 수정 + 외부 순회” 패턴을 코드 리뷰 체크리스트에 넣습니다.
로깅으로 실행 순서 추적
- 전역 또는 스레드 로컬 시퀀스 번호를
++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 문서 재확인, 테스트로 순서 고정 |
8. 고급 주제
executor와의 관계
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 코루틴과의 조합
Boost.Asio 1.74+ 및 실행자 기반 코루틴에서, **co_await**는 내부적으로 completion handler를 executor에 제출합니다. strand와 함께 쓸 때는 bind_executor(strand_, ...) 또는 awaitable에 executor를 명시해 모든 서스펜션 지점이 같은 strand로 돌아가게 하는 것이 안전합니다.
// 개념 스케치: strand에 한 번 스케줄한 뒤 이어서 진행 (필요할 때만)
boost::asio::awaitable<void> session::loop() {
while (socket_.is_open()) {
co_await boost::asio::post(strand_, boost::asio::use_awaitable);
// 이후 async_read/async_write 등은 bind_executor(strand_, …)와 함께
}
}
코루틴에서는 스택이 끊겨 보여 재진입이 덜 눈에 띄지만, 동일 공유 상태에 여러 재개 지점이 생기므로, 결국 executor 직렬화 규칙은 콜백 기반과 같습니다. dispatch를 코루틴 본문에서 남발하면 동기 재진입이 될 수 있으므로, 공유 자료 구조에는 post나 명시적 큐를 우선 고려합니다.
9. 정리
- post: 무조건 큐에 넣음. 현재 스택에서 즉시 실행되지 않음. 재진입 방지에 유리.
- dispatch: 가능하면 즉시 실행. 지연 최소화에 유리하나, 재진입 가능성 있음.
- defer: 다음 실행 기회로 미룸. 보통 post와 유사하게 동작.
이 셋을 구분하면 “언제 작업을 큐 맨 뒤로 보낼지”, “언제 당장 실행할지”를 정밀하게 제어할 수 있어, Asio 기반 고성능 서버의 지연과 안정성을 다루는 데 필수입니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. post, dispatch, defer를 구분하지 못하면 진정한 Asio 마스터가 될 수 없습니다. 지연 시간을 줄이기 위해 당장 실행할지, 무조건 큐의 맨 뒤로 보낼지 결정하는 스케줄링 기법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
다음 글: [C++ 고성능 네트워크 가이드 #5] 핸들러 메모리 최적화: 동적 할당(new/delete) 오버헤드 찢어버리기
아키텍처 다이어그램
graph TD
A[시작] --> B{조건 확인}
B -->|예| C[처리 1]
B -->|아니오| D[처리 2]
C --> E[완료]
D --> E
설명: 위 다이어그램은 전체 흐름을 보여줍니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ Strand | 락(Lock) 없는 동시성 제어 [#3]
- C++ 고성능 네트워크 가이드 시리즈 목차 | Boost.Asio·이벤트 루프·코루틴
- C++ 핸들러 메모리 최적화 | 동적 할당 오버헤드 제거 [#5]
10. 실전 체크리스트
post를 써야 하는 경우
- 재진입이 객체 불변식을 깨뜨릴 수 있는데, 같은 executor/strand 핸들러 안에서 추가 작업을 예약해야 할 때
- 공유 컨테이너를 순회하는 중에, 콜백이 그 컨테이너를 수정할 수 있을 때 (브로드캐스트·퇴장 처리 등)
- 호출 스택을 얕게 유지해야 할 때 (깊은
dispatch연쇄 방지) - 다른 스레드에서 온 작업을 항상 한 턴 뒤에 객체 로직과 합치고 싶을 때
- defer와 구현상 거의 같아도, “무조건 다음에”가 명확한 post가 팀 규칙에 맞을 때
dispatch를 써야 하는 경우
- 같은 strand/executor 맥락에서 이미 실행 중이고, 재진입이 안전하다고 증명할 수 있을 때
- 지연을 최소화해야 하는 핫패스(상태 머신 한 단계, 직렬화된 소량의 동기 후속 작업)
- 큐 왕복 비용이 측정상 병목이고, 불변식이
dispatch의 동기 호출에도 유지될 때 - 외부에서
io_context::post로 넣은 작업이 아니라, 이미 strand 안에서 “바로 이어서” 해도 되는 작업
defer를 고려할 경우
- post와 동일하게 “지금 스택에서 실행하지 않음”이 필요하고, 구현체가 제공하는 “현재 실행 사이클 이후로” 미루는 의미가 요구사항과 맞을 때
- 이벤트 루프의 한 번의
run/poll처리 묶음과 맞추고 싶은 스케줄링이 문서/버전에 설명되어 있을 때 - 팀에서 defer = post로 표준화했거나, 반대로 defer만 쓰는 래퍼로 추상화했을 때 — 혼용 시 순서 가정을 테스트로 고정할 것
공통: API 선택이 코드에 주석 한 줄(왜 post인지/dispatch인지)로 남아 있는지, 리뷰에서 재진입·순서를 질문할 수 있는지 확인하세요.
관련 글
- C++ 멀티스레드 Asio의 딜레마 | Data Race와 Mutex의 한계 [#2]