C++

[C++] 코루틴(Coroutine) 분석(작성 중)

Elan 2024. 9. 15. 22:43

코루틴의 원리를 이해하기 위한 배경지식

▼▼▼

더보기



 

1.  연관된 용어 정리


설명에서 사용될 용어들을 미리 정리해 보자.

// 함수(Function)
int mul(int a, int b);

int mul(int a, int b) {
    return a * b;
}

// Assembly들의 집합 (Routine)
int mul(int,int) PROC
    mov     DWORD PTR [rsp+16], edx
    mov     DWORD PTR [rsp+8], ecx
    mov     eax, DWORD PTR a$[rsp]
    imul    eax, DWORD PTR b$[rsp]
    ret     0
int mul(int,int) ENDP


루틴(Routine) : 명령들의 집합체라고 보면 된다.

// 루틴(Routine): 명령들의 (순서있는)집합

int mul(int,int) PROC
    mov     DWORD PTR [rsp+16], edx  ; 두 번째 인자(b)를 스택에 저장
    mov     DWORD PTR [rsp+8], ecx   ; 첫 번째 인자(a)를 스택에 저장
    mov     eax, DWORD PTR a$[rsp]   ; a 값을 eax 레지스터로 로드
    imul    eax, DWORD PTR b$[rsp]   ; eax와 b 값을 곱하여 결과를 eax에 저장
    ret     0                        ; 함수 반환
int mul(int,int) ENDP

// 명령(Instruction): 
// - 기계의 동작(behavior)을 추상화한 것
// - 기계 상태가 전이(Transition)됨

// 루틴 == 명령[]
// 루틴은 여러 개의 명령으로 구성된 배열 또는 시퀀스로 볼 수 있음


호출(Invocation) : 루틴의 시작점으로 Jump
활성화(Activation) : 루틴 안의 임의 지점으로 Jump
중단(Suspension) : 현재 루틴을 종결하지 않고, 다른 루틴의 임의 지점으로 Jump
종결(Finalization) : 현재 루틴의 끝에 도달하면 루틴 상태의 소멸 및 정리

 

서브 루틴(Subroutine)이란?


호출(Invocation) or 종결(Finalization)할 수 있는 루틴을 말한다.
즉, 특정 작업을 수행하는 코드 블록으로,
호출되면 실행을 시작하고 완료 후 호출자에게 제어를 반환하는 것이다.

// 서브루틴(Subroutine): 호출/종결할 수 있는 루틴

// get_zero 서브루틴 정의
int get_zero(void) PROC
    xor    eax, eax    ; eax 레지스터를 0으로 초기화
    ret    0           ; 0을 반환하고 서브루틴 종료 : Return(Finalize)
int get_zero(void) ENDP

// 메인 루틴
main PROC
$LN3:
    mov    QWORD PTR [rsp+16], rdx
    mov    DWORD PTR [rsp+8], ecx
    sub    rsp, 40                 ; 스택 공간 확보
    call   int get_zero(void)      ; get_zero 서브루틴 호출 : Call(Invoke)
    add    rsp, 40                 ; 스택 정리
    ret    0                       ; 메인 루틴 종료
main ENDP

// _formal$ = 48
// _formal$ = 56

// 설명:
// 1. 서브루틴은 호출(Invoke)과 종결(Finalize)이 가능한 루틴입니다.
// 2. get_zero 서브루틴은 0을 반환하는 간단한 함수입니다.
// 3. 메인 루틴에서 get_zero 서브루틴을 호출(call)합니다.
// 4. 서브루틴 호출 전후로 스택 조정 작업이 이루어집니다.
// 5. ret 명령어로 각 루틴이 종료(Finalize)됩니다.

그래서 서브루틴이 코루틴과 무슨 상관?


서브 루틴은 진입점이 하나이며, 종료 시 항상 호출자로 돌아간다.
또한 실행 중 상태를 유지하지 않으며, 매번 호출 시 처음부터 실행된다.
호출 스택을 사용하여 호출-반환 구조를 관리한다.

서브루틴 실행 흐름 : 호출 → 실행 → 반환의 단일 흐름

즉, 서브루틴은 호출 간 상태를 유지하지 않으며 호출 스택만 사용한다.




코루틴(Coroutine)은 실행을 중간에 중단하고 나중에 중단된 지점부터 재개할 수 있는 일반화된 서브루틴이다.

여러 진입점과 중단점을 가질 수 있다.
실행 상태를 유지하며, 중단된 지점부터 재개할 수 있다.
협력적 멀티태스킹을 가능하게 한다.


코루틴 실행 흐름: 호출 → 실행 → 중단 → ... → 재개 → 실행 → 중단 → ... → 완료의 복잡한 흐름

코루틴은 중단과 재개 사이에 상태를 유지해야 하기 때문에 코루틴 프레임이 추가로 필요하다.

호출 스택 (Call Stack)

호출 스택은 함수 프레임을 관리하는 방법 중 하나.

1. 호출 == 프레임 Push (스택에 새로운 함수 프레임 추가)
2. 반환 == 프레임 Pop (스택에서 함수 프레임 제거)

함수 호출 과정 예시:
1. main() 함수 시작
2. main()에서 a() 함수 호출
3. a() 함수에서 b() 함수 호출
4. b() 함수 실행 완료 후 a()로 반환
5. a() 함수에서 c() 함수 호출
6. c() 함수 실행 완료 후 a()로 반환
7. a() 함수 실행 완료 후 main()으로 반환
8. main() 함수 종료



호출 스택 (Call Stack) 시각화 :

[시작 상태]
|          |
|  main()  |  <- 스택 맨 아래에 main() 프레임
|__________|

[main()에서 a() 호출]
|          |
|   a()    |  <- a() 프레임 추가
|__________|
|  main()  |
|__________|

[a()에서 b() 호출]
|          |
|   b()    |  <- b() 프레임 추가
|__________|
|   a()    |
|__________|
|  main()  |
|__________|

[b() 함수 종료, a()로 반환]
|          |
|   a()    |  <- b() 프레임 제거
|__________|
|  main()  |
|__________|

[a()에서 c() 호출]
|          |
|   c()    |  <- c() 프레임 추가
|__________|
|   a()    |
|__________|
|  main()  |
|__________|

[c() 함수 종료, a()로 반환]
|          |
|   a()    |  <- c() 프레임 제거
|__________|
|  main()  |
|__________|

[a() 함수 종료, main()으로 반환]
|          |
|  main()  |  <- a() 프레임 제거
|__________|

[프로그램 종료]
|          |  <- 스택 비움
|__________|

설명:

  호출 스택의 구조 
  - 가장 아래(Lower memory): main() 함수의 프레임
  - 위로 올라갈수록(Higher memory): 호출된 함수들의 프레임이 쌓임

1. 프로그램 시작 시 main() 프레임이 스택의 맨 아래에 위치한다.
2. 함수 호출 시 새로운 프레임이 스택 상단에 추가(Push)된다.
3. 함수 종료 시 해당 프레임이 스택에서 제거(Pop)된다.
4. 이 과정을 통해 프로그램의 실행 흐름과 각 함수의 컨텍스트가 관리된다.
5. 스택의 가장 위에 있는 프레임이 현재 실행 중인 함수를 나타낸다.

이 구조를 통해 함수 호출의 중첩, 지역 변수의 생명주기, 그리고 실행 컨텍스트의 관리가
효율적으로 이루어진다.


중요 포인트:
- 서브루틴(함수)에 매우 적합한 구조
- C 언어의 모든 함수는 서브루틴으로 구현됨

이 구조를 통해 프로그램의 실행 흐름과 각 함수의 지역 변수, 매개변수 등을 효율적으로 관리할 수 있다.



2.  Stack frame


하나의 프로세스에 대하여

쓰레드들은 공통된 메모리 공간(Data Section, Code Section, Heap 등)을 공유하지만

Stack은 예외이다.

즉, Thread마다 독립적인 Stack 공간을 갖는다.


앞으로 학습할 C++ Stackless 코루틴은

컴파일러의 도움으로 이 Stack 프레임과 코루틴 프레임을 이용하여

실행 흐름이 마치 Multi-thread 처럼 느껴지도록 할 수 있게 된다.



1) 함수 종류

 

  • 프레임 함수(Frame Function)

    : 프레임 함수는 자신만의 스택 프레임(Stack Frame)**을 만드는 함수다.

    프레임 함수가 하는 일은 다음과 같다.
    - 지역 변수를 저장
    - 다른 함수를 호출할 때 그 함수의 리턴 주소와 매개변수 등을 저장하기 위한 스택 공간 할당
    - 비휘발성 레지스터를 저장
    - 예외 발생 시 이를 복구하거나 처리하기 위해 필요한 정보 저장


    원리는 다음과 같다.
    함수의 *프롤로그(prologue)에서 스택 포인터(RSP 혹은 ESP)를 조정하여 스택 프레임을 설정하고,
    *에필로그(epilogue)에서 스택 포인터를 복원하여 프레임을 제거하는 식이다.



  • 리프 함수 (Leaf Function)

    : 리프 함수는 다른 함수를 호출하지 않는 함수를 의미한다.

    리프 함수는 다른 함수를 호출하지 않기 때문에 별도의 스택 프레임을 만들지 않아도 된다.

    즉, 스택 프레임 없이도 동작할 수 있다.

    리프 함수는 성능 최적화에 유리하며, 
    함수 호출 시 스택 포인터의 변경을 최소화하기 때문에 더 효율적이다.


  • 함수의 프롤로그(Prologue)

    : 프롤로그는 함수가 시작될 때 수행되는 코드로, 스택을 설정하는 작업을 한다.

    프롤로그에서 하는 일은
    호출자(Caller)의 레지스터와 반환 주소를 스택에 저장,
    스택 포인터를 이동하여 스택 프레임을 할당,
    함수에서 사용할 지역 변수 공간을 스택에 확보이다.


    예시 (x86 어셈블리) :
push rbp        ; 이전 베이스 포인터를 저장
mov rbp, rsp    ; 스택 포인터를 새 베이스 포인터로 설정
sub rsp, 0x20   ; 지역 변수 공간을 스택에 할당

      예시 (x64 어셈블리) :

mov     [rsp+8],rcx         ; store parameter in shadow space if necessary
push    r14                 ; save any non-volatile registers to be used
push    r13                 ;
sub     rsp,size            ; allocate stack for local variables if needed
lea     r13,[bias+rsp]      ; use r13 as a frame pointer with an offset

 

  • 함수의 에필로그(Epilogue)

    : 에필로그는 함수가 종료될 때 수행되는 코드로, 스택을 정리하는 작업을 한다.

    주로 하는 일은
    - 스택 포인터를 복원하여 스택 프레임을 해제
    - 이전의 레지스터와 리턴 주소를 복원
    - 함수 호출을 끝내고 호출자로 복귀


    예시 (x86 어셈블리) :
