C++프로그래밍수석 C++ 개발자

C++20 코루틴 약속 유형에 관하여, `await_suspend`에서 특정 반환 유형이 스택 없는 대칭 코루틴 전달을 가능하게 하는 이유는 무엇인가요?

Hintsage AI 어시스턴트로 면접 통과

질문에 대한 답변.

질문의 역사

초기 코루틴 구현은 스택 정적 할당 방식으로, 각 컨텍스트 전환 시 메가바이트의 고정 스택 공간을 할당하여 동시성을 수천 개의 작업으로 제한했습니다. C++20은 힙에서 프레임을 할당하는 스택 없는 코루틴을 도입했지만, 단순한 재귀 조합은 여전히 스택 오버플로우의 위험이 있었습니다. 비대칭 전달—await_suspend에서 void 또는 bool을 반환하는 것—은 복귀 지점에서 resume()을 호출하게 하여 O(N) 네이티브 호출 스택 프레임을 쌓게 했습니다. 대칭 전달은 코루틴 A가 코루틴 B를 직접 다시 시작하도록 허용하기 위해 표준화되어 A의 스택 프레임을 필수 꼬리 호출 최적화를 통해 relinquishing하게 했습니다.

문제

코루틴 A가 코루틴 Bco_await하고, BC를 기다릴 때, 비대칭 전달은 각 resume() 호출이 호출자에게 복귀한 후 더 깊이 내려가야 합니다. 재귀 깊이가 N(예: 50,000개 이상의 트리 노드 탐색)일 경우, 각 코루틴 프레임이 힙에 존재함에도 불구하고 네이티브 스택이 소진되어 SIGSEGV 또는 STATUS_STACK_OVERFLOW가 발생합니다.

해결책

await_suspendstd::coroutine_handle<Promise> (또는 std::coroutine_handle<>)를 반환해야 합니다. 컴파일러는 이를 꼬리 호출로 간주하여 현재 활성화 레코드를 파괴하고 호출 스택을 늘리지 않고 타겟 핸들의 재개 지점으로 직접 점프합니다. 이 메커니즘은 논리적 코루틴 중첩 깊이에 관계없이 상수 스택 깊이 실행을 보장합니다.

