Coroutine을 구글에 검색했을 때 유니티의 코루틴이 제일 많이 나온다. 이는 C#의 코루틴을 유니티에서 적극 채용해 랩핑해서 사용하는 형태이다. 유니티에서 코루틴을 사용해 쿨타임 계산 등이 편리했던 기억이 있다. 이런 코루틴의 기능의 C++20 에도 도입 되었다.
Coroutine을 한 마디로 요약하자면 어떤 함수를 호출했는데 어디까지 호출되었는지 저장하고 일시정지했다가 이어서 호출할 수 있는 기능이다.
①
사용될 수 있는 예를 한번 들어보자.
엄청 복잡하고 어려운 기능을 하는 함수가 있다고 가정해보자. 스타크래프트에서 군집화된 유닛들이 A*같은걸 이용해 길을 찾는 알고리즘 같은것.! 연산이 무거운 함수는 함수가 호출되고 다음으로 넘어가기 때문에 이런 함수는 호출 자체가 큰 부담이 될 것이다. 그리고 게임은 무한 루프 속에서 한 프레임마다 한번씩 함수를 호출하게 될것이고 이는 엄청 부담이 된다. 60FPS라 가정하면 1초에 60번씩 저 함수를 호출해야하는데 길찾기 로직을 1/60초 만에 끝내야하는것도 아니고 오래걸리게 되면 틱이 다 밀리게 되는 일이 발생할 수 있다. 즉, 복잡한 연산은 모두 한 번에 끝낼 필요가 없고 나눠서 처리해도 된다는 것이다.
위와 같이 1000번까지 돌아야하는 반복문이라면 한 번 호출될 때마다 100씩 0-100, 100-200,..총 10번 돌아서 해결하면 효율적으로 처리할 수 있다. 그런데 이 함수의 상태를 Coroutine이 등장하기 전에는 어디에 저장했냐하면 거의 전역으로 static변수를 만들어 임시저장할 수 있다. 그러나 임시저장할 변수가 굉장히 많이지면 이도 복잡해지므로 Coroutine이 필요해진다. 결국 하나의 복잡한 함수를 조금씩 쪼개서 호출할 수 있도록 현재 상태를 저장하고 중지시켰다가 나중에 호출하고 싶을때 재개하는게 코루틴이다.
②
서버에서는 어떻게 이 코루틴을 이용할 수 있을지 가정해보자.
일반적인 MMORPG에서 몬스터를 잡게되면 경험치도 오르고 아이템도 인벤토리에 들어가고 한다. 이를 순서대로
1. 몬스터 잡음
2. DB저장
3. 아이템 생성 + 인벤토리 추가 (인게임 메모리)
의 순서대로 이루어져야하는데 DB접근 처리가 상대적으로 오래걸리고 만약 아이템 테이블이 많으면 더 오래걸린다. 그런데 MMORPG에서 짧은 순간에 많은 일이 이루어지는데 2가 끝날때까지 기다리는건 무리이다. 그래서 만약 2fmf 별도의 스레드에 던져두고 아이템을 메모리에 생성하고 인벤토리에 추가하고 인게임 로직을 따로 돌리면 되지 않을까 싶지만 이와같이 DB와 관련된 부분과 인게임 로직을 분리하게 되면 위험할 수 있다. 만약 DB에 아이템 추가가 실패한경우 메모리에는 들고있고 렉이 생기다보면 아이템 복사가 일어날 수도 있어서 꼭 1,2,3의 일들이 순차적으로 일어나야 좋다.
이런 모순을 지금까지 해결했던 방법은 이벤트 방식이었다. 잡큐를 만들어서 DB에 아이템 생성일을 넣은 다음 완료되면 콜백으로 3을 람다로 이어서 실행되는 방식이었다. 필연적으로 사용됐으나 이는 복잡하기에 코루틴으로 이를 어떻게 처리할지 고려해보자.
1,2,3을 합친 KillMonster 일련을 Coroutine으로 만들어서 호출한 다음에 그동안 KillMonster외의 다른 부분을 처리하고, 이어서 DB가 성공적으로 처리됐으면 인게임 부분을 처리하도록 할 수 있다.
이렇게 코루틴을 응용할 수 있는곳이 무궁무진하다. 이제 그 동작 방식과 사용 방법을 살펴보자.
우선 규칙을 살펴보자.
1. Coroutine을 사용하려면 아래 중 하나를 코루틴으로 만들 함수에서 사용해야한다.
함수가 코루틴이 되려면...
co_return
co_yield
co_await
앞으로 세 개의 예제를 통해 각각 어떻게 사용되는지 볼 예정이다.
2. 코루틴 함수는 코루틴 객체를 반환형으로 해야한다. 코루틴 객체는 struct나 class로 정의할 수 있으며 promise 객체를 내부에 갖고 있어야한다.
그리고 promise 객체는 다음과 같은 인터페이스를 제공해야한다.
- 기본 생성자 : promise 객체는 기본 생성자로 만들어질 수 있어야 함
- get_return_object : 코루틴 객체를 반환 (resumable object)
- return_value(val) : co_return val에 의해 호출됨 (코루틴이 영구적으로 끝나지 않으면 없어도 됨)
- return_void() : co_return에 의해 호출됨 (코루틴이 영구적으로 끝나지 않으면 없어도 됨)
- yield_value(val) : co_yield에 의해 호출됨
- initial_suspend() : 코루틴이 실행 전에 중단/연기될 수 있는지
- final_suspend() : 코루틴이 종료 전에 중단/연기될 수 있는지
- unhandled_exception() : 예외 처리시 호출됨
3. 코루틴의 3요소
3가지 요소로 구성
- promise 객체
- 코루틴 핸들 (밖에서 코루틴을 resume / destroy 할 때 사용. 일종의 리모컨)
- 코루틴 프레임 (promise 객체, 코루틴이 인자 등을 포함하는 heap 할당 객체)
즉, 2와 3을 미루어 봤을 때 C++ 20에서는 코루틴을 사용할 수 있는 일종의 Framework를 제공하는 것이며 이 프레임워크를 규칙에 맞게 활용하는 것이다.
4. 코루틴 실행시 일어나는 일
코루틴 실행시 일어나는 일
1. 코루틴이 최초 실행 되면 new를 이용해 힙 메모리 영역에 coroutine state를 생성합니다. 2. 코루틴 함수의 모든 인자들을 coroutine state에 복사합니다. 이때 모든 인자들은 move 되거나 복사 됩니다. 단, 레퍼런스들은 그대로 레퍼런스로 남아 있습니다. (*만일 코루틴이 재개(resume) 될 때, 레퍼런스 변수들의 생명 주기가 이미 종료 되었다면 뎅글링 레퍼런스를 참조 할 수 있으므로 코루틴 함수의 인자로 레퍼런스 타입을 사용 할 때는 주의가 필요 합니다.)
3.promise 객체의 생성자를 호출 합니다.
(*만일 promise 타입의 생성자가 모든 코루틴 함수의 인자를 가지고 있다면 해당 생성자가 호출 됩니다. 그렇지 않다면 기본 생성자가 호출 됩니다.)
4. 코루틴 반환 객체를 생성하여 반환하는 get_return_object() 함수를 호출 합니다. 이 값은 로컬 변수에 저장되었다가 최초 코루틴 중단(suspend) 시 코루틴 호출자에게 리턴 됩니다.
5. promise객체의 initial_suspend() 를 호출하고 그 결과를 외부 코루틴 함수의 co_await/ co_return/ co_yield 오퍼레이터에게 전달 합니다.
일반적으로 initial_suspend() 함수는 게으른 시작(lazily-start)을 위해 suspend_always를 리턴하거나, 즉시 시작(eagerly-start)을 위해 suspend_never를 리턴합니다.
6. 외부 코루틴 함수에서 co_await연산자를 썼으면 initial_suspend()로 코루틴 함수 밖으로 빠져나간 후
코루틴이 다시 재게(resume) 되면 코루틴은 그제서야 본문(사용자가 정의한코루틴 함수의 내용들)을 실행 합니다.
위 일들이 코루틴 함수에서 co_await, co_return, co_yield중 하나를 만났을 때 즉, 함수가 코루틴일 때 일어나는 일이다.
이 중 1,2번은 코루틴이 실행되는 원리이나 자동으로 진행된다.
그리고 코루틴 함수에서 반환형이 코루틴 객체이며 이 객체는 promise타입 객체를 들고 있는 것이다. 또
5. 예시
5-1. co_return 사용하기.
#include <coroutine>
#include <list>
#include <vector>
// 코루틴 객체
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() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void unhandled_exception() { }
// 데이터
shared_ptr<T> _ptr = make_shared<T>();
};
};
Future<int> CreateFuture()
{
co_return 2021;
}
int main()
{
auto future = CreateFuture();
// TODO : 다른걸 하다...
cout << future.get() << endl;
return 0;
}
main부터 살펴보면 우선 코루틴 함수인 CreateFuture를 실행하려고 보니 co_return이 있었다. 이를 만나면 어떻게 되는지 아래 의사코드로 살펴보자. 실제 실행되는 코드가 아닌 동작 방식인것.!
⭐ co_yield, co_await, co_return을 함수 안에서 사용하면, 그 함수는 코루틴이 됨. ⭐
Promise prom; (get_return_object)
co_await prom.initial_suspend();
try
{
// co_return, co_yield, co_await를 포함하는 코루틴 함수의 코드
}
catch (...)
{
prom.unhandled_exception();
}
co_await prom.final_suspend();
우선 co_return을 CreateFuture에서 만났으므로 코루틴 객체 Future 안의 promise객체 생성자가 만들어지고
코루틴 반환 객체를 생성하여 반환하는 get_return_object() 함수를 호출한다. Future클래스의 promise_type에서 정의했던대로 값을 하나 받아서(여기선 co_return 2021이므로 2021) promise객체의 get_return_object안에서 코루틴 객체 Future의 생성자에 넘겨주고 만들어 이 객체를 반환한다. 즉, promise객체랑 Future객체랑 관련있는것을 get_return_object에서 처리한다. 실질적으로 promise객체가 들고있는 포인터를 Future에도 넘겨줘 같이 가리킨다.그리고 이와같이위의 4에서 3,4가 실행되면 다시 promise_type에 정의되었던 initial_suspend가 호출된다.
initial_suspend는 initial_final과 마찬가지로 반환형이 awiatable인 suspend_always 혹은 suspend_never 다.
inital_suspend를 만나면 우선 코루틴 함수 밖으로 호출자로 나가는데 이 다음에 이 함수를 정지할지 아니면 무시하고 진행할지 각각 정하는 것이다. 여기선 suspend_never로 그냥 무시하고 정지하지 않을것이라 했다.
awaitalbe의 플로우
if awaitable.await_ready() returns false;
suspend coroutine
awaitable.await_suspend(handle) returns:
void:
awaitable.await_suspend(handle);
coroutine keeps suspended
return to caller
bool:
bool result = awaitable.await_suspend(handle);
if (result)
coroutine keeps suspended
return to caller
else
return awaitable.await_resume()
another coroutine handle:
anotherCoroutineHandle = awaitable.await_suspend(handle);
anotherCoroutineHandle.resume();
return to caller;
return awaitable.await_resume();
await_ready가 false이면 아직 준비가 안됐으니 코루틴을 suspend한다.
그리고 그밖의 promise_type을 정의한 함수들을 보면 이 코루틴함수 CreateFuture에서 co_return을 사용했으므로 2 에서 봤듯이 멤버를 반환하는 return_value를 만들어줬다. (그러나 아마도 따로 CreateFuture객체를 핸들로써 사용해 재개하지 않으므로 굳이 사용하지는 않을것 같다.?)
그리고 inital_suspend에서 다시 CreateFuture로, 다시 main함수로 돌아오면 코루틴객체 CreateFuture타입의 future의 멤버 get()을 하면 2021이 출력된다.
5-2. co_yield사용해서 코루틴 재개해보기.
Future 코루틴 객체에서는 그냥 값을 리턴하는 역할밖엔 안했지만
return_yeild를 살펴볼 Generator 코루틴 객체는 정말 값을 저장했다가 재개하는걸 할 것이다.그리고 그러려면 코루틴 핸들을 밖에서 들고 있다가 이를 이용해 제어해야 한다.(함수 밖에서 이를 이용해 끝낼지, 재개할지) 코루틴 객체를 만들 때 핸들을 인자로 받아서 코루틴 객체에서도 이를 들고 있는다.
#pragma once
#include <coroutine>
#include <iostream>
using namespace std;
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() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(const T value) { _value = value; return {}; }
std::suspend_always return_void() { return {}; }
void unhandled_exception() { }
T _value;
};
};
int main()
{
auto numbers = GenNumbers(0, 1);
for (int i = 0; i < 20; i++)
{
numbers.next();
cout << " " << numbers.get();
}
return 0;
}
그리고 이 코루틴핸들을 이용해 코루틴객체가 될 class(혹은 struct)안에서 코루틴프레임(핸들?) destory혹은 resume(재개)해 줄 수 있다. 그리고 이 코루틴 핸들로 done()을 하면 이 함수가 끝났는지 여부도 확인할 수 있다.
그리고 마찬가지로 이 핸들을 이용해 값을 꺼내오기 위해 get()을 만들어줬다.
이번엔 suspend_always를 이용해보았다. 위의 Future에서는 절대 멈추지 말고 넘어가라는 suspend_never를 사용했는데 이번엔 무조건 멈추고 빠져나가라고 inital_suspend, final_suspend에서 awaitable에 반환하고 있다.
'[C++]' 카테고리의 다른 글
가상함수테이블과 가상함수테이블포인터 정리 (0) | 2025.01.02 |
---|---|
[C++ 20] Range (0) | 2024.12.09 |
[C++ 20] Module (1) | 2024.11.13 |
Modern C++ #9 전달 참조(forwarding reference) (0) | 2024.10.22 |
Modern C++ #8 오른값 참조(rvalue reference)와 std::move (0) | 2024.10.22 |