add rsp, 0x20   ; 할당된 스택 프레임 해제
pop rbp         ; 이전 베이스 포인터 복원
ret             ; 호출한 함수로 복귀

      예시 (x64 어셈블리) :

프레임 포인터 레지스터가 있든 없든 사용할 수 있는 형식
    lea     rsp,[r13-bias]      ; this is not part of the official epilogue
    add     rsp,size            ; the official epilogue starts here
    pop     r13
    pop     r14
    ret

프레임 포인터 레지스터가 있는 경우 사용해야하는 형식
The following can also be used provided that a frame pointer register has been established:

    lea     rsp,[r13+size-bias]
    pop     r13
    pop     r14
    ret

 

2) 함수 호출 구조와 스택

x64 Calling Convetion


: 위 그림은 함수 호출 시 스택이 일반적으로 어떻게 사용되는지 보여준다.

함수가 호출되면 8바이트짜리 반환 주소가 스택에 자동으로 푸쉬되며,

함수는 사용할 비휘발성 레지스터를 저장한다.

로컬 변수를 위한 추가 공간도 할당할 수 있으며,

필요한 경우 프레임 포인터 레지스터를 할당할 수도 있다.




unwind data를 만들 때 고려해야 할 스택 프레임에는 두 가지 유형이 있다.


Static 방식 :

스택에 고정된 공간만 할당하고
다른 함수를 호출하는 동안을 제외하고는
함수 본문 내에서 스택 포인터의 값이 고정된 상태로 유지된다.
이 유형의 스택 프레임에서는 프롤로그 끝에 있는 스택 포인터 값이
나중에 설명하는 Unwind primitive 및 macro에서 offset의 기준으로 사용된다.
이때 스택 포인터 값은 16바이트로 정렬되어야 합니다.


Dynamic 방식 :

스택 공간이 동적으로 할당되므로
스택 포인터 값을 정적으로 예측할 수 없고
unwind offset의 베이스로 사용할 수 없다.
이러한 상황에서는 프레임 포인터 레지스터를 사용하여 이 기준 주소를 제공해야 한다.

여기서 unwind offset의 기준은 스택에서 고정 할당 영역의 하단이며,
일반적으로 프레임 레지스터가 할당될 때 스택 포인터의 값이다.

이 값은 16바이트 정렬되어야 하며,
오프셋이 있는 unwind macro를 사용하기 전에 할당되어야 한다.

프레임 포인터 레지스터에서 단일 바이트 오프셋(-128 ~ \+127)으로
최대량의 데이터에 액세스 할 수 있도록 하기 위해서는
할당된 영역의 중앙으로 값을 오프셋 하는 것이 일반적이다("바이어스").

프레임 포인터 레지스터와 16바이트의 배수여야 하는 이 오프셋의 신원은
unwind data에 기록되어 스택 프레임 기본 주소를 프레임 레지스터의 값에서 계산할 수 있도록 한다.



3. 그래서 코루틴(Coroutine)은 어떤데?


(여기서부터는 코루틴을 사용해 보고 오는 편이 이해가 수월할 것이다)


1) 코루틴을 구성하기 위해 필요한 정보



먼저 코루틴은 Multi-threading도 비동기도 아니다.

코루틴은 위에 설명한 스택 프레임 구성들을 응용하여

Context switching을 구현한다.



코루틴 함수는 함수가 반환하지 않아도

코루틴 함수 본문 프레임과 외부 프레임을 넘나들며 명령을 수행할 수 있다.

코루틴 프레임과 외부 스택 프레임의 중단 포인트(suspension point)를 저장해 놓고

왔다갔다하므로써 Context Switching 느낌을 구현한 것이다.




코루틴은 2가지 정보를 따로 저장하기 위한 추가 공간이 필요하다.

첫째, 코루틴의 실행 상태를 저장할 공간.

둘째, 코루틴 함수가 반환하기 전까지 스택 프레임을 넘나들면서도 로컬 변수들을 안전하게 저장하기 위한 공간.


이 공간들은 스택에 할당될 수도 있고, 힙에 할당될 수도 있다.

C++을 포함한 대부분의 언어 표준에서는 힙 영역에 할당하라고 명시하지 않았으나,

대부분의 컴파일러들은 이 공간 할당에 기본적으로 힙을 사용한다.

물론 항상 힙에 할당하는 것은 아니며

컴파일러에 따라서는 자동 혹은 수동으로 특정 상황에서 최적화를 통해 스택 할당이 가능하다.
(간단한 코루틴의 경우 프레임을 스택에 할당하도록 최적화 함)



C++ 컴파일러가 코루틴을 구성하는 방식의 기본 메커니즘을 추상적으로 표현해보았다.

우선 다음과 같은 코루틴 작업을 정의하였다.

task<void> func()
{
  int mylocal = 10;
  co_return;
}


 위 코드의 co_return 키워드를 보고 C++ 컴파일러는 다음과 비슷한 방식의 코드를 변환/생성한다.

// Struct representing the coroutine state
struct func_frame
{
  task<void>::promise_type __promise;
  int __step = 0;

  decltype(__promise.initial_suspend()) __initial_suspend_awaiter;
  decltype(__promise.final_suspend()) __final_suspend_awaiter;

  // Structure to hold local and temporary variables
  struct 
  {
    // Local and temporary variables reside here
    int mylocal;
  } local_and_temps;

  // Function to resume coroutine execution
  void resume()
  {
    switch(__step)
    {
      case 0:
        // co_await __promise.initial_suspend();
        __initial_suspend_awaiter = __promise.initial_suspend();
        if (!__initial_suspend_awaiter.await_ready())
        {
          __step = 1;
          __initial_suspend_awaiter.await_suspend();
          return;
        }
      case 1:
        __initial_suspend_awaiter.await_resume();
        // .. func body
        mylocal = 10;
        // co_return
        __promise.return_void();
        // co_await __promise.final_suspend();
        __final_suspend_awaiter = __promise.final_suspend();
        if (!__final_suspend_awaiter.await_ready())
        {
          __final_suspend_awaiter.await_suspend();
          return;
        }
        delete this;
    }
  }
};

// Coroutine function transformed into a coroutine frame
task<void> func()
{
  func_frame * frame = task<void>::promise_type::operator new(func_frame);
  task<void> ret = frame->__promise.get_return_object()
  frame->resume();
  return ret;
}

코루틴 함수 func()를 실행하면 가장 먼저

코루틴 실행에 필요한 코루틴 프레임을 할당하고,

다음으로 코루틴의 상태와 결과를 반환할 코루틴 객체를 get_return_object()라는 함수를 통해 생성한다.

그리고 코루틴 함수 바디 본문의 실행 시작을 위해 코루틴 프레임의 resume()을 호출하고 있다.


만약 변수가 많고, 중단 지점이 여러개라면 local_and_temps에 더 많은 멤버가 존재할 것이다.

이런식으로 컴파일러는 코루틴의 생명 주기(lifetime)와 프레임 레이아웃을 명확히 결정하면

HALO(coroutine Heap Allocation eLision Optimization) 기술을 사용하여 최적화된 코루틴은

로컬 변수 내에 일반 함수 또는 다른 코루틴 일 수 있는 func_frame의 복사본을 포함시킬 수 있게 된다.


간혹 첫 번째 중단 지점에서 온디맨드로 할당할 수 있다고 오해하는 경우가 있는데

모든 로컬 변수와 임시 변수를 참조와 함께 재배치해야 하기 때문에

컴파일러가 ABI 경계를 넘어 추적할 수 없어서 실현 가능하지 않다고 한다.


* 다시 말하지만 위 코드는 코루틴 메커니즘을 이해하기 위한 추상적 코드이다.




현재 C++ 20을 구현한 메이저 컴파일러(MSVC, GCC, Clang)를 기준으로 작성됨.





2) Stackful Coroutine    vs    Stackless Coroutine


대표적인 코루틴 타입 2가지에 대해 알아보자.

  • Stackful Coroutine 방식 :

    - 각 Coroutine마다 별도의 스택 메모리를 할당해야 하므로 메모리 사용량이 크다.
    - Context Switcing 시 전체 스택을 저장/복원하므로 Context Switcing 비용이 크다.
    - 대신 구현이 상대적으로 간단하다.
    - 임의의 함수 호출 지점에서 yield가 가능하다.


  • Stackless Coroutine 방식 :

    - Coroutine은 자체 스택을 가지지 않으므로 스택 메모리를 적게 차지한다.
      대신 Coroutine의 상태를 저장하기 위한 최소한의 프레임 객체는 필요하다.
    - Context Switcing 시 최소한의 상태만 저장/복원하기 때문에 Context Switcing 비용이 적다.
    - 구현이 더 복잡하기 때문에 컴파일러의 지원이 필요하다.
    - 일반적으로는 특정 지점에서만 yield가 가능하다.


C++에서 Stackless 방식 Coroutine을 채택한 이유(N4402 인용) :


재개 가능 함수(Resumable functions) 개정의 설계 목표는
C++ 언어와 표준 라이브러리를 확장하여 다음 특성을 가진 코루틴을 지원하는 것이다:

  • 확장성이 뛰어남(수십억 개의 동시 코루틴까지).
  • 함수 호출 오버헤드와 비슷한 비용으로 매우 효율적인 작업 재개 및 일시 중단.
  • 오버헤드 없이 기존 facilities과의 원활한 상호 작용.
  • 라이브러리 설계자가 코루틴 라이브러리를 개발할 수 있는 개방형 코루틴 머신으로
  • 제너레이터, 고루틴, 태스크 등과 같은 다양한 고수준 시맨틱을 노출합니다.
  • 예외가 금지되거나 사용할 수 없는 환경에서도 사용 가능



'확장성과 오버헤드 없이 기존 것들과의 원활한 상호 작용'이라는 설계 목표
(즉, 기존 라이브러리 및 OS API에 대한 기존 라이브러리 및 OS API를 제한 없이 호출하는 것)를 위해서는
'Stackless Coroutine'이 필요하다.

모든 코루틴에 대해 기본 스택을 예약하는 일반적인 'Stackful Coroutine'의 경우
32비트 주소 공간에서(Windows의 경우 기본 1MB, Linux의 경우 기본 2MB)는
사용 가능한 모든 가상 메모리를 단 몇 천 개의
코루틴으로 32비트 주소 공간에서 사용 가능한 가상 메모리를 소진하게 될 수 있다. 

