std::execution이 등장한 이유 — 콜백 지옥과 구조적 동시성
콜백 지옥은 C++에서도 똑같이 벌어진다
JavaScript 개발자들이 프로미스 이전에 콜백 지옥을 겪었다는 이야기는 유명합니다. C++는 애초에 표준 비동기 모델이 마땅치 않아서, 그 문제를 지금까지도 각자의 방식으로 겪고 있습니다.
// 콜백 기반 비동기 코드 — 파일을 읽고, 파싱하고, 네트워크로 보낸다
void process_file(std::string path, std::function<void(Result)> on_done) {
read_file_async(path, [on_done](std::string content) {
parse_async(content, [on_done](Data data) {
send_async(data, [on_done](Result result) {
on_done(result); // 에러 처리는 어디서? 취소는 어떻게?
});
});
});
}읽기 쉽지 않다는 건 둘째치고, 이 코드에는 구조적인 문제가 세 가지 숨어 있습니다.
- 에러 전파가 일관되지 않습니다. 각 단계마다 실패를 어떻게 알릴지 직접 정해야 하고, 한 곳에서 정책을 놓치면 에러가 조용히 삼켜집니다.
- 취소가 사실상 불가능합니다.
read_file_async가 진행 중일 때 전체 작업을 취소하고 싶어도, 콜백 체인 어디에도 "그만둬"라고 알릴 표준 지점이 없습니다. - 어떤 스레드에서 실행되는지 코드만 봐서는 알 수 없습니다.
on_done이 I/O 스레드에서 불릴지, 스레드 풀에서 불릴지는 각*_async함수의 구현에 달렸는데, 그 구현은 호출부에서 안 보입니다.
std::future/std::async로 이 문제를 풀어보려는 시도도 있었지만, future::get()이 블로킹이라 진짜 비동기 체이닝이 안 되고, 취소·에러 전파·실행 컨텍스트 지정 문제는 그대로 남았습니다.
센더/리시버 — "일"을 값으로 다루자는 아이디어
std::execution(P2300)의 핵심 아이디어는 단순합니다. 비동기 작업을 "지금 실행되는 것"이 아니라 "나중에 실행될 수 있는 값"으로 표현하자는 것입니다. 이 값을 센더(sender)라고 부릅니다.
#include <execution>
namespace ex = std::execution;
// just(3) 은 "3을 즉시 반환하는 작업"을 나타내는 센더 — 아직 실행되지 않았다
ex::sender auto work = ex::just(3);ex::just(3)을 실행해도 당장 아무 일도 일어나지 않습니다. 이건 함수 호출이 아니라 "3을 값으로 갖는 작업"을 표현하는 값을 만든 것뿐입니다. 이 값을 다른 센더와 조합해서 파이프라인을 만들고, 마지막에 sync_wait나 스케줄러에 넘겨야 비로소 실행됩니다. 함수형 언어의 지연 평가(lazy evaluation)와 비슷한 발상입니다.
이 설계가 앞서 본 세 가지 문제를 어떻게 해결하는지 짚어보면:
- 에러는 센더 체인을 타고 흐르는 별도의 완료 채널로 정의됩니다. 성공(
set_value), 실패(set_error), 취소(set_stopped)가 프레임워크 차원에서 구분되므로, 콜백마다 다른 정책을 만들 필요가 없습니다. - 취소는
stop_token이 체인 전체에 전파되는 구조로 표준화됩니다. 콜백 체인처럼 임시방편으로 끼워 넣는 게 아니라 처음부터 모델에 포함됩니다. - 실행 컨텍스트는
scheduler라는 별도 개념으로 명시적으로 지정합니다. 이 작업이 어느 스레드 풀에서 도는지가 코드에 드러납니다.
"구조적 동시성"이라는 표현이 나오는 이유
이 모델을 설명할 때 "구조적 동시성(structured concurrency)"이라는 말이 자주 따라붙습니다. 일반 함수에서 중첩된 블록이 끝나면 그 안의 지역 변수도 함께 정리되는 것처럼, 비동기 작업 트리도 부모가 끝나기 전에 자식이 먼저 끝나는 걸 구조적으로 보장하자는 발상입니다. 콜백 기반 코드에서는 부모 함수가 이미 반환된 뒤에도 콜백이 아직 살아있는 상황이 흔했고, 이게 use-after-free나 종료 시점 경쟁 상태(race condition)의 흔한 원인이었습니다.
NVIDIA가 이 제안에 깊이 관여한 것도 우연이 아닙니다. GPU 커널 실행, CPU 스레드 풀, 네트워크 I/O처럼 실행 방식이 완전히 다른 작업들을 같은 어휘로 조합할 수 있어야 한다는 요구가 있었고, 센더/리시버는 그 다양한 실행 자원을 "스케줄러"라는 공통 인터페이스 뒤에 감춥니다. 다음 장에서 이 문법을 직접 손에 익혀보겠습니다.