본문 바로가기
잡(job)기술

C++20 코루틴 + Asio 비동기 기초: io_context부터 concurrent_channel, executor 표기까지

by 무니이구나 2026. 9. 22.

💡 한 줄 요약

  • 이 글은 C++20 코루틴으로 Asio 비동기 파이프라인을 처음 짤 때 발생하는 io_context/awaitable/co_spawn/concurrent_channel이 어떻게 맞물리는지 감이 안 잡히는 문제를 각 요소의 역할과 실행 시점 분리를 통해 생산자-소비자 예제 코드로 해결하는 과정을 다룬다.

🚨 1. 문제 상황 및 대상 독자

  • 현상: C++20 코루틴 문법(co_await, co_return)은 익숙한데, Asio로 넘어오면 io_context, asio::awaitable, asio::co_spawn, asio::experimental::concurrent_channel이 각각 뭘 담당하는지, 그리고 이 넷이 실행 시점에 어떤 순서로 맞물리는지 감이 안 잡혀서 막히는 경우가 많다.
  • 대상 독자: C++20 코루틴은 알지만 Asio의 실행 모델(executor, 이벤트 루프)은 처음 접하는 실무 개발자

⚠️ 공통적인 Pain Point
awaitable<void> compute() { ... } 형태의 코루틴 함수를 만들어놓고 그냥 compute();처럼 호출하면 아무 일도 일어나지 않는다. 코루틴 함수가 "실행 준비된 객체"만 반환할 뿐, 실제로 스케줄러에 올려 돌리는 별도 단계가 빠졌기 때문인데, 처음 접하면 이 지점에서 막히기 쉽다.


🛠️ 2. 사전 지식 & 개발 환경

2.1 개발 환경

분류 기술 스택 / 도구 버전 비고
OS Ubuntu 24.04  
언어/기반 C++20 (코루틴 지원 컴파일러) g++ 13.3.0 GCC 10+/Clang 14+/MSVC 19.28+ 권장
라이브러리 Asio (standalone 또는 Boost.Asio) 1.28.1 concurrent_channel은 asio::experimental 네임스페이스의 실험적 기능

🔍 3. 핵심 개념 및 문제 재현

3.1 네 가지 핵심 요소 한눈에 보기

요소 역할
io_context 이벤트 루프(스케줄러). run()을 호출해야 등록된 작업이 실제로 처리됨
asio::awaitable<T> 코루틴 함수의 반환 타입. 그 자체로는 실행되지 않고 "일시정지 가능한 실행 단위"만 생성
asio::co_spawn awaitable을 io_context(또는 executor) 위에 등록해 실제 실행을 시작시키는 함수
asio::experimental::concurrent_channel 코루틴/스레드 간 비동기 메시지 큐. async_send/async_receive로 생산자-소비자 패턴을 구현하며, 내부적으로 스레드 안전

 

핵심은 "객체를 만드는 것"과 "실제로 실행되는 것"이 분리되어 있다는 점이다. io_context는 아무것도 등록되지 않으면 텅 비어 있고, awaitable을 반환하는 코루틴 함수를 호출해도 그 자체로는 실행되지 않으며, co_spawn으로 등록한 뒤 ctx.run()을 호출해야 비로소 전체 흐름이 진행된다.

3.2 문제 재현 — 최소 생산자·소비자 예제

#include <asio.hpp>
#include <asio/experimental/concurrent_channel.hpp>
#include <iostream>

using Chan = asio::experimental::concurrent_channel<void(asio::error_code, int)>;

// 생산자 코루틴: 채널에 0~4를 순서대로 전송
asio::awaitable<void> producer(Chan& ch) {
    for (int i = 0; i < 5; ++i) {
        // async_send는 채널 버퍼가 가득 찼을 때 non-blocking하게 대기
        co_await ch.async_send(asio::error_code{}, i, asio::use_awaitable);
    }
    ch.close(); // 더 보낼 게 없으면 채널을 닫아 소비자에게 종료를 알림
}

// 소비자 코루틴: 채널이 닫힐 때까지 값을 받아 출력
asio::awaitable<void> consumer(Chan& ch) {
    for (;;) {
        auto [ec, val] = co_await ch.async_receive(
            asio::as_tuple(asio::use_awaitable));
        if (ec) break; // 채널이 닫히면 error_code가 설정되고 루프 종료
        std::cout << "received: " << val << "\n";
    }
}

int main() {
    asio::io_context ctx;
    Chan ch(ctx.get_executor(), /*max_buffer_size=*/2); // 버퍼 크기 2

    // co_spawn으로 두 코루틴을 io_context에 등록 (아직 실행은 안 됨)
    asio::co_spawn(ctx, producer(ch), asio::detached);
    asio::co_spawn(ctx, consumer(ch), asio::detached);

    ctx.run(); // 여기서부터 실제로 producer/consumer가 진행됨
}

 

이 예제만 놓고 보면 co_spawn 호출 두 줄까지는 아무 출력도 없다가, ctx.run()이 호출되는 순간 producer가 채널에 값을 넣고 consumer가 그 값을 받아 출력하는 흐름이 시작된다. "등록"과 "실행"이 분리되어 있다는 감각을 이 예제로 확인하는 것이 핵심이다.


✅ 4. 해결 방법 및 검증