'Stackful Coroutine'은 가상 메모리를 소모하는 것 외에도 메모리 조각화로 이어진다.
왜냐하면 일반적인 스택 구현에서는 가상 메모리를 예약하는 것 외에도
플랫폼이 먼저 두 페이지의 스택(하나는 스택으로 사용되는 읽기/쓰기 액세스, 
다른 하나는 가드 페이지 역할을 하는
자동 스택 증가를 구현하기 위해)을 커밋하는데,
코루틴의 상태를 저장하기 위해 필요한 메모리가 몇 바이트 되지도 않는 상황에서도 그러하다.

'Split-stack'을 사용하는 것과 같은 완화 접근 방식을 사용하려면
전체 프로그램(호출하는 모든 라이브러리 및 호출하는 모든 OS 기능 포함)을 분할 스택으로 컴파일하거나,
컴파일되지 않은 코드를 호출할 때 런타임 페널티가 발생한다.
이러한 작은 고정 크기 스택을 사용하는 것과 같은 완화 접근 방식은
이러한 스택에서 호출할 수 있는 것을 제한한다.
코루틴에서 호출할 수 있는 함수가 작은 고정 스택에 할당된 것보다 더 많은 메모리를
소비하지 않도록 보장해야 한다.

(참고 : go언어의 런타임에서 이러한 기법(세그먼티드 스택)들을 사용하여 goroutine의 스택 관리를 최적화한다
)


더 자세히 알고 싶다면 참고 문헌 참조.



 




 

▲▲▲




C++ 컴파일러 별 Coroutine 사용 옵션 설정법




MSVC(Visual Studio 2015 later) :



프로젝트 속성  >  C/C++  >  Language  >  C++ Language standard 탭 > C++ 20 선택
프로젝트 속성  >  C/C++  >  Additional Options  >  /await:strict  추가

C++20 선택
/await:string 옵션 적용


※ 주의 사항

옛날 글을 검색해 보면 experimental/coroutine을 사용하기 위한 /await 옵션이 나온다.
하지만 C++20 표준에 맞는 코루틴 구현을 MSVC에서 사용하려면 /await:strict 옵션을 사용해야 한다.
참고로 /await은 MSVC 초기에
#include <experimental/coroutine> 구문과 함께
experimental coroutine 구현 사용을 위한 옵션이다.




※ pre-compiled header관련 에러


 Visaul Studio에서 C++20 표준 coroutine을 사용하기 위해
보통은 프로젝트 전체에 /await:strict 옵션을 적용하여 사용하는 경우
다음과 같은 에러가 발생할 수도 있다.

이 에러는 표준 코루틴을 구현할 때 컴파일러들이
코루틴 예약어를 탐지하는 방식 때문에 발생할 수 있으며,

해당 에러 관련 내용을 검색해 보면
단순히  pre-compiled headers 옵션을 /Yu에서 /Yc로 바꾸라는 내용의
MSDN페이지 링크가 걸려있다.

이 방법으로 해결이 될지도 모르지만 그다지 이 방법으로는 해결이 잘 안 될 것이며,
관련이 없다고 봐도 무방한 그냥 버그라고 생각하면 된다..

원인은 IDE에서 소스 코드 파일을 추적하기 위해 소스 코드별로 옵션을 붙여놓는 방식 때문에
다른 프로젝트에서 소스 파일을 복사해 올 때 주로 발생한다.

다만 소스 코드 파일에 해당 옵션 관련 데이터가 직접 붙어있진 않기 때문에 쉽게 해결이 가능하다.

그냥 코드 파일을 프로젝트에서 제거(Remove)했다가 다시 추가하면 된다.


그래도 안 되는 경우 프로젝트에서 해당 코드 파일을 삭제한 뒤 새로 만들어서
코드만 다시 붙여 넣으면 된다.

원인을 모르면 하루 종일 헤매게 되는 오류다.

 

Clang(v5.0 later) :



-std=c++20 -fcoroutines-ts  추가






GCC(v 10.0 later) :



-std=c++20 -fcoroutines  추가




Intel C++ Compiler(ICC 19 later) :


-std=c++20
즉, 코루틴을 위한 별도의 옵션이 필요 없고 C++20 이상이면 가능






C++ Coroutine이 동작하는 방식



https://en.cppreference.com/w/cpp/language/coroutines

 

Coroutines (C++20) - cppreference.com

A coroutine is a function that can suspend execution to be resumed later. Coroutines are stackless: they suspend execution by returning to the caller, and the data that is required to resume execution is stored separately from the stack. This allows for se

en.cppreference.com

 

중간중간에 나오는 예제들을 잘 살펴보도록 하자.

※ '참고'가 붙은 내용은 개인적으로 이해를 돕기위해 추가한 내용이다.


1. 컴파일러가 Coroutine함수로 인식하는 방법

다음 예약어 중 한 가지라도 포함되어 있으면 Coroutine 함수로 인식한다.(Stackless 구성을 위함)

  • co_await
  • co_yield
  • co_return


2. 반환 타입에 대한 제약 (다음을 반환 타입으로 사용할 수 없음)

  • Variadic arguments
  • plain return statements
  • placeholder return type( auto or Concept )

 

3. Coroutine에서 사용할 수 없는 것들

  • Consteval functions
  • Constexpr functions
  • Constructors
  • Destructors
  • main function

 

4. Coroutine 실행 동작


1) Coroutine과 연관된 것들

  • coroutine 내부에서 조작되는 promise 객체 :

    coroutine은 promise 객체를 통해 결과 또는 예외를 제출한다.
    (여기서 말하는 promis 객체는 std::promise와는 관련이 없다)


  • coroutine 외부에서 조작되는 coroutine handle:

    코루틴의 실행을 재개(Resume)하거나
    코루틴 프레임을 파괴(destroy)하는 데 사용되는 소유권이 없는 핸들이다.
    (참고 : 코루틴 핸들(std::coroutine_handle<T>)은 코루틴 객체의 멤버로 명시적으로 정의할 수도 있으며,
     그렇지 않은 경우에도 코루틴 프레임워크가 알아서 코루틴 객체에 내부적으로 코루틴 핸들을 생성한다.
     그리고 await_suspend()메서드는 항상 코루틴 핸들을 인자로 넘겨받기 때문에
      await_suspend()메서드를 통해 코루틴의 다음 동작을 직접 정의할 수 있다)


  • coroutine 상태 :

    내부적으로 동적 할당된 스토리지(할당이 최적화되지 않은 경우)와 객체는 다음을 포함한다.
      - promise 객체
      - 매개 변수(모두 값으로 복사됨)
      - 어디서 재개(resume) 해야 할지 알기 위한 현재 중단점(suspension point)과
        소멸(destory)시켜야 할 Scope 내의 로컬 변수 정보
      - 로컬 변수 및 현재 일시 중단 지점까지의 수명을 가진 임시적인 것들
    (참고 : 내부적으로 동적 할당된 스토리지란 코루틴이 중단&재개 시
     코루틴 상태(위에 언급된 4가지)를 저장하기 위해 할당된 공간을 말한다)

 

2) Coroutine이 실행 시작될 때 수행하는 것들

  • new 연산자를 사용하여 coroutine 상태 객체 할당

  • 함수의 모든 매개변수를 coroutine 상태에 복사 : by-value 매개변수들은 이동 또는 복사되며
    by-reference 매개변수들은 그대로 참조형식을 유지
    (따라서 참조된 객체의 수명이 끝났음에도 coroutine이 재개되면 해당 참조 객체는 댕글링이 될 수 있으니 주의)
    (참고 : 특히 람다 캡쳐로 매개변수를 참조 전달한 경우 람다가 소멸하면 해당 참조도 소멸하므로
     웬만하면 복사로 넘길 것)

  • promise 객체에 대한 생성자를 호출한다.
    promise_type에 모든 코루틴 매개변수를 받는 생성자가 있는 경우,
    복사 후 코루틴 인자와 함께 해당 생성자가 호출된다.
    그렇지 않으면 기본 생성자가 호출된다.

  • promise_type.get_return_object()를 호출하고 결과를 로컬 변수에 보관한다.
    해당 호출의 결과는 코루틴이 처음 일시 중단될 때 호출자에게 반환된다.
    이 단계까지 발생한 모든 예외는 promise에 배치되지 않고 호출자에게 다시 전파된다.
    (참고 : 코루틴 함수의 최초 호출 시점에
      promise_type.get_return_object() 메서드에 의해 코루틴 객체가 생성되는데
      이 단계는 아직 코루틴 함수가 실행된 것이 아니기 제어권을 가진 caller측에 예외가 발생되는 것이다)


  • promise.initial_suspend()를 호출하고 그 결과를 co_await 한다.
    일반적인 promise 타입들은 lazily-started coroutines의 경우 std::suspend_always를 반환하며,
    eagerly-started coroutines의 경우 std::suspend_never를 반환한다.
    (참고 : initial_suspend()의 반환 타입을 std::suspend_always로 한 경우
     코루틴이 생성 되고 나서 일단 중단된다는 말이다.
     반환 타입이 std::suspend_never이면 코루틴은 생성되자마자 실행된다. 즉, await_resume()이 호출된다.)

  • co_await  promise.initial_suspend() 호출 후 coroutine이 재개되면 코루틴 본문의 실행을 시작한다.
    (참고:initial_suspend()의 반환 타입이 어찌되었던 간에 await_resume()이 호출되면 코루틴 본문의 실행을 시작한다)


5. Coroutine 인자가 댕글링(dangling)이 되는 과정 예제

#include <coroutine>
#include <iostream>
 
// 코루틴 객체(promise) 전방 선언
struct promise; // 코루틴 상태 객체(이름이 promise일 필요는 없음)
 
// 코루틴 객체 promise의 제어를 위한 핸들러std::coroutine_handle<promise>를 상속을 이용하여 구현함
struct coroutine : std::coroutine_handle<promise>
{
    using promise_type = ::promise;
};
 
// 코루틴 객체로 사용하기 위한 필수 Interface 정의 
struct promise
{
	// get_return : 코루틴 객체를 생성하는 역할
    coroutine get_return_object() { return {coroutine::from_promise(*this)}; }
    
    // initial_suspend : 코루틴 함수 실행 시작시 중단 여부 결정
    (반환 타입이 std::suspend_always이면 무조건 중단하며, std::suspend_never이면 중단하지 않음)
    std::suspend_always initial_suspend() noexcept { return {}; }
    
    // final_suspend : 코루틴 함수 종료 시 중단 여부 결정
	(반환 타입이 std::suspend_always이면 무조건 중단하며, std::suspend_never이면 중단하지 않음)
    std::suspend_always final_suspend() noexcept { return {}; }
    
    // return_void : 값을 반환하지 않는 코루틴을 위한 함수
    void return_void() {}
    
