C++/C++

C++ - 코루틴 (coroutine)

오의현 2024. 4. 14. 22:07

C++ 20부터 추가된 코루틴이라는 기능에 대해 알아보자.

본인도 공부하면서 찾아본 정보를 정리하는거라 다소 정확하지 않을 수 있다.

 

이 코루틴이라는 기능이 다른 언어에는 진작부터 있는 기능이었다고 한다.

C++은 C++ 20이 되어서야 추가되었고, 사용법 또한 C++ 답게 다소 복잡하다고 한다.

(사실 다른 언어에서 사용을 안해봐서 얼마나 복잡한지는 모름)

 

코루틴이 먼저 무엇인지 알아보자.

일반적인 함수는 위와 같은 방식으로 실행된다.

 

A함수에서 B함수를 호출하게 되면, B함수가 실행되고, B함수는 내부에서 return문을 만나거나 함수의 끝에 도달하게 되면 함수를 종료하고, 다시 A함수로 돌아가 남은 코드를 실행하게 된다.

 

반면, 코루틴 함수는 아래 그림과 같다.

중간에 중단했다가, 나중에 그 위치부터 다시 진행하고, 다시 중단했다가, 다시 진행하며 A함수와 B함수를 왔다갔다 할 수 있다.

 

일반적인 함수는 중간에 return하며 스레드가 해당 함수를 탈출한 뒤, 다시 함수를 호출하면 그 함수의 처음부터 실행하게 된다. 반면, 코루틴 함수는 스레드가 함수를 탈출한 위치를 저장해뒀다가 코루틴 함수를 재개하면 함수가 탈출했던 위치부터 다시 함수를 실행하게 된다.

 

사실, 이 기능이 어떻게 활용되는 지는 아직 잘 모른다. 일단 이 기능이 어떻게 구현되고, 어떻게 사용되는지를 먼저 알아보고 나서 활용방법에 대해서는 따로 정리된 게시글을 작성할 생각이다. 

 

먼저, 코루틴 함수를 사용하기 위해선 코루틴 객체가 필요하다. 코루틴 함수를 중단하고 재개하는 과정은 코루틴 객체를 통해 이루어진다. 코루틴 함수를 최초에 호출하면 해당 함수는 코루틴 객체를 반환하게 되고, 반환된 객체를 이용하여 원하는 시기에 코루틴 함수를 재개하는 것이다.

 

먼저 코루틴을 사용하기 위한 틀을 만들어보겠다.

참고로 코루틴을 사용하기 위해선 C++ 20이상이어야 하고 coroutine 헤더파일을 포함해야 한다.

 

#include <coroutine>

class MyCoroutine
{
public:
private:
};

MyCoroutine CoroutineTest
{
}

int main()
{
    return 0;
}

 

위와 같이 틀을 만들어 보았다.

코루틴 객체인 MyCoroutine 클래스를 만들었고, 테스트를 위한 코루틴 함수인 CoroutineTest 함수도 선언해주었다.

 

이제 코루틴 객체를 채워보자. 코루틴은 일종의 프레임워크이다. 정해진 규칙이 있고, 그 규칙에 맞게 설계해야 한다.

코루틴 객체를 만들기 위한 규칙 중 중요한 규칙은 내부에 반드시 promise_type 이라는 구조체가 선언되어 있어야 한다는 것이다.

 

promise_type을 내부에 선언하지 않으면 아래와 같은 컴파일 오류가 발생한다.

 

promise_type과 함께 코루틴 객체 내부를 좀 채워보도록 하겠다.

class MyCoroutine
{
public:
    struct promise_type
    {
        MyCoroutine get_return_object()
        {
            return  MyCoroutine{ std::coroutine_handle<promise_type>::from_promise(*this) };
        }

        std::suspend_always initial_suspend()
        {
            return std::suspend_always{};
        }

        std::suspend_always final_suspend() noexcept
        {
            return std::suspend_always{};
        }

        void unhandled_exception() {}
    };
    
    MyCoroutine(std::coroutine_handle<promise_type> _Handler) : Handler(_Handler) {}
    
    ~MyCoroutine()
    {
        if ((bool)Handler == true)
        {
            Handler.destroy();
        }
    }

public:
    const std::coroutine_handle<promise_type> GetHandler()
    {
        return Handler;
    }

private:
    std::coroutine_handle<promise_type> Handler;
};

 

뭔가 많이 생겼다.

하나씩 확인해보자.

 