4.1 채널 생성 시 실행 컨텍스트 표기법: ctx vs ctx.get_executor()

위 예제에서 Chan ch(ctx.get_executor(), 2);라고 썼는데, 다음처럼 io_context를 직접 넘겨도 동작한다.

Chan ch(ctx, 2);                // io_context를 직접 전달
Chan ch(ctx.get_executor(), 2); // executor를 명시적으로 전달 (권장)

 

Asio의 I/O 객체 생성자는 ExecutionContext를 만족하는 타입(예: io_context)을 받으면 내부에서 자동으로 .get_executor()를 호출해 executor로 변환하기 때문에, 두 표기는 완전히 동일한 결과를 낸다.

표기 의미
Chan ch(ctx, 2) io_context를 넘김 → 암묵적으로 ctx.get_executor()로 변환됨
Chan ch(ctx.get_executor(), 2) executor를 명시적으로 넘김

 

그럼에도 get_executor()를 명시하는 쪽을 권장하는 이유는 두 가지다.

  1. executor가 io_context 전용 개념이 아님을 드러냄 — Asio는 thread_pool, strand<Executor> 등 다양한 executor를 지원하므로, executor를 명시적으로 다루는 습관이 나중에 실행 모델을 바꿀 때(예: 특정 채널만 별도 strand에 묶기) 헷갈림을 줄여준다.
  2. strand 전환이 코드 한 줄로 가능 — io_context를 직접 넘기는 표기로는 strand executor를 끼워 넣을 수 없지만, executor를 넘기는 구조라면 asio::make_strand(ctx)로 바꿔 끼우기만 하면 된다.
// 특정 채널을 strand에 묶고 싶다면 executor 표기가 자연스럽게 확장됨
auto strand_ex = asio::make_strand(ctx);
Chan ch(strand_ex, 2);

4.2 결과 검증

위 예제(3.2)를 그대로 컴파일·실행하면, 버퍼 크기가 2인 채널을 통해 producer가 보낸 0~4가 consumer 쪽에 순서대로 출력되는 것을 확인할 수 있다.

received: 0
received: 1
received: 2
received: 3
received: 4

💡 5. 고급 정보

흔한 실수

  • co_spawn 없이 awaitable 함수만 호출: producer(ch);처럼 그냥 호출하면 컴파일은 되지만 아무 일도 일어나지 않는다. 코루틴을 스케줄러에 올리는 건 반드시 co_spawn의 역할이다.
  • ctx.run()을 호출하지 않음: co_spawn으로 등록만 해두고 run()을 안 부르면 이벤트 루프가 아예 돌지 않아 등록된 코루틴들이 전혀 진행되지 않는다.
  • 채널을 닫지 않아 consumer가 영원히 대기: producer 쪽에서 ch.close()를 호출하지 않으면 consumer의 async_receive가 계속 대기 상태로 남아 ctx.run()이 끝나지 않는다.

FAQ

  • Q. asio::detached 대신 결과를 받고 싶으면 어떻게 하나?
    A. 세 번째 인자에 asio::use_future를 넘기면 std::future로 완료를 기다릴 수 있고, 다른 코루틴 안에서라면 co_spawn 결과 자체에 co_await를 걸 수도 있다. 두 방식을 코드로 비교하면 다음과 같다.
  asio::awaitable<int> compute() {
      co_return 42;
  }

  // 방식 1: use_future — 코루틴이 아닌 일반 함수(main 등)에서 결과를 기다릴 때
  void run_with_future(asio::io_context& ctx) {
      std::future<int> fut = asio::co_spawn(ctx, compute(), asio::use_future);

      std::thread runner([&ctx] { ctx.run(); }); // run()은 블로킹되므로 별도 스레드 필요
      int result = fut.get();                    // 완료될 때까지 대기
      runner.join();

      std::cout << "result: " << result << "\n";
  }

  // 방식 2: co_await — 다른 코루틴 안에서 결과를 기다릴 때 (더 흔한 패턴)
  asio::awaitable<void> caller(asio::io_context& ctx) {
      // co_spawn에 use_awaitable을 넘기면 co_await로 결과를 받을 수 있음
      int result = co_await asio::co_spawn(ctx, compute(), asio::use_awaitable);
      std::cout << "result: " << result << "\n";
  }

 

use_future는 코루틴 바깥(예: main())에서 결과가 필요할 때, co_await는 이미 다른 코루틴 안에 있어서 그 흐름을 그대로 이어가고 싶을 때 쓴다. 이 글의 producer/consumer 예제처럼 결과값 자체가 필요 없고 그냥 "돌려놓고 잊어버리는" 경우에만 asio::detached가 적합하다.


🏁 6. 마무리

6.1 결론

io_context는 실행 엔진, awaitable은 실행 단위, co_spawn은 그 둘을 잇는 등록 함수, concurrent_channel은 코루틴 간 통신 수단이다. "객체 생성/등록"과 "실제 실행"이 분리되어 있다는 감각만 잡으면 나머지는 이 네 요소의 조합으로 자연스럽게 이해된다.

6.2 참고 자료

6.4 다음 단계

다음 글에서는 멀티스레드 io_context::run() 환경에서 필요한 strand의 동작 원리와, GPU 프로그래밍에 익숙한 독자를 위한 CUDA Stream과의 비교를 딥다이브로 다룬다.