    // unhandled_exception : 예외 처리를 위한 함수
    void unhandled_exception() {}
};
 
struct S
{
    int i;
    
    coroutine f()
    {
        std::cout << i;
        co_return; // 코루틴 함수 f는 중단 없이 한번에 끝나는 로직으로 구성되어있음
    }
};
 
void bad1()
{
    coroutine h = S{0}.f(); // 코루틴 생성 후 즉시 실행
    // S{0}는 생성되자마자 실행되고 이미 종료되었음
    h.resume(); // 이미 종료된 코루틴을 재개하면서 S::i에 접근하여 uses 'S::i' after free오류 발생
    h.destroy();
}
 
coroutine bad2()
{
    S s{0};
    return s.f(); // 이미 반환된 코루틴은 use-after-free오류를 저지르지 않고서는 resume할 수 없음
    // S는 bad2함수가 반환하는 순간 소멸해버리는데 반환하겠다는 것은 외부에서 resume하겠다는 의도로 해석한 것인듯?
}
 
void bad3()
{
    coroutine h = [i = 0]() -> coroutine 
    {
        std::cout << i;
        co_return;
    }(); // immediately invoked
    // lambda destroyed
    h.resume(); // uses (anonymous lambda type)::i after free
    h.destroy();
}
 
void good()
{
    coroutine h = [](int i) -> coroutine // make i a coroutine parameter
    {
        std::cout << i;
        co_return;
    }(0);
    // lambda destroyed
    h.resume(); // no problem, i has been copied to the coroutine
                // frame as a by-value parameter
    h.destroy();
}

(참고 :
위 예시에서 bad3()과 good()은 헷갈릴 수 있으므로 설명해 보겠다.

위 예시의 promise객체의 initial_suspend() 함수의 반환타입을 std::suspend_always으로 정의하였으므로
coroutine 객체를 생성하고 initial_suspend()가 호출되면 coroutine이 중단된다.
따라서 코루틴 본문을 실행하기 위해 resume()을 호출하여 코루틴을 재개시켜주어야 한다.

그런데 bad3()에서는 coroutine 객체 생성 시
coroutine객체 내부에서 참조하는 i를 람다 캡처로 가져온다.
그런데 람다로 코루틴을 생성하고 즉시 실행(immediately invoke)하지만
initial_suspend() 함수의 반환 타입이 std::suspend_always이기 때문에
코루틴은 일단 중단된다.
그리고 prvalue인 람다는 소멸한다.
따라서 람다 객체가 캡처해서 참조로 가지고 있던 i는
람다 객체가 소멸되면서 dangle 상태가 된다.

반면 good에서는 람다 캡처가 아니라 매개변수 i로 value copy 해서 넘겨서
코루틴이 정상적으로 소유하고 있기 때문에 정상 동작하는 것이다.


만약 initial_suspend가 std::suspend_never이었다면?

bad3()은 즉시 실행이기 때문에 cout<< i;를 수행하고 아랫줄의  resume()에서 문제가 발생할 것이다.
왜냐하면 이미 completed 된코루틴에 resume을 시도하는 것은 정의되지 않은 동작(UB : Undefined Behavior)기 때문이다.

그리고 good함수에서 resume을 시도하는 시점에서는
이미 completed 된 코루틴에 resume을 시도하므로 UB가 된다.


※ Coroutine 사용 시 주의 사항

Coroutine 객체 생성 시 lambda capture 기능으로 인자를 넘기지 않도록 한다.
람다로 전달 시 전달되는 instance의 생명 주기를 보장할 수 없기 때문이다.)




6. Coroutine이 일시 중단 지점에 도달하면 


필요한 경우 coroutine의 반환 타입으로 암시적 변환 후
앞서 얻은 반환 객체가 caller 또는 resumer에게 반환된다.

 

1) 코루틴이 co_return 문에 도달했을 때

  • co_return; 또는 co_return void; 인 경우 promise.return_void()를 호출한다.
    (참고 : co_return과 co_return void는 똑같다)
  • co_return expr;에서 expr이 void 타입 아닌 경우 promise.return_value(expr)을 호출한다.
  • 자동 저장 기간이 있는 모든 변수를 생성된 역순으로 소멸시킨다.
  • promise.final_suspend()를 호출하고 결과를 co_await 한다.


코루틴 본문의 끝에 도달하는 것(모든 문장을 실행한 후)은
co_return;을 명시적으로 사용하는 것과 동일하다.

단, promise 객체에 return_void가 선언되어 있지 않으면 정의되지 않은 동작(UB)이 발생한다.
즉,  코루틴이 값을 반환하지 않고 끝나도
promise객체의 return_void()를 호출할 수 있어야 한다는 말이다.

함수 본문에 코루틴 키워드가 없는 함수는 반환 유형에 관계없이 코루틴이 아니며, 
반환 유형이 (possibly cv-qualified) void가 아닌 경우 끝에 떨어지면 정의되지 않은 동작이 발생한다.

(참고 : 코루틴 함수가 아무것도 반환하지 않는 경우 본문의 끝에 co_return을 명시하지 않아도 된다.
 단, promise_type은 return_void()메서드를 정의하고 있어야 하며, 그렇지 않으면 UB가 발생한다.
 또한 코루틴 함수의 반환 타입이 void가 아닌 경우에는  promise_type은 return_value() 메서드를 정의하고 있어야 하며,
 그렇
지 않으면 UB가 발생한다. )

// assuming that task is some coroutine task type
task<void> f()
{
    // 코루틴 키워드가 없으므로 코루틴으로 취급하지 않아서 undefined behavior
}
 
task<void> g()
{
    co_return;  // OK
}
 
task<void> h()
{
    co_await g();
    // OK, implicit co_return;
}


2) 코루틴이 잡히지 않는 예외(uncaught exception)로 종료된 경우, 다음과 같이 실행됨 :

  • 예외를 잡아서 catch 블록 내에서 promise.unhandled_exception()을 호출
    (참고 : 잡히지 않는 예외 발생 시 promise.unhandled_exception()가 호출되며, caller측으로 예외가 전파됨.
     만약 이 메서드가 정의되지 않은 경우에는 바로 caller측으로 예외가 전파됨.)
  • promise.final_suspend()를 호출하고 결과를 co_await 한다(예: 연속을 재개하거나 결과를 게시하기 위해).
    이 시점에서 코루틴을 재개하는 것은 정의되지 않은 동작이다.
    (참고 : final_suspend()가 호출되고나면 코루틴은 completed상태가 되는데 이때 resume()을 하면 UB발생)

만약 코루틴 상태가 소멸되는 원인이
co_return 또는 잡히지 않는 예외(uncaught exception)를 통해 종료되었기 때문이거나
핸들을 통해 소멸되었기 때문인 경우 다음을 수행 :

  • promise 객체의 소멸자를 호출한다.
  • 함수 파라미터 복사본의 소멸자를 호출한다.
  • delete 연산자를 호출하여 코루틴 상태를 위해 사용된 메모리를 해제한다.
  • Caller/Resumer에게 실행을 다시 전송한다.


7. 동적 할당


코루틴 상태는 non-array operator new를 통해 동적으로 할당된다.
(참고 : C++에는 두 가지 주요 형태의 operator new가 있다:
  단일 객체를 위한 operator new와 배열을 위한 operator new[]이다.
  그리고 코루틴은 단일 객체를 위한 operator new를 사용한다는 말이다.)

1) 코루틴 상태 할당 :

  • promise 타입이 class-level replacement(클래스 수준의 대체 할당자)를 정의하면 그것을 사용하고,
    그렇지 않으면 전역 operator new가 사용된다.

 

2) 특별한 할당 : 

  • 만약 promise 타입이 추가 매개변수를 받는 placement new 연산자를 정의하고,
    첫 번째 인자가 요청된 크기(std::size_t 타입)이고 나머지가 coroutine 함수의 인자와 일치한다면,
    해당 인자는 operator new로 전달된다.
    (이를 통해 코루틴에 대한 leading-allocator-convetion을 사용할 수 있음)

3) 최적화 :


다음 조건이 충족되면 opeartor new 호출이 최적화(생략)될 수 있다.(custom allocator를 사용할지라도 최적화함)

  • 코루틴 상태의 수명이 Caller의 수명 내에 엄격히 중첩될 때
    코루틴 프레임의 크기가 호출 시점에 알려져 있을 때

이 경우 코루틴 상태는 Caller의 스택 프레임(일반 함수인 경우) 또는
코루틴 상태(Caller가 코루틴인 경우)에 포함된다.

4) 할당 실패 처리 :

  • 기본적으로 할당 실패 시 std::bad_alloc 예외를 던진다.
    단, promise 타입이 promise::get_return_object_on_allocation_failure() 멤버 함수를 정의하면 다르게 동작한다.
    이 경우 nothrow 형태의 operator new를 사용한다.

  • 할당 실패 시,
    코루틴은 즉시 promise:: get_return_object_on_allocation_failure()로부터 얻은 객체를 호출자에게 반환한다.

예시

struct Coroutine::promise_type
{
    /* ... */
 
    // ensure the use of non-throwing operator-new
    static Coroutine get_return_object_on_allocation_failure()
    {
        std::cerr << __func__ << '\n';
        throw std::bad_alloc(); // or, return Coroutine(nullptr);
    }
 
    // custom non-throwing overload of new
    void* operator new(std::size_t n) noexcept
    {
        if (void* mem = std::malloc(n))
            return mem;
        return nullptr; // allocation failure
    }
};

 

8. Promise(★★★★★)


1) Promise 타입의 결정

  • 코루틴의 promise 타입은 컴파일러가 코루틴의 반환 타입을 기반으로
    std::coroutine_traits를 사용하여 결정한다.

2) 타입 결정 과정

  • R : 코루틴의 반환 타입
  • Args... : 코루틴의 매개변수 타입 목록
  • Class T : 코루틴이 non-static 멤버 함수로 정의된 경우의 클래스 타입
  • cv : non-static 멤버 함수로 정의된 경우 cv-qualification(const, volatile)

3) promise 타입 결정 규칙

  • 일반 함수나 static 멤버 함수인 경우 :
    std::coroutine_traits <R, Args...>::promise_type

  • non-static 멤버 함수이며 rvalue 참조가 아닌 경우 :
    std::coroutine_traits<R, cv ClassT&, Args...>::promise_type

  • non-static 멤버 함수이며 rvalue 참조인 경우 :
    std::coroutine_traits<R, cv Classt&&, Args...>::promise_type

promise_type 결정 예시