먼저, private에 멤벼번수로 std::coroutine_handle<promise_type> 형의 Handler를 선언하였다.

이는 코루틴을 제어하는 핸들러 변수이다. 핸들러가 없어도 코루틴 함수는 실행하는 것에는 문제가 없다고 한다.

다만, 중지된 코루틴 함수를 재개하려면 핸들러가 반드시 있어야 한다고 한다. 그 외에도 핸들러에는 여러가지 기능이 있어서 웬만하면 만드는게 좋을 듯 하다.

 

MyCoroutine 의 생성자를 하나 만들었다.

std::coroutine_handle<promise_type> 타입의 인자가 들어오면 인수를 Handler에 저장하도록 하였다.

 

소멸자에서는 Handler가 할당되었다면 해당 Handler를 해제하도록 하였다.

 

중요한 것은 promise_type 구조체이다.

해당 구조체 안을 보면 4가지의 함수가 존재한다.

 

이 4가지의 함수는 반드시 promise_type안에 정의해야 하는 함수들이다.

 

1. get_return_obejct()

get_return_object() 함수는 코루틴 함수가 최초에 실행될 때, 반환하는 객체에 대한 함수이다.

코루틴 함수를 중간에 재개하는 등 코루틴 함수 외부에서 제어를 하려면 코루틴 객체가 필요하다.

 

그렇기 때문에 코루틴 함수의 반환형은 코루틴 객체로 되어있는데, get_return_object()함수가 정의되어 있지 않다면 반환할 코루틴 객체를 생성할 수 없어 오류가 발생한다.

 

2. initial_suspend()

코루틴 함수가 최초로 호출될 때, 어떻게 작동할지를 제어하는 함수이다.

반환형을 보면 std::suspend_alway{}로 되어있는데, std::suspend_never{} 도 존재한다.

 

std::suspend_alway{}를 반환하게 한다면 코루틴 함수는 최초에 실행될 때 코루틴 객체만 반환하고 함수 내부의 코드를 실행하지 않는다.

 

std::suspend_never{}를 반환하면, 코루틴 함수 내부에서 중지 명령을 찾기 전까지 함수의 코드를 실행한 뒤, 그 이후 코루틴 객체를 반환하게 된다.

 

3.final_suspend()

코루틴 함수는 외부에서 코루틴 핸들의 멤버함수인 done함수를 통해 함수가 끝났는지 끝나지 않았는지 여부를 확인할 수 있다. 

 

만약 final_suspend()에서 std::suspend_never{}를 반환했다면, 함수가 종료되는 순간 그냥 함수를 끝내버리게 된다.

코루틴 함수는 종료되는 순간 코루틴 핸들을 해제해버린다. 즉, 외부에서 핸들러의 done()함수를 실행하는 순간, 해제된 메로리를 참조하기 때문에 프로세스가 펑 하고 터져버린다. 

 

반면, final_suspend()에서 std::suspend_always{}를 반환하게 되면, 코루틴 함수는 종료 직전에 일시중지 상태에 머문다.

그렇기 때문에 코루틴 핸들은 파괴되지 않고, 외부에서는 done()함수를 무사히 실행할 수 있다.

std::suspend_always{}를 반환하여 종료 직전에 일시정지 상태에 머문다고 하더라도, 개발자가 작성해놓은 함수 내부의 코드가 모두 실행되었다면 , done()는 true를 반환한다.

 

4. unhandled_exception()

코루틴 함수 내부에서 의도치 않은 오류가 발생했을 때, 호출하는 함수이다.

예외 처리, 디버깅에 대한 코드를 작성하고 싶다면, 이 함수 내부에 작성하면 된다.

 

이렇게 4가지의 함수는 promise_type의 핵심이기 때문에, 반드기 함수를 구현해주어야 한다.

 

그런데, 위의 코드에는 이 4가지 함수만 구현해놓았지만, 사실은 구현해야 하는 함수가 한 가지 더 있다.

 

5. return_void() , return_value()

구현해야 한다면서 이 함수를 왜 구현해놓지 않았냐면, 설명이 조금 필요하기 때문이었다.

 

이 두개의 함수는 동시에 선언할 수 없고, 코루틴 함수에서 값을 반환하느냐 마느냐에 따라 다르게 선언해야 한다.

코루틴 함수 자체는 기본적으로 코루틴 객체를 반환형으로 하고 있다.

 

하지만, 코루틴 함수가 아예 종료되는 순간 반환하고 싶은 값이 있을 수 있다.

 

