[C++20] Coroutine연구

MIN·2025년 6월 4일
0

CPP20

목록 보기
4/8

Coroutine

코루틴(coroutine)은 실행 도중에 일시 정지(suspend) 했다가, 이후에 다시 이어서 실행할 수 있는 함수를 의미한다.

일반적으로 코루틴에는 2가지가 있다.

  • 스택 기반 코루틴:
    중첩된 호출 사이 어디에서나 일시 정지 가능
  • 스택 없는 코루틴:
    가장 바깥쪽 함수(Top-level 함수)에서만 일시 정지할 수 있다.

스택 없는 코루틴은 콜 스택(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개의 함수가 반드시 정의되어 있어야 한다:

  • get_return_object : 코루틴 객체를 반환
  • initial_suspend : 코루틴이 실행 전에 중단/연기될 수 있는지
  • final_suspend : 코루틴이 종료 전에 중단/연기될 수 있는지
  • return_void : co_return에 의해 호출됨
  • unhandled_exception : 예외 처리시 호출됨

이제 다음 단계에서는 이 함수들이 어떤 상황에서 호출되고,
어떻게 동작을 조절하는지에 대해 더 자세히 알아볼 예정이다.

co_await

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을 활용한 가장 기본적인 형태의 코루틴이다.

// 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는 호출자에게 값을 하나 반환하고, 코루틴을 일시 정지시킨 뒤, 다시 호출되면 멈췄던 지점부터 이어서 실행하는 방식이다.

아래 예제는 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

profile
게임개발자(진)

0개의 댓글