코루틴이 다음과 같이 정의된 경우 promise_type은 다음과 같음
task<void> foo(int x); std::coroutine_traits<task<void>, int>::promise_type
task<void> Bar::foo(int x) const; std::coroutine_traits<task<void>, const Bar&, int>::promise_type
task<void> Bar::foo(int x) &&; std::coroutine_traits<task<void> Bar&&, int>::promise_type




9.  co_await


co_await은 단항 연산자이며,
코루틴을 일시 중단하고 제어권을 Caller 측에 반환하는 역할을 한다.

1) co_await의 피연산자가 될 수 있는 것들은 :

  • 멤버 연산자 co_await을 정의하는 클래스 타입이거나,
  • 비멤버 연산자 co_await으로 전달될 수 있거나 (혹은 그런 타입이거나),
  • 현재 코루틴의 promise::await_transform을 통해 그러한 클래스 유형으로 변환할 수 있는 표현식

 

2) 'co_await  expr'

'co_await  expr'에서 co_await의 피연산자인 표현식 expr
일반 함수 본문 내의  잠재적으로 평가되는 표현식에만 나타날 수 있으며(람다 표현식의 함수 본문 포함),
다음 위치에서는 나타날 수 없다 :

  • handler 내부
  • 선언문 내부(해당 선언문의 initializer에 나타나는 경우 제외)
  • init-문의 단순 선언 내부(if, switch, for, range-for 참조) (해당 init-문의 initializer에 나타나는 경우 제외)
  • default parameter 내부
  • static storage duration 또는 thread storage duration을 가진 블록 범위 변수의 initializer 내부

 

2) 표현식 'co_await  expr'은 다음과 같이 평가된다.

  • 'expr'이 클래스 또는 열거형 타입인 경우 :

    - 관련 네임스페이스와 전역 네임스페이스에 선언된 멤버 및 비멤버 'operator co_await'를 고려하여
    오버로드 결정이 성공하면, 그 오버로드 결정과 함수 호출의 결과가 `co_await expr`의 결과가 된다.

    - 그렇지 않으면, 'co_await expr'은 'co_await Awaitable(expr)'과 동등하다.
    여기서 'Awaitable'은 'expr'의 타입을 나타낸다.


  • 먼저 'expr'은 다음과 같이 awaitable로 변환된다.

    - 'expr'이 초기 중단점, 최종 중단점 또는 yield 표현식에 의해 생성된 경우
      awaitable은 그대로 'expr'이다.

    - 그렇지 않고, 현재 코루틴의 promise 타입이 'await_transform' 멤버 함수를 가지고 있다면,
     awaitable은 promise.await_transform(expr)이 된다.
    (연산자 co_await의 피연산자 부분인 expr에 대해서 커스텀 동작을 구현할 수 있다는 뜻 )

    - 그 외의 경우, awaitable은 그대로 'expr'이다


awaiter object는 다음과 같이 얻을 수 있다 :

  • 연산자 'co_await'에 대한 오버로드 결정(overload resolution)이 단일 최선의 오버로드를 제공하면,
    awaiter는 그 호출의 결과이다 :
    - 멤버 오버로드의 경우 'awaitable.operator co_await( )'
    - 비멤버 오버로드의 경우 'operator co_await( static_cast<Awaitable&&>( awaitable ) )'

  • 오버로드 결정이 'operator co_await'을 찾지 못하면, awaiter는 그대로 awaitable이다.

  • 오버로드 결정이 모호하면, 프로그램은 ill-formed(잘못된 형식)다.

위의 표현식이 prvalue인 경우, awaiter object는 이로부터 임시로 구체화된 객체다.

그렇지 않고 위의 표현식이 glvalue인 경우, awaiter object는 이 표현식이 참조하는 객체다.

그다음, `awaiter.await_ready()`가 호출된다.(결과가 준비되었거나 동기적으로 완료될 수 있다는 것을 알고 있는 경우 suspension 비용을 회피하기 위한 short-cut이다).

만약 그 결과를 문맥상 bool로 변환할 수 있을 때  false라면:

  • 코루틴이 중단된다 (코루틴 상태는 지역 변수와 현재 일시 중단 지점으로 채워진다).

  • 현재 코루틴에 해당하는 코루틴 핸들인 경우 `awaiter.await_suspend(handle)`이 호출된다.
    해당 함수 내부에서 일시 중단된 코루틴 상태는 해당 핸들을 통해 관찰할 수 있으며,
    특정 executor에서 재개되도록 schedule 하거나,
    소멸(destroy)되도록 schedule 하는 것은 이 함수의 책임이다(false를 반환하는 것은 scheduling으로 간주한다).

    여기서 `handle`은 현재 코루틴을 나타내는 코루틴 핸들입니다.

    이 함수 내에서, 일시 중단된 코루틴 상태는 해당 핸들을 통해 관찰 가능하며,
    이 함수의 책임은 그것을 어떤 실행자(executor)에서 재개하도록 스케줄링하거나 파괴되도록 하는 것이다(false를 반환하는 것은 스케줄링으로 간주됩니다).
       - `await_suspend()`가 void를 반환하면,
           └  제어권이 즉시 현재 코루틴의 Caller/Resumer에게 반환된다.(이 코루틴은 일시 중단된 상태로 유지된다).
       - `await_suspend()`가 bool을 반환하면 :
           └ true 값은 제어권을 현재 코루틴의 Caller/Resumer에게 반환한다.
           └  false 값은 현재 코루틴을 재개한다.
       - `await_suspend()`가 다른 코루틴에 대한 코루틴 핸들을 반환하면, 해당 핸들이 재개된다(`handle.resume()` 호출을 통해)
          이는 결국 현재 코루틴을 재개하는 연쇄 효과를 가질 수 있다.
       - `await_suspend()`가 예외를 던지면, 예외가 포착되고, 코루틴이 재개되며, 예외가 즉시 다시 던져집니다.

마지막으로, `awaiter.await_resume()`이 호출되며(코루틴이 일시 중단되었든 아니든),
그 결과가 전체 `co_await expr` 표현식의 결과가 된다.

코루틴이 `co_await` 표현식에서 중단되었다가 나중에 재개되면, 
재개 지점은 `awaiter.await_resume()` 호출 직전이 된다.

주의할 점은 코루틴이 `awaiter.await_suspend()`에 들어가기 전에 완전히 중단된다는 것이다.

그 핸들은 다른 스레드와 공유될 수 있으며
`await_suspend()` 함수가 반환되기 전에 재개될 수 있다.
(기본 메모리 안전 규칙은 여전히 적용되므로, 코루틴 핸들이 락 없이 스레드 간에 공유되는 경우 awaiter는 최소한 release 의미론을, 재개자는 최소한 acquire 의미론을 사용해야 한다.)

예를 들어, 코루틴 핸들은 비동기 I/O 작업이 완료될 때 스레드 풀에서 실행되도록 예약된 콜백 내에 넣을 수 있다.
이 경우, 현재 코루틴이 재개되어 awaiter 객체의 소멸자를 실행했을 수 있으므로,
`await_suspend()`가 현재 스레드에서 계속 실행되는 동안 동시에,
`await_suspend()``*this`를 파괴된 것으로 취급하고 핸들이 다른 스레드에 공개된 후에는 접근하지 않아야 한다.


예제

#include <coroutine>
#include <iostream>
#include <stdexcept>
#include <thread>
 
auto switch_to_new_thread(std::jthread& out)
{
    std::cout<<"Switching_to_new_thread()"<<std::endl;
    struct awaitable
    {
        std::jthread* p_out;
        bool await_ready()
        {
            std::cout<<"awaitable::await_ready()"<<std::endl;
            return false;
        }
        void await_suspend(std::coroutine_handle<> h)
        {
            std::cout<<"awaitable::await_suspend()"<<std::endl;
            std::jthread& out = *p_out;
            if (out.joinable())
                throw std::runtime_error("Output jthread parameter not empty");
            out = std::jthread([h] { h.resume(); });
            // Potential undefined behavior: accessing potentially destroyed *this
            // std::cout << "New thread ID: " << p_out->get_id() << '\n';
            std::cout << "New thread ID: " << out.get_id() << '\n'; // this is OK
        }
        void await_resume()
        {
            std::cout<<"awaitable::await_resume()"<<std::endl;
        }
    };
    return awaitable{&out};
}
 
struct task
{
    struct promise_type
    {
        task get_return_object()
        {
            std::cout<<"task::get_return_object()"<<std::endl;
            return {};
        }
        std::suspend_never initial_suspend()
        {
            std::cout<<"task::initial_suspend()"<<std::endl;
            return {};
        }
        std::suspend_never final_suspend() noexcept
        {
            std::cout<<"task::final_suspend()"<<std::endl;
            return {};
        }
        void return_void()
        {
            std::cout<<"task::return_void()"<<std::endl;
        }
        void unhandled_exception()
        {
            std::cout<<"task::unhandled_exception()"<<std::endl;
        }
    };
};
 
task resuming_on_new_thread(std::jthread& out)
{
    std::cout << "Coroutine started on thread: " << std::this_thread::get_id() << '\n';
    co_await switch_to_new_thread(out);
    // awaiter destroyed here
    std::cout << "Coroutine resumed on thread: " << std::this_thread::get_id() << '\n';
}
 
int main()
{
    std::jthread out;
    resuming_on_new_thread(out);
}

/* 출력 :

task::get_return_object()
task::initial_suspend()
Coroutine started on thread: 4760
Switching_to_new_thread()
awaitable::await_ready()
awaitable::await_suspend()
New thread ID: 2388
awaitable::await_resume()
Coroutine resumed on thread: 2388
task::return_void()
task::final_suspend()

*/

위 예시는 코루틴에서 매우 중요한 부분을 보여주고 있으니 잘 파악해 놓도록 하자.

task 타입은 co_await switch_to_new_thread(out); 코드에서
코루틴 객체 생성 부분을 위해 정의한 타입이다.

awaitable 타입은 co_await을 수행하기 위해 정의한 타입이며
await_ready(), await_suspend(),  await_resume() 메서드를 정의하고 있다.





10.  co_yield


co_yield 표현식은 호출자에게 값을 반환하고 현재 코루틴을 일시 중단하며, 
재개 가능한 제너레이터 함수의 공통 구성 요소(common building block of resumable generator functions)이다.

'co_yield expr'
'co_yield braced-init-list'

위 표현식은 아래와 같다.

co_await promise.yield_value(expr)

일반적인 제너레이터의 'yield_value()'는 인자를 제너레이터 객체에 저장(복사/이동하거나 인자의 수명이 co_await 내부의 일시 중단 지점을 넘기 때문에 주소만 저장)하고
std::suspend_always를 반환하여 호출자/재시도자에게 제어권을 넘긴다.

 

#include <coroutine>
#include <cstdint>
#include <exception>
#include <iostream>