그럴 땐, return_value()를 선언하면 된다.

딱히 반환할게 없다면, return_void()를 선언하면 된다.

 

이 게시글에선 두 가지 경우에 대해서 모두 테스트해보도록 하겠다.

먼저, return_void()에 대해서 구현해보겠다.

void return_void() {}

promise_type 내부에 return_void를 선언해주었다. 

 

다음은 CoroutineTest함수를 구현해보겠다.

MyCoroutine CoroutineTest()
{
    std::cout << "Coroutine 1" << std::endl;
    co_await std::suspend_always{};

    std::cout << "Coroutine 2" << std::endl;
}

 

내부를 보면, co_await이라는 처음보는 놈이 존재한다.

이 것은 코루틴 함수의 중지를 요청하는 문법이다.

 

코루틴 함수에서는 co_await, co_yield, co_return 세가지를 사용할 수 있다.

 

co_await는 값의 반환없이 중지하는 경우에 사용하고

co_yield는 값을 반환하며 중지하는 경우에 사용하며

co_return은 코루틴 함수를 종료할 때 사용한다.

 

먼저, co_await으로 테스트해보자.

co_await 뒤에는 std::suspend_always{}와 std::suspend_never{}를 사용할 수 있는데 std::suspend_never{}를 사용하면 중지되지 않고 그냥 함수를 계속 진행하게 된다. 

 

아래는 메인함수이다.

int main()
{
    std::cout << "main 1" << std::endl;
    MyCoroutine Coroutine = CoroutineTest();
    std::cout << "main 2" << std::endl;
}

먼저, main 1을 출력한 이후, CoroutineTest()를 실행하였다.

해당 함수는 코루틴 객체를 반환하기 때문에, 이를 저장할 MyCoroutine형 변수를 선언하여 반환된 객체를 저장하였다.

 

현재는 initial_suspend() 에서 std::suspend_always{}를 반환하도록 하였기 때문에, CoroutineTest()함수를 호출하여도 아무 것도 실행되지 않는다.

 

아래 사진은 그를 증명하는 실행결과이다.

분명 CoroutineTest()를 호출했음에도, 아무것도 실행되지 않았다.

처음 함수가 호출되고 일시정지 상태에 머물러있기 때문이다.

 

이 함수를 재개하기 위해선 코루틴 핸들의 resume함수를 호출해야 한다.

아래와 같이 코드를 작성한 뒤 실행해보겠다.

 

int main()
{
    std::cout << "main 1" << std::endl;
    MyCoroutine Coroutine = CoroutineTest();
    Coroutine.GetHandler().resume();
    std::cout << "main 2" << std::endl;
}

이번엔, Coroutine 1이라는 메시지가 중간에 출력되었다.

 

CoroutineTest함수 코드를 보면, 중간에 co_await을 이용해 중지시켰기 때문에 Coroutine 1만 호출되었다.

이번엔, 다시 아래와 같이 코드를 작성하고 실행해보겠다.

int main()
{
    std::cout << "main 1" << std::endl;
    MyCoroutine Coroutine = CoroutineTest();
    Coroutine.GetHandler().resume();
    std::cout << "main 2" << std::endl;
    Coroutine.GetHandler().resume();
}

이번엔, main2 이후에 Coroutine2가 출력되었다.

Coroutine1이 아닌 Coroutine2가 출력되었다는 것은, CoroutineTest함수가 처음부터 시작된 것이 아니라, 중간부터 시작되었다는 증거이다.

 

만약, 이 이후에 resume을 한 번 더 한다면?

프로세스는 터져버린다. 즉, resume을 할 때 프로세스가 터져버리는 걸 방지하기 위해, 항상 done()함수의 반환값을 확인하며 호출해야 한다. 아래와 같이 방어코드를 함께 작성해야 한다. 

int main()
{
    std::cout << "main 1" << std::endl;

    MyCoroutine Coroutine = CoroutineTest();

    if (Coroutine.GetHandler().done() == false)
    {
        Coroutine.GetHandler().resume();
    }	

    std::cout << "main 2" << std::endl;

    if (Coroutine.GetHandler().done() == false)
    {
        Coroutine.GetHandler().resume();
    }
}

 

코루틴 함수에 대해 설명은 끝났으니, 몇 가지 테스트를 해보자.

 

1. initial_suspend() 에서 std::suspend_never{}를 반환하는 경우