struct Task { struct promise_type { Task get_return_object() { return Task{std::coroutine_handle<promise_type>::from_promise(*this)}; } std::suspend_always initial_suspend() { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void return_void() {} void unhandled_exception() {} }; std::coroutine_handle<> h; }; struct SymmetricAwaiter { std::coroutine_handle<> target; bool await_ready() const noexcept { return false; } // 비대칭 (나쁜): void await_suspend(std::coroutine_handle<>) { target.resume(); } // 대칭 (좋은): 꼬리 호출 최적화 std::coroutine_handle<> await_suspend(std::coroutine_handle<>) noexcept { return target; } void await_resume() noexcept {} };

실제 상황

문제 설명

고주파 거래 엔진을 개발하는 동안, 복잡한 파생상품 가격 책정 트리를 모델링하기 위해 콜백 기반 비동기 I/O에서 C++20 코루틴으로 마이그레이션했습니다. 깊게 중첩된 합성 옵션(50,000+ 수준)을 포함한 포트폴리오로 스트레스 테스트를 수행하는 동안, 시스템은 힙에서 할당된 코루틴 프레임을 사용했음에도 불구하고 스택 오버플로우로 충돌했습니다. 원인은 await_suspend의 초기 구현이 void를 반환했기 때문에 가격 책정 모델의 깊이에 비례하여 네이티브 스택이 증가했기 때문입니다.

고려된 다양한 솔루션

솔루션 1: ulimit -s 또는 링커 플래그를 통해 네이티브 스택 크기 증가.

장점은 코드 변경이 필요하지 않으며 테스트 중 즉각적인 완화를 제공했습니다. 단점은 각 스레드마다 기가바이트의 가상 메모리를 낭비했으며, 무한 재귀 시나리오를 해결하지 못하고 LinuxWindows 간의 스택 할당 메커니즘 이질성으로 이식성 문제를 야기했습니다.

솔루션 2: 재귀가 없는 트램폴린 실행 루프 구현.

장점은 코루틴 구문을 유지하면서 스택 관리를 중앙 이벤트 루프로 이동했습니다. 단점은 가상 배치로 인해 컨텍스트 전환 당 수백 나노초의 상당한 대기 시간 이점과 스케줄러에서 코드 복잡성이 증가했으며, 정지 포인트 간 레지스터 할당에 대한 컴파일러 최적화가 손실되었습니다.

솔루션 3: await_suspend에서 std::coroutine_handle를 반환하여 대칭 전달 채택.

장점으로는 제로 오버헤드 추상화(핸드 작성 상태 기계와 동일한 어셈블리), 스택 증가 없이 자연스럽게 무한 재귀를 처리하며 가독성 있는 코루틴 구문을 유지합니다. 단점으로는 C++20 컴파일러 지원이 필요하고(일부 임베디드 플랫폼에서 초기에는 제한적이었음) 꼬리 호출 제거로 인해 스택 추적이 단축되어 디버깅이 복잡해졌습니다.

선택된 솔루션과 이유

우리는 솔루션 3을 선택했습니다. 금융 모델은 이론적 가격 계산을 위해 본질적으로 무한 재귀 깊이를 요구했습니다. 마이크로초 대기 시간 예산은 트램폴린의 오버헤드를 수용할 수 없었고, 메모리 제한으로 대규모 스택 사전 할당이 금지되었습니다. 대칭 전달은 정확하고 효율적인 제로비용 솔루션을 제공했습니다.

결과

엔진은 100,000개 이상의 중첩 수준을 가진 포트폴리오를 성공적으로 처리했습니다. 대기 시간 벤치마크는 손으로 최적화된 C 상태 기계와 동일한 성능을 보였으며, 메모리 사용량은 재귀 깊이에 관계없이 일정하게 유지되었습니다. 시스템은 스택 관련 충돌 없이 18개월 동안 운영되었습니다.

후보자들이 자주 놓치는 점

await_suspendvoid를 반환하는 것과 true를 반환하는 것이 코루틴 프레임의 중단 타이밍에 있어서 다른 이유는 무엇이며, 이로 인해 스레드 안전성이 왜 중요한가요?

많은 후보자들은 void가 즉각적인 중단과 제어를 이전한다고 가정합니다. 실제로 void를 반환하면 현재 코루틴이 중단되지만 제어는 resume()호출자에게 돌아가고, 그 후 다음 실행 단계를 결정합니다. true를 반환하면 또한 중단되지만, 비판적으로도 voidawait_suspend가 반환하기 전에 코루틴이 중단되도록 보장하지만, bool로 중단되는 정확한 타이밍은 구현에 따라 달라질 수 있습니다. 이 구분은 await_suspendvoid를 반환한 후 코루틴 로컬에 접근하면(예: 다른 스레드에서) 중단 포인트에 도달한 후에만 안전하므로 중요합니다. 대칭 전달(핸들을 반환하는 경우)에서는 반환 즉시 스택 프레임이 파괴되어 로컬에 즉시 접근할 수 없게 되어, 후보자들은 종종 대칭 전달을 시작한 후 캡처된 변수를 접근하여 데이터 레이스를 발생시킵니다.

대칭 전달이 예외 처리를 어떻게 상호작용하며, 목표 코루틴이 예외를 던질 때 promise 유형의 unhandled_exception을 복잡하게 만드는 이유는 무엇인가요?

후보자들은 대칭 전달이 대기 코루틴을 통해 일반적인 스택 풀림을 우회한다는 사실을 자주 놓칩니다. 코루틴 A가 대칭적으로 B로 전달하고 B가 예외를 던지면, 예외는 B의 unhandled_exception으로 전파됩니다. 그러나 A의 스택 프레임은 이미 꼬리 호출 최적화를 통해 교체되었기 때문에, A는 co_await 표현식 주위에서 try/catch를 사용하여 B에서의 예외를 잡을 수 없습니다. 예외는 대신 A의 원래 호출자(재개자)에게 전파되어 A의 정리 코드를 건너뛰는 결과가 발생할 수 있으며, 이는 A의 promise가 힙에 할당된 프레임을 통해 상태를 관리하는 경우를 제외하고는 처리됩니다. 초보자들은 RAII 스택 가드가 A에서 작동한다고 가정하는 경향이 있어 대칭 체인에서 예외가 발생할 때 자원 누수가 발생합니다.

std::noop_coroutine()의 대칭 전달 체인에서의 중요성은 무엇이며, 완료를 나타내기 위해 기본 생성된 핸들이 아니라 이를 반환해야 하는 이유는 무엇인가요?

기본 생성된 std::coroutine_handle는 반환된 경우 정의되지 않은 동작을 발생시키는 null 핸들이며, 이를 await_suspend에서 반환하는 것은 "지금은 아무것도 재개하지 않는다"는 것을 나타내어 현재 코루틴을 후속자가 없는 상태로 남겨두어 스케줄러가 유효한 연속을 기대할 경우 시스템을 걸리게 할 수 있습니다. **std::noop_coroutine()**는 재개할 경우 즉시 호출자에게 돌아가는 특별한 싱글톤 핸들을 반환합니다. 이는 완료에 매우 중요합니다: 리프 코루틴이 끝나고 수동 재개 없이 부모에게 제어를 반환하고자 할 때, std::noop_coroutine()을 반환합니다. 이렇게 하면 자식에게 대칭적으로 전달된 부모의 await_suspend가 유효한 "계속"을 수신하여 단순히 반환하여 체인을 안전하게 종료할 수 있습니다. 후보자들은 null 핸들을 noop 핸들과 혼동하여 시스템이 null 재개 대상에서 영원히 대기하는 미세한 교착 상태를 발생시킵니다.