template <typename T>
struct Generator
{
    /* 클래스 명을 Generator라고 명명할 필요 없음.
     * 컴파일러가 코루틴이라고 인지하는 것은 'co_yield'키워드의 존재 여부임.
     * 클래스 명을 'MyGenerator'라고 네이밍한다면
     * MyGenerator get_return_object()메서드만 정의해놓으면 됨
     * (Note: 이름을 변경할 때 생성자 및 소멸자의 선언을 조정해야 한다.)*/

    struct promise_type;
    using handle_type = std::coroutine_handle<promise_type>;

    struct promise_type // required
    {
        T value_;
        std::exception_ptr exception_;

        Generator get_return_object()
        {
            std::cout << "get_return_object()" << std::endl;
            return Generator(handle_type::from_promise(*this));
        }

        std::suspend_always initial_suspend()
        {
            std::cout << "initial_suspend()" << std::endl;
            return {};
        }

        std::suspend_always final_suspend() noexcept
        {
            std::cout << "final_suspend()" << std::endl;
            return {};
        }

        void unhandled_exception()
        {
            std::cout << "Unhandled exception()" << std::endl;
            exception_ = std::current_exception();
        } // saving
        // exception

        template <std::convertible_to<T> From> // C++20 concept
        std::suspend_always yield_value(From&& from)
        {
            std::cout << "yield_value()" << std::endl;
            value_ = std::forward<From>(from); // caching the result in promise
            return {};
        }

        void return_void()
        {
            std::cout << "return_void()" << std::endl;
        }
    };

    handle_type h_;

    Generator(handle_type h) : h_(h)
    {
       std::cout<<"Generator constructed"<<std::endl; 
    }

    ~Generator()
    {
        std::cout<<"Generator destroyed"<<std::endl;
        h_.destroy();
    }

    /* 코루틴 객체의 사용을 위해 명시적으로 정의해야하는
     * promise타입의 operator bool() 메서드가 반환하는 bool값은
     * 컴파일러에 의해 아래 2가지 목적으로 암시적으로 사용된다.
     
       1. 'co_yield' 혹은 operator()()를 만났을 때
          현재 Generator가 아직 유효한 값을 생성할 수 있는지 확인하는 목적
       2. 코루틴이 완전히 종료되었는지 확인

      즉, 코루틴을 완료했는지 여부, 코루틴에서 다음 값이 생성될지 여부(co_yield)를
        C++ getter(아래 연산자()를 통해 안정적으로 확인할 수 있는 유일한 방법은
        다음 co_yield 지점까지 코루틴을 실행/재시작하는 것이다(또는 종료되도록 놔두는 것일 수 있다).
        그런 다음 결과를 promise에 저장/캐싱하여 코루틴을 실행하지 않고도 getter(아래 연산자()가 결과를 가져올 수 있도록 한다.)
     */
    explicit operator bool()
    {
        std::cout << "operator bool()" << std::endl;
        fill(); 
        return !h_.done();
    }

    T operator()()
    {
        std::cout<<"operator()()"<<std::endl;
        fill();
        full_ = false; // 이전에 캐싱된 결과 값을 이동시켜 promise를 다시 empty 상태로 만드는 의도
        return std::move(h_.promise().value_);
    }

private:
    bool full_ = false;

    void fill()
    {
        std::cout << "fill()" << std::endl;
        if (!full_)
        {
            h_();
            if (h_.promise().exception_)
                std::rethrow_exception(h_.promise().exception_); // propagate coroutine exception in called context

            full_ = true;
        }
    }
};

Generator<std::uint64_t>
fibonacci_sequence(unsigned n)
{
    std::cout << "fibonacci_sequence()" << std::endl;
    if (n == 0)
        co_return;

    if (n > 94)
        throw std::runtime_error("Too big Fibonacci sequence. Elements would overflow.");

    co_yield 0;

    if (n == 1)
        co_return;

    co_yield 1;

    if (n == 2)
        co_return;

    std::uint64_t a = 0;
    std::uint64_t b = 1;

    for (unsigned i = 2; i < n; ++i)
    {
        std::uint64_t s = a + b;
        co_yield s;
        a = b;
        b = s;
    }
}

Generator<int> range(int start, int end)
{
    for (int i = start; i < end; ++i)
    {
        co_yield i; // 각 숫자를 생성하고 일시 중단
    }
}

int main()
{
    try
    {
        std::cout << "Generate coroutine handle" << std::endl;
        auto gen = fibonacci_sequence(5); // 여기서 get_return_object()호출 후 initial_suspend()호출 됨
        std::cout << "================================================" << std::endl;
        
        for (int j = 0; gen; ++j)
        {
            std::cout << "fib(" << j << ")=" << gen() << std::endl;
            std::cout << "================================================" << std::endl;
        }
    }
    catch (const std::exception& ex)
    {
        std::cerr << "Exception: " << ex.what() << '\n';
    }
    catch (...)
    {
        std::cerr << "Unknown exception.\n";
    }
}


/* 출력 :
Generate coroutine handle
get_return_object()
Generator constructed
initial_suspend()
================================================
operator bool()
fill()
fibonacci_sequence()
yield_value()
fib(0)=operator()()
fill()
0
================================================
operator bool()
fill()
yield_value()
fib(1)=operator()()
fill()
1
================================================
operator bool()
fill()
yield_value()
fib(2)=operator()()
fill()
1
================================================
operator bool()
fill()
yield_value()
fib(3)=operator()()
fill()
2
================================================
operator bool()
fill()
yield_value()
fib(4)=operator()()
fill()
3
================================================
operator bool()
fill()
return_void()
final_suspend()
Generator destroyed
*/

위 코드를 실행시켜 보고 코루틴에서 co_yield가 대충 어떻게 동작하는지 파악해 놓도록 하자.


※ 추가 사항

코루틴 함수 내부에서 예외가 발생했을 때

처리할 수 없는 예외가 발생한 경우

unhandle_exception()메서드를 정의하지 않았으면 Caller측 프레임으로 예외가 전파된다.

당연한 내용인데 위 문서에는 제대로 적혀있지 않아서 추가함.

 


C++ 20 Coroutines 핵심 요약



C++ Coroutines을 의미있게 사용하려면

실제 비동기 작업의 결과를 받아서 처리하는 정도 수준의 예제 코드로 학습해야 한다.


그러기 위해선 코루틴을 완벽하게 이해해야 한다.

따라서 이 포스팅의 모든 예제들을 완벽히 이해하는 것을 추천한다.


앞서 기술한 내용들을 재정리 해보자면 다음과 같다.


1. 코루틴 함수로 인식되는 조건

C++ 컴파일러는 반드시 다음 3가지 코루틴 키워드 중 하나라도 함수 본문 내에 존재할 때에만 코루틴 함수로 취급한다.

  • co_await
  • co_yield
  • co_return

 

2. 코루틴 함수 호출 시 동작

  • 코루틴 함수의 반환 타입인 에 사용되는 코루틴 객체의 promise_type::get_return_object() 메서드가 호출된다.
    (보통은 get_return_object 메서드에 의해 코루틴 객체가 생성된다. 즉, 코루틴 객체의 생성자를 호출함)
  • promise_type의 initial_suspend() 메서드가 호출된다.


3. 코루틴 객체의 조건

  • 코루틴 객체는 promise_type를 가지고 있다.
    promise_type에 대한 type alias가 존재하는 방식일 수도 있고,
     nested type definition 방식으로 직접 정의했을 수도 있고,
    멤버 변수로 가지고 있을 수도 있다.

    어찌됬건 코루틴 프레임 내에 promise_type이 존재하도록 한다.
struct promise;
 
struct CoroResult : std::coroutine_handle<promise>
{
    using promise_type = ::promise; // type alias 방식
};
 
struct promise
{
    CoroResult get_return_object() { return { CoroResult::from_promise(*this) }; }
    std::suspend_always initial_suspend() noexcept { return {}; }
    std::suspend_always final_suspend() noexcept { return {}; }
    void return_void() {}
    void unhandled_exception() {}
};

// 혹은 다음과 같이 직접 정의해도 된다
struct CoroResult : std::coroutine_handle<promise>
{
	// nested type definition 방식
    struct promise
    {
        CoroResult get_return_object() { return { CoroResult::from_promise(*this) }; }
        std::suspend_always initial_suspend() noexcept { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() {}
    };
};
  • 코루틴 객체의 promise_type, 즉, CoroResult::promise_type은 다음 5가지 메서드를 필수로 정의해야 한다.
    - get_return_object(),
    - initial_suspend(),
    - final_suspend(),
    - return_value() 또는 return_void(),
    - unhandled_exception()  

    추가로
    promise_type에 await_transform() 메서드를 정의할 경우
    연산자 co_await의 피연산자인 awaitable object에 대해서 커스텀 동작을 구현할 수 있다.
    즉, co_await의 피연산자의 타입이 무엇이냐에 따라 어떤 awaitable object를 사용할지 선택할 수 있게 된다.
struct MyPromise {
    template<typename T>
    auto await_transform(T&& value) {
        if constexpr (is_awaitable_v<T>) {
            // 이미 awaitable인 경우, 로깅을 추가한 버전을 반환
            return LoggingAwaitable<T>{std::forward<T>(value)};
        } else {
            // awaitable이 아닌 경우, awaitable로 변환
            return MakeAwaitable<T>{std::forward<T>(value)};
        }
    }
};

...

co_await 54; // await_transform으로 이어져 LoggingAwaitable<int> 타입을 awaitable object로 사용한다.

 

 

※ 근데 코루틴 함수의 반환 타입에 promise_type이 없어도 동작하던데?(★)


boost asio에서 제공하는 코루틴 객체 awaitable<T>를 살펴보면 promise_type을 가지고 있지 않았다.

그런데도 문제 없이 코루틴이 잘만 동작한다.

왜 그런지는 boost asio를 살펴보던 중 수상한 코드를 발견했다.

직접 std 네임스페이스 안에 직접 coroutine_traits을 정의하고 그 안에 promise_type을 정의해 놓은 것을 발견했다.

이는 C++ 코루틴 메커니즘의 유연성 덕분에 가능한 것인데
Boost.Asio는 별도의 헤더 파일에 std::coroutine_traits를 특수화하여
awaitable 객체에 대한 promise_type을 정의하였다.

이는 C++ 표준에서 인정하고 권장하는 방식이다.(promise_type 결정 방식 참고)

<coroutine>헤더에 std::coroutine_traits이 정의되어 있는데,
이 trait의 주요 목적은 코루틴 객체가 아닌 타입들을 코루틴과 함께 사용할 수 있게 하는 것이다.

// <coroutine> 헤더의 coroutine_traits 정의 부분
template <class _Ret, class = void>
struct _Coroutine_traits {};

template <class _Ret>
struct _Coroutine_traits<_Ret, void_t<typename _Ret::promise_type>> {
    using promise_type = typename _Ret::promise_type;
};

_EXPORT_STD template <class _Ret, class...>
struct coroutine_traits : _Coroutine_traits<_Ret> {};


/* coroutine_traits의 상속 내용을 합치면 다음과 같다.
  template<class R, class... ArgTypes>
  struct coroutine_traits {
    using promise_type = typename R::promise_type;
  };
*/

위 코드를 잘 살펴보면 std::coroutine_traits의 첫번째 템플릿 인자를 promise_type으로 지정하고 있다.

그래서 기존 타입들(예: std::future)을 코루틴 반환 타입으로 사용할 수 있는 수단을 제공한다.

예를 들어, 내 프로젝트의 임의의 위치에 다음 코드를 가지고 있으면

std::future<T>도 코루틴 함수 반환 타입으로 사용가능하다는 뜻이다.

namespace std {