위에서 설명했지만, std::suspend_never{}를 반환하면, 코루틴 함수가 최초에 호출될 때 중지상태에 머물지 않고 co_await, co_yield, co_return을 만날 때까지 계속 실행된다.

 

std::suspend_never{}를 반환하도록 수정한 뒤 아래 코드를 실행해보겠다.

int main()
{
    std::cout << "main 1" << std::endl;

    MyCoroutine Coroutine = CoroutineTest();
    
    std::cout << "main 2" << std::endl;
}

 

아래는 실행 결과이다.

기존에는 resume함수를 호출하지 않으면 Coroutine 1이 출력되지 않았었다. 하지만, std::suspend_never{}를 반환하도록 수정하니 Coroutine 1 이 출력되는 것을 볼 수 있다.

 

 

2. return void가 아닌, return_value를 정의하는 경우

 

이 경우에는 수정을 해야하는 것이 좀 있다.

먼저, 반환할 값을 저장할 멤버변수를 선언해야 한다.

이후, return_value는 값을 그 멤버변수에 저장하도록 구현해야 한다.

 

아래와 같이 말이다.

int A = 0;

void return_value(int value) 
{
    A = value;
}

 

그리고 코루틴 객체 내부에는 return값을 반환해주는 함수를 만들어야 한다.

int GetReturnValue() 
{
    return Handler.promise().A;
}

 

이후 코루틴 함수에서 아래와 같이 return값을 반환하게 되면, promise_type객체 내부의 A에 반환값이 저장된다.

MyCoroutine CoroutineTest()
{
    std::cout << "Coroutine 1" << std::endl;
    co_return (5);

    std::cout << "Coroutine 2" << std::endl;
}

 

외부에서는 아래와 같이 반환값을 받아볼 수 있다.

int main()
{
    std::cout << "main 1" << std::endl;

    MyCoroutine Coroutine = CoroutineTest();
    Coroutine.GetHandler().resume();
    
    if (Coroutine.GetHandler().done() == true)
    {
        std::cout << Coroutine.GetReturnValue() << std::endl;
    }

    std::cout << "main 2" << std::endl;
}

 

여기서 반환값이라 하는 것은 사실 promise의 멤버변수 값을 그냥 받아올 뿐이다.

그렇기 때문에 함수가 co_return을 만나기 전까지는 반환받는 값이 의도와 다를 수 있다.

그러므로 반드시 done()를 체크한 뒤, 반환값을 확인해야 한다.

 

실행 결과는 아래와 같다.

정상적으로 return 값인 5가 출력되고 있다.

 

 

3. co_yield를 사용하는 경우

co_yield는 함수가 일시중지될 때, 값을 반환하기 위해 사용된다.

이를 사용하기 위해선, yield_value함수가 선언되어 있어야 한다.

 

과정 자체는 return_value와 동일하다.

아래와 같이 먼저 yield_value를 정의해주자.

 

int YieldValue = 0;
std::suspend_always yield_value(int _Value)
{
    YieldValue = _Value;
    return std::suspend_always{};
}

 

YieldValue라는 멤버변수에 데이터를 저장해주자.

 

이후, 코루틴 객체에 아래와 같이 YieldValue를 반환하는 함수를 만들자.

int GetYieldValue()
{
    return Handler.promise().YieldValue;
}

 

코루틴 함수는 아래와 같이 수정해보겠다.

MyCoroutine CoroutineTest()
{
    std::cout << "Coroutine 1" << std::endl;
    co_yield (5);

    std::cout << "Coroutine 2" << std::endl;
    co_yield (2);
}

 

이후 아래와 같이 메인함수를 실행해보자.

int main()
{
    std::cout << "main 1" << std::endl;

    MyCoroutine Coroutine = CoroutineTest();
    if (Coroutine.GetHandler().done() == false)
    {
        Coroutine.GetHandler().resume();
        std::cout << Coroutine.GetYieldValue() << std::endl;
    }
    
    if (Coroutine.GetHandler().done() == false)
    {
        Coroutine.GetHandler().resume();
        std::cout << Coroutine.GetYieldValue() << std::endl;
    }

    std::cout << "main 2" << std::endl;
}

 

결과는 아래와 같다.

 

yield에서 반환한 값이 잘 저장되어 있는 것을 볼 수 있다.

 

여기까지가 코루틴의 기초 사용법이었다.

커스텀하는 방법도 다양하고, 활용하는 방법도 다양한 것 같지만, 아직 잘 모르는 것이 많아서 본인도 더 찾아보고 연구해보아야 할 듯 하다.