코루틴(coroutine)은 실행 도중에 일시 정지(suspend) 했다가, 이후에 다시 이어서 실행할 수 있는 함수를 의미한다.
일반적으로 코루틴에는 2가지가 있다.
스택 없는 코루틴은 콜 스택(call stack)을 저장하지 않으며, 함수 본문 내에서 스토리지 기간이 자동인 변수나 임시 객체만 저장한다.
이러한 구조 덕분에 메모리 사용량이 적고, 수백만 개 또는 심지어 수십억 개의 코루틴을 동시에 실행하는 것도 가능하다.
C++20에서는 바로 이 스택 없는 코루틴만을 지원한다.
함수 본문에 co_await, co_return, co_yield 중 하나라도 포함되어 있다면,
해당 함수는 자동으로 코루틴으로 간주된다.
이때, 이 함수는 반드시 promise_type을 포함한 특정 구조를 갖추고 있어야 한다.
struct promise_type
{
CoroutineObject get_return_object() { return {}; }
std::suspend_never initial_suspend() const noexcept { return {}; }
std::suspend_never final_suspend() const noexcept { return {}; }
void return_void() { }
void unhandled_exception() { }
};
위 구조체는 코루틴의 핵심 컨트롤 역할을 하며,
다음과 같은 5개의 함수가 반드시 정의되어 있어야 한다:
이제 다음 단계에서는 이 함수들이 어떤 상황에서 호출되고,
어떻게 동작을 조절하는지에 대해 더 자세히 알아볼 예정이다.
co_await는 어떤 연산이 끝날 때까지 코루틴의 실행을 일시 정지하고,
연산이 완료되면 다시 실행을 이어가는 역할을 하는 키워드이다.
바로 예제를 보자.
// co_await 사용 예제
class Job
{
public:
struct promise_type;
using handle_type = coroutine_handle<promise_type>;
Job(handle_type handle) : _handle(handle)
{
}
~Job()
{
if (_handle)
_handle.destroy();
}
void start()
{
if (_handle)
_handle.resume(); // 정지된 코루틴 재개
}
private:
handle_type _handle;
public:
struct promise_type
{
Job get_return_object() { return Job(handle_type::from_promise(*this)); }
std::suspend_always initial_suspend() { cout << "Prepare Job" << endl; return {}; }
std::suspend_never final_suspend() noexcept { cout << "Do Job" << endl; return {}; }
std::suspend_never return_void() { return {}; }
void unhandled_exception() { }
};
};
Job PrepareJob()
{
// 실제로는 일시 정지되지 않음
co_await std::suspend_never();
}
int main()
{
auto job = PrepareJob();
job.start();
return 0;
}
위 코드에서 중요한 점들을 정리해 보자.
PrepareJob()은 코루틴 함수이며, 내부에서 co_await를 사용해 일시 정지를 시도한다.
하지만 std::suspend_never는 즉시 복귀하는 동작을 하기 때문에, 실제로는 정지가 발생하지 않는다.
이후 start() 멤버 함수가 호출되면서 내부적으로 resume()을 통해 코루틴이 실행된다.
마지막으로, 코루틴이 종료될 때는 ~Job() 소멸자가 호출되어 destroy()를 통해 핸들이 정리된다.
PrepareJob에서 suspend_never 함수를 호출하고 있는데, 이것을 이해하기 위해서는 suspend_never와 suspend_ready가 구현된 코드를 보면 쉽게 이해할 수 있다.
_EXPORT_STD struct suspend_never { // 즉시 복귀
_NODISCARD constexpr bool await_ready() const noexcept {
return true;
}
constexpr void await_suspend(coroutine_handle<>) const noexcept {}
constexpr void await_resume() const noexcept {}
};
_EXPORT_STD struct suspend_always { // 항상 정지
_NODISCARD constexpr bool await_ready() const noexcept {
return false;
}
constexpr void await_suspend(coroutine_handle<>) const noexcept {}
constexpr void await_resume() const noexcept {}
};
suspend_never는 await_ready()가 항상 true를 반환하기 때문에,
co_await를 만나더라도 정지하지 않고 바로 다음 코드를 실행하게 만든다.
반면 suspend_always는 await_ready()가 false이므로, 코루틴이 반드시 suspend 상태에 들어가도록 강제한다.
이후 resume()을 호출해주지 않으면 코루틴은 계속 정지된 채로 남아 있게 된다.
마지막으로 예제 코드의 결과를 보고 다음으로 넘어가겠다.
Prepare Job
Do Job
initial_suspend()에서 "Prepare Job"을 출력하고, suspend_always가 아니라 suspend_never이기 때문에 곧바로 실행을 이어간다.
코루틴이 종료될 때 final_suspend()가 실행되며 "Do Job"이 출력된다.
co_return은 코루틴의 실행을 종료시키고, 이후의 루틴은 다시 재개되지 않는다.
아래 예제는 co_return을 활용한 가장 기본적인 형태의 코루틴이다.
// co_return 사용 예제
template<typename T>
class Future
{
public:
Future(shared_ptr<T> value) : _value(value) { }
T get() { return *_value; }
private:
shared_ptr<T> _value;
public:
struct promise_type
{
Future<T> get_return_object() { return Future<T>(_ptr); }
void return_value(T value) { *_ptr = value; }
std::suspend_never initial_suspend() { cout << "Future Init" << endl; return { }; }
std::suspend_never final_suspend() noexcept { cout << "Future Final" << endl; return {}; }
void unhandled_exception() { }
// 데이터
shared_ptr<T> _ptr = make_shared<T>();
};
};
Future<int> CreateFuture()
{
co_return 2025;
}
int main()
{
auto future = CreateFuture();
cout << future.get() << endl;
return 0;
}
위 코드는 co_await와 크게 다르지 않다.
CreateFuture()에서 2025라는 값을 co_return으로 반환하면, 내부적으로 return_value()가 호출되어 _ptr에 해당 값이 저장된다.
Future 객체는 해당 값을 갖고 있다가, get() 호출 시 반환한다.
핵심은, co_return을 사용함으로써 코루틴의 실행을 즉시 종료하고, 필요한 값을 외부에 전달할 수 있다는 점이다.
이는 결과 값을 비동기적으로 전달할 수 있는 가장 단순하고 직관적인 방식이다.
결과값만 빠르게 확인하고 다음으로 넘어가겠다.
Future Init
Future Final
2025
co_yield는 호출자에게 값을 하나 반환하고, 코루틴을 일시 정지시킨 뒤, 다시 호출되면 멈췄던 지점부터 이어서 실행하는 방식이다.
아래 예제는 co_yield를 사용하여 숫자를 생성해주는 제너레이터(generator)를 구현한 예제다.
// co_yield 사용 예제
template<typename T>
class Generator
{
public:
struct promise_type;
using handle_type = coroutine_handle<promise_type>;
Generator(handle_type handle) : _handle(handle)
{
}
~Generator()
{
if (_handle)
_handle.destroy();
}
T get() { return _handle.promise()._value; }
bool next()
{
_handle.resume(); // 코루틴 재개
return !_handle.done(); // 완료 여부 확인
}
private:
handle_type _handle;
public:
struct promise_type
{
Generator<T> get_return_object() { return Generator(handle_type::from_promise(*this)); }
std::suspend_always initial_suspend() { cout << "Generator Init" << endl; return {}; }
std::suspend_always final_suspend() noexcept { cout << "Generator Final" << endl; return {}; }
std::suspend_always yield_value(const T value) { _value = value; return {}; }
std::suspend_always return_void() { return {}; }
void unhandled_exception() { }
T _value;
};
};
여기서 핵심은 next() 멤버 함수다.
resume()을 호출해서 코루틴을 재개하고, done()을 통해 코루틴이 끝났는지를 판단한다.
즉, next()는 코루틴을 한 단계 실행시키고, 여전히 실행 가능한지 확인하는 역할을 한다.
다음은 실제 숫자를 생성하는 코루틴이다.
Generator<int> GenNumbers(int start = 0, int delta = 1)
{
int now = start;
while (true)
{
co_yield now;
now += delta;
}
}
이 함수는 무한히 숫자를 생성하면서 호출자에게 now 값을 하나씩 넘긴다.
co_yield는 내부적으로 yield_value를 호출해서 _value에 값을 저장하게 되고, get()을 통해 호출자가 이 값을 받아 볼 수 있다.
다음은 이를 사용하는 코드이다.
int main()
{
auto numbers = GenNumbers(0, 2);
for (int i = 0; i < 20; i++)
{
numbers.next();
cout << " " << numbers.get();
}
}
마지막으로 호출한 값을 보자.
Generator Init
0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30 32 34 36 38
지금까지 살펴본 것처럼 코루틴은 멀티스레드 환경에서 강력한 도구가 될 수 있다.
특히 비동기 프로그래밍에 적합해서, 여러 작업을 병렬적으로 처리해야 할 때 적재적소에 함수를 일시 정지하고 재개하는 흐름 제어가 가능하다.
C++로 비동기 처리를 하다 보면 메모리 사용량이나 오버헤드 때문에 고민이 많을 수 있는데, 코루틴은 이 문제를 해결할 수 있는 하나의 실마리가 될 수도 있다.
필자 역시 코루틴을 바로 실전 코드에 적용해 보고 싶지만, 아직 완전히 익숙하지는 않아서 좀 더 연습하고 나서 적용해볼 예정이다.
이번 시리즈에서는 Concept, Module, Range, Coroutine까지 C++20에서 추가된 핵심 개념들을 차근차근 공부해봤다.
하나하나가 독립적으로도 중요하지만, 공부를 해보니 이 개념들이 따로 놀지 않고 유기적으로 연결되어 있다는 느낌을 받았다.
아마 앞으로 배우게 될 개념들도 지금까지 살펴본 내용들과 자연스럽게 이어질 것이다.
그래서 공부를 할 때도 "이건 어디에 어떻게 쓰일까?"라는 생각보다는,
기본기를 다진다는 마음으로 천천히 쌓아가다 보면 점점 더 넓고 깊게 볼 수 있는 시각이 생기지 않을까 싶다.
지금까지 읽어준 독자들에게 감사드리며, 다음 글에서는 더 재밌고 실용적인 주제로 찾아오겠다.
Inflearn [Rookiss][C++20 훑어보기]
전문가를 위한 C++(개정5판) P1335~P1337
http://cppreference.com