  template<class T, class... Args>
  struct coroutine_traits<std::future<T>, Args...> 
  {
  
    struct promise_type {
      std::promise<T> p;
      std::future<T> get_return_object() { return p.get_future(); }
      std::suspend_never initial_suspend() { return {}; }
      std::suspend_never final_suspend() noexcept { return {}; }
      void return_value(T value) { p.set_value(value); }
      void unhandled_exception() { p.set_exception(std::current_exception()); }
    };
    
  };
  
}


...
// std::future<bool>를 반환 타입으로 갖는 코루틴 함수
std::future<bool> AsyncExecute(redis::request req)
{
   // 코루틴 본문
   auto result = co_await SomeAsyncOperation();
   // 다른 작업 수행
   bool isSucceeded = ProcessResult(result);
   co_return isSucceeded;
}

C++은 사용법을 곱게 알려주지 않는다. 하루 종일 이것 저것 찾아보면서 겨우 여기까지 알아냈다 ㅠㅠ
능력이 되면 써라는 식이다. 표준 문서도 도움이 되지 않았다.
코루틴에 대해 공부하면서 동시에 이 글을 작성했는데
여기까지 작성하는데 대략 3일이 걸렸다.

C++을 사용해야만 하는 입장이라면 제대로 공부해서 사용하도록 하자.
하지만 C++을 안할 수 있다면 처음부터 안하는게 이득!!!

 

4. 연산자 co_await(앞에 awaitable object 에 대해서도 작성 필요)

1) 'co_await expr' 표현식의 평가

  • co_await연산자는 awaitable object를 피연산자로 받는다.
    즉, 'co_await expr' 표현식에서 expr은 awaitable object가 된다.

  • expr이 초기 중단점, 최종 중단점, 또는 co_yield에 의해 생성된 경우에는
    expr 자체가 이미 awaitable object이기 때문에,
    별도의 변환이나 추가적인 co_await 연사자를 호출하지 않고
    그대로 co_await의 피연산자가 될 수 있다는 의미

    만약, 현재 코루틴의 promise_type이 'await_transform()' 메서드를 가지고 있다면,
     awaitable object는 promise_type.await_transform(expr)이 된다.

    - 그 외의 경우, awaitable은 그대로 'expr'이다
struct MyCoroutine {
    struct promise_type {
        std::suspend_always initial_suspend() { return {}; } // 초기 중단점
        std::suspend_always final_suspend() noexcept { return {}; } // 최종 중단점
        void return_void() {}
        void unhandled_exception() {}
    };
};

// 코루틴 함수 정의
MyCoroutine my_coroutine() {
    co_await std::suspend_always{};  // 중단점
    co_return;  // 최종 중단점
}

위 코드에서 co_await std::suspend_always {}는 명시적으로 중단을 의미하며,
std::suspend_always 자체가 awaitable 객체이다.
따라서 'expr'은 std::suspend_always**이고, 'co_await expr'에서 expr이 그대로 awaitable 객체로 취급된다.

2) co_await 연산자의 동작 과정

  • 만약 expr이 await_transform() 메서드를 정의하고 있다면 피연산자를 변환한다.
    promise_type에 await_transform()가 정의되어 있지 않다면 이 단계는 건너뜀.

  • operator co_await()에 대한 overload resolution이 진행된다.
    즉, exproperator co_await()를 가지고 있는 경우 이 연산자를 호출하여 awaitable을 변환한다.
    참고로 operator co_await()는 awaitable object의 멤버 연산자일수도 있고, 비멤버 연산자일수도 있다.
#include <coroutine>
#include <iostream>
#include <thread>
#include <chrono>

// 1. operator co_await를 멤버로 정의하는 클래스
struct MemberCoAwait {
    bool await_ready() { return false; }
    void await_suspend(std::coroutine_handle<>) { std::cout << "MemberCoAwait: Suspended\n"; }
    int await_resume() { return 42; }

    auto operator co_await() { return *this; } // 멤버 연산자
};

// 2. 비멤버 operator co_await에 전달될 수 있는 타입
struct NonMemberCoAwait {};
auto operator co_await(NonMemberCoAwait&&) {
    struct Awaiter {
        bool await_ready() { return false; }
        void await_suspend(std::coroutine_handle<>) { std::cout << "NonMemberCoAwait: Suspended\n"; }
        int await_resume() { return 24; }
    };
    return Awaiter{};
}

// 3. Promise::await_transform을 통해 변환 가능한 타입
struct CustomAwaitable {
    int value;
};

struct Promise {
    auto await_transform(CustomAwaitable ca) {
        struct Awaiter {
            int value;
            bool await_ready() { return false; }
            void await_suspend(std::coroutine_handle<>) { std::cout << "CustomAwaitable: Suspended\n"; }
            int await_resume() { return value; }
        };
        return Awaiter{ca.value};
    }
};

// 코루틴을 반환하는 함수의 반환 타입
struct Task {
    struct promise_type : Promise {
        Task get_return_object() { return {}; }
        std::suspend_never initial_suspend() { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() {}
    };
};

// 코루틴 함수 예제
Task example_coroutine() {
    std::cout << "Coroutine started\n";
    
    // MemberCoAwait 사용
    int result1 = co_await MemberCoAwait{};
    std::cout << "Result1: " << result1 << "\n";

    // NonMemberCoAwait 사용
    int result2 = co_await NonMemberCoAwait{};
    std::cout << "Result2: " << result2 << "\n";

    // CustomAwaitable 사용
    int result3 = co_await CustomAwaitable{100};
    std::cout << "Result3: " << result3 << "\n";

    std::cout << "Coroutine ended\n";
}

// await_suspend의 다양한 반환 타입 예제
struct VoidAwaiter {
    bool await_ready() { return false; }
    void await_suspend(std::coroutine_handle<>) {
        std::cout << "VoidAwaiter: Suspended\n";
    }
    void await_resume() {}
};

struct BoolAwaiter {
    bool await_ready() { return false; }
    bool await_suspend(std::coroutine_handle<>) {
        std::cout << "BoolAwaiter: Suspended\n";
        return false; // 즉시 재개
    }
    void await_resume() {}
};

struct HandleAwaiter {
    bool await_ready() { return false; }
    std::coroutine_handle<> await_suspend(std::coroutine_handle<> h) {
        std::cout << "HandleAwaiter: Suspended\n";
        return h; // 현재 코루틴을 재개
    }
    void await_resume() {}
};

Task await_suspend_examples() {
    co_await VoidAwaiter{};
    co_await BoolAwaiter{};
    co_await HandleAwaiter{};
}

int main() {
    example_coroutine();
    await_suspend_examples();
    return 0;
}
  • await_ready() 메서드가 호출되어 비동기 작업이 즉시 완료 가능한지 확인한다.
    반환 값이 true인 경우 다음 두 단계를 건너뛰어 즉시 await_resume()메서드를 호출하며,
    반환 값이 false인 경우 다음 단계인 await_suspend()메서드를 호출한다.

  • await_ready()메서드가 false를 반환하면 await_suspend() 메서드가 호출되는데
    코루틴을 제어하는데 사용되는 std::coroutine_handle<T>이 인자로 넘어온다.
    여기서 비동기 작업에 대한 진행 여부를 확인하고 완료될때까지 대기하다가
    완료되면 이 핸들에 std::coroutine_handle<T>::resume()메서드를 호출하여
    다음 단계인 await_resume()메서드를 호출한다.

  • await_resume()은 코루틴을 재개한다.
    이때 값을 반환할 수도 있다.

 

5. 연산자 co_yield

co_yield는 주로 제너레이터 패턴을 구현할 때 사용된다.

co_yield는 co_await처럼 비동기 작업이 결과를 반환할 때까지 기다리면서
값이 준비되었는지 확인한다거나 하는 동작은 지원하지 않는다.

  • co_yield 연산자를 사용하기 위해 yield_value()를 정의해야 한다.
    yield_value는 반드시 awaitable 객체를 반환해야 한다.
    가장 흔한 반환 타입은 std::suspend_always다.
  • co_yield expr은 내부적으로 'co_await promise_type.yield_value(expr)' 로 변환된다.

    'co_await expr'에서  expr이 awaitable object로 평가되는 경우
    이는 promise_type.await_suspend()으로 치환된다고 했었다.

    그런데  co_yield이므로 await_suspend() 대신 yield_value()가 호출된다고 생각하면 된다.
#include <coroutine>
#include <exception>
#include <iostream>
#include <chrono>
#include <thread>

template<typename T>
class generator {
public:
    struct promise_type {
        T current_value;
        bool value_ready = false;
        
        generator get_return_object() {
            return generator{std::coroutine_handle<promise_type>::from_promise(*this)};
        }
        
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        
        std::suspend_always yield_value(T value) {
            std::cout<<"promise::yield_value()"<<std::endl;
            current_value = value;
            value_ready = true;
            return {};
        }
        
        void unhandled_exception() {
            std::cout<<"promise::unhandled_exception()"<<std::endl;
            std::terminate();
        }
        
        void return_void()
        {
            std::cout<<"promise::return_void()"<<std::endl;
        }
    };
    
    generator(generator&& other) noexcept : handle(other.handle) {
        std::cout<<"generator(generator&& other)"<<std::endl;
        other.handle = nullptr;
    }
    
    ~generator() {
        if (handle)
            handle.destroy();
    }
    
    class iterator {
    public:
        void operator++() {
            std::cout<<"iterator::operator++()"<<std::endl;
            if (handle) {
                handle.promise().value_ready = false;
                handle.resume();
                wait_for_value();
            }
        }
        
        T operator*() const {
            std::cout<<"iterator::operator*()"<<std::endl;
            return handle.promise().current_value;
        }
        
        bool operator==(std::default_sentinel_t) const {
            std::cout<<"iterator::operator==(std::default_sentinel_t)"<<std::endl;
            return !handle || handle.done();
        }
        
        explicit iterator(std::coroutine_handle<promise_type> h) : handle(h) {
            std::cout<<"iterator::iterator()"<<std::endl;
            wait_for_value();
        }
        
    private:
        std::coroutine_handle<promise_type> handle;

        void wait_for_value() {
            std::cout<<"iterator::wait_for_value()"<<std::endl;
            while (handle && !handle.done() && !handle.promise().value_ready) {
                std::this_thread::sleep_for(std::chrono::milliseconds(10));
            }
        }
    };
    
    iterator begin() {
        std::cout<<"generator::begin()"<<std::endl;
        if (handle) {
            handle.resume();
        }
        return iterator{handle};
    }
    
    std::default_sentinel_t end() {
        std::cout<<"generator::end()"<<std::endl;
        return {};
    }
    
private:
    std::coroutine_handle<promise_type> handle;
    
    explicit generator(std::coroutine_handle<promise_type> h) : handle(h)
    {
        std::cout<<"generator(std::coroutine_handle<promise_type> h)"<<std::endl;
    }
};

// 값을 생성하는데 시간이 걸리는 제너레이터 예시
generator<int> slow_count_to(int n) {
    for (int i = 1; i <= n; ++i) {
        co_yield i;
    }
}

int main() {
    auto gen = slow_count_to(5);
    for (int value : gen) {
        std::cout << "Generated value: " << value << std::endl;
    }
    return 0;
}





우선 co_await연산자의 피연산자는 awaitable object여야 한다.

awaitable object는 다음 메서드를 정의해야 한다.

  • await_ready()
  • await_suspend(std::coroutine_handle<>)
  • await_resume()

 

만약 코루틴의 promise 타입이  await_transform 메서드를 정의한 경우,
이 메서드가 co_await 표현식을 변환하는 데 사용된다.



C++ 코루틴을 잘 사용하려면? 주의해야할 점은?



Q1.   일반적인 비동기 함수를 co_await 과 함께 사용하려면?

A1.   해당 함수를 awaitable object로 래핑해야 한다.


안되는 예시

// 이렇게 쉽게 반환 타입을 설정하면 작동하지 않는다
std::future<int> async_function();
int result = co_await async_function();  // 오류!

래핑한 예시

// 이렇게 래핑해야 한다
template <typename T>
auto await_future(std::future<T>&& f) {
    struct Awaiter {
        std::future<T> fut;
        bool await_ready() { return fut.wait_for(std::chrono::seconds(0)) == std::future_status::ready; }
        // await_suspend에서 코루틴 핸들을 통해 비동기 작업에 대한 처리 결과를 기다린다
        void await_suspend(std::coroutine_handle<> h) {
            std::thread([this, h] {
                fut.wait();
                h.resume();
            }).detach();
        }
        T await_resume() { return fut.get(); }
    };
    return Awaiter{std::move(f)};
}

// 이제 이렇게 사용할 수 있다
int result = co_await await_future(some_async_work());




비동기 작업 래핑 예시

#include <iostream>
#include <coroutine>
#include <functional>
#include <thread>
#include <chrono>
#include <future>
#include <vector>
#include <memory>
#include <stdexcept>
using namespace std::chrono_literals;

class AsyncJob
{
public:
    using Callback = std::function<void(int)>;

    void DoAsyncWork(int id, Callback callback)
    {
        std::cout << "DoAsyncWork called for id: " << id << std::endl;
        std::async(std::launch::async, [id,callback]()
        {
            std::this_thread::sleep_for(5000ms);
            callback(id * 10);
        });
    }
};

// 코루틴 객체
template <typename T>
struct CoroResult
{
    struct promise_type
    {
        std::promise<T> promise;

        CoroResult get_return_object()
        {
            std::cout << "CoroResult::promise_type::get_return_object()" << std::endl;
            return CoroResult(promise.get_future());
        }
        std::suspend_never initial_suspend()
        {
            std::cout << "CoroResult::promise_type::initial_suspend()" << std::endl;
            return {};
        }
        std::suspend_never final_suspend() noexcept
        {
            std::cout << "CoroResult::promise_type::final_suspend()" << std::endl;
            return {};
        }
        void return_value(T value)
        {
            std::cout << "CoroResult::promise_type::return_value()" << std::endl;
            promise.set_value(std::move(value));
        }
        void unhandled_exception()
        {
            std::cout << "CoroResult::promise_type::unhandled_exception()" << std::endl;
            promise.set_exception(std::current_exception());
        }
    };


    std::future<T> future;

    explicit CoroResult(std::future<T>&& f) : future(std::move(f))
    {
        std::cout << "CoroResult(std::future<T>&& f)" << std::endl;
    }

    T get()
    {
        std::cout << "CoroResult::get()" << std::endl;
        return future.get();
    }

    // co_await 수행을 위한 메서드 정의
    bool await_ready()
    {
        std::cout << "CoroResult::await_ready()" << std::endl;
        /* 아래 future.wait_for을 하는건 실제로는 non-blocking이며 즉시 상태를 확인한다.
         * 이때 true를 반환하면 await_suspend()를 호출하지 않고 즉시, await_resume()을 호출한다.
         * 하지만 false를 반환한다면 await_suspend()를 호출하게 되고 코루틴은 일시 중지된다.
         * await_suspend()내부에서 비동기로 future에 결과가 나올때 까지 대기하다가
         * future에 값이 들어오면 coroutine_handle.resume()을 하게 되어 await_resume()을 호출하고
         * 코루틴을 재개함*/
        return future.wait_for(0s) == std::future_status::ready;
    }
    void await_suspend(std::coroutine_handle<> _handle)
    {
        std::cout << "CoroResult::await_suspend(std::coroutine_handle<>)" << std::endl;
        std::async(std::launch::async, [this, _handle]()
        {
            future.wait();
            _handle.resume(); // future가 결과를 받으면 코루틴을 재개한다. 코루틴 핸들을 resume()하면 await_resume()메서드가 호출 됨
        });
    }
    T await_resume()
    {
        std::cout << "CoroResult::await_resume()" << std::endl;
        return future.get();
    }
};


CoroResult<int> fetch_data_coroutine(AsyncJob& api, int id)
{
    struct Awaiter
    {
        AsyncJob& api;
        int id;
        std::promise<int> promise;

        bool await_ready()
        {
            std::cout << "Awaiter::await_ready()" << std::endl;
            return false;
        }
        void await_suspend(std::coroutine_handle<> h)
        {
            std::cout << "Awaiter::await_suspend() called for id: " << id << std::endl;

            api.DoAsyncWork(id, [this, h](int value) mutable
            {
                std::cout << "Callback executed for id: " << id << " with value: " << value << std::endl;
                promise.set_value(value);
                h.resume(); // 이부분에서 await_resume() 메서드가 호출됨
            });
        }
        int await_resume()
        {
            int result = promise.get_future().get();
            std::cout << "Awaiter::await_resume() called, returning: " << result << std::endl;
            return result;
        }
    };

    std::cout << "fetch_data_coroutine called for id: " << id << std::endl;
    int result = co_await Awaiter{api, id};
    std::cout << "fetch_data_coroutine completed for id: " << id << " with result: " << result << std::endl;
    co_return result;
}


CoroResult<std::vector<int>> process_data(AsyncJob& api)
{
    std::vector<int> results;

    std::cout << "process_data started" << std::endl;
    for (int i = 0; i < 3; i++)
        results.emplace_back(co_await fetch_data_coroutine(api, i));

    std::cout << "process_data completed" << std::endl;

    co_return results;
}

int main()
{
    AsyncJob api;
    std::cout << "Starting async operations...\n";

    auto result = process_data(api);
    std::cout << "process_data coroutine created, calling get()" << std::endl;

    auto results = result.get();

    std::cout << "All operations completed. Results:\n";
    for (size_t i = 0; i < results.size(); ++i)
    {
        std::cout << "CoroResult " << (i + 1) << ": " << results[i] << '\n';
    }

    return 0;
}

/* 출력 :
 Starting async operations...
CoroResult::promise_type::get_return_object()
CoroResult(std::future<T>&& f)
CoroResult::promise_type::initial_suspend()
process_data started
CoroResult::promise_type::get_return_object()
CoroResult(std::future<T>&& f)
CoroResult::promise_type::initial_suspend()
fetch_data_coroutine called for id: 0
Awaiter::await_ready()
Awaiter::await_suspend() called for id: 0
DoAsyncWork called for id: 0
Callback executed for id: 0 with value: 0
Awaiter::await_resume() called, returning: 0
fetch_data_coroutine completed for id: 0 with result: 0
CoroResult::promise_type::return_value()
CoroResult::promise_type::final_suspend()
CoroResult::await_ready()
CoroResult::await_resume()
CoroResult::promise_type::get_return_object()
CoroResult(std::future<T>&& f)
CoroResult::promise_type::initial_suspend()
fetch_data_coroutine called for id: 1
Awaiter::await_ready()
Awaiter::await_suspend() called for id: 1
DoAsyncWork called for id: 1
Callback executed for id: 1 with value: 10
Awaiter::await_resume() called, returning: 10
fetch_data_coroutine completed for id: 1 with result: 10
CoroResult::promise_type::return_value()
CoroResult::promise_type::final_suspend()
CoroResult::await_ready()
CoroResult::await_resume()
CoroResult::promise_type::get_return_object()
CoroResult(std::future<T>&& f)
CoroResult::promise_type::initial_suspend()
fetch_data_coroutine called for id: 2
Awaiter::await_ready()
Awaiter::await_suspend() called for id: 2
DoAsyncWork called for id: 2
Callback executed for id: 2 with value: 20
Awaiter::await_resume() called, returning: 20
fetch_data_coroutine completed for id: 2 with result: 20
CoroResult::promise_type::return_value()
CoroResult::promise_type::final_suspend()
CoroResult::await_ready()
CoroResult::await_resume()
process_data completed
CoroResult::promise_type::return_value()
CoroResult::promise_type::final_suspend()
process_data coroutine created, calling get()
CoroResult::get()
All operations completed. Results:
CoroResult 1: 0
CoroResult 2: 10
CoroResult 3: 20

 */


위 비동기 작업 래핑 방식을 실행하여

디버깅을 통해 어떤 순서로 흐름이 흘러가는지 잘 익혀두자.

Q2. 코루틴은 성능을 크게 신경쓰지 않아도 될까?
A2. 코루틴 프레임 할당 시 관련 최적화 기술 HALO(Heap Allocation eLision Optimization)의 맹점에 주의해야 한다.