C++/Game

[게임 프레임워크 개발 일지] #15 지옥의 Multithread와 메모리 관리

Kareus 2023. 7. 2. 22:29

이전에 시스템 이벤트 처리와 렌더링 루프를 멀티쓰레드로 분리했습니다. 무려 #2에서 말입니다.

그 이후로 이벤트 트리거나 ECS를 구축하면서 발견한 문제가 있습니다.

 

이벤트 처리에서 race condition이 발생합니다.

이걸 인지한 지는 오래됐는데, 작업 목록에 UNDONE 박아 놓고는 신경을 안 쓰고 있었습니다.

내가 선택한 업보가 이제 돌아왔군요

 

#2에서 렌더링 관련 race condition을 어느 정도 해결하려 했고, 해결하긴 했습니다만

사실 엉성하게 코드를 짜놓아서 재검토할 필요가 있었습니다.

상황을 간단히 정리하면 이렇습니다.

1. 시스템과 인게임의 쓰레드를 분리해놓았다.

2. 시스템 쓰레드와 렌더(인게임) 간에 문제가 발생한다. (race condition)

3. 시스템 쓰레드와 인게임 루프 간에 문제가 발생한다. (race condition)

 

3번의 예시로서, 마우스 클릭 이벤트로 Entity를 제거하도록 하고

인게임 루프에서는 Entity 정보를 참조하도록 코드를 짜면, 실행하고 얼마 안 있어 런타임 에러가 발생합니다.

void update(float delta)
{
    if (!entityManager.isValid(id))
         id = entityManager.createEntity().getRawEntity();
    
    if (entityManager.isValid(id))
    {
        auto entity = entityManager.getEntity(id);
        entity.move(...);
    }
}

//Mouse Event Trigger
Event ev([&] (sf::Event) {
    if (entityManager.isValid(id))
        entityManager.destroy(id);
});

eventListener.connect(sf::Event::MouseButtonPressed, ev);

해결하면서 삽질을 많이 했기 때문에, 여기에다 노트 필기를 좀 해야겠습니다.

공부하면서 예전에 들은 강의 내용이 새록새록 떠오르더군요. 시험 준비를 해야할 것만 같은, 기분 나쁜 경험이었습니다.


일단... 용어 정리도 하고 문제 파악부터 해야했습니다.

위 상황처럼 쓰레드가 자기 마음대로 돌아가면서 여러 가지 다른 결과를 발생시킬 수 있는 상황을

race condition이라고 합니다. 이러한 race condition이 발생할 수 있는 코드 구간을 critical section이라고 하고요.

 

근본적인 원인은 두 쓰레드가 변수를 공유하기 때문입니다.

쓰레드 1번이 잘 쓰고 있던 변수를 쓰레드 2번이 난입해서 막 바꿔버리면,

쓰레드 1번은 2번이 쓰기 전에는 1234인 줄 알고 사용하던 변수 a가, 난입 후에는 0이나 -423454654 뭐시기로 바뀌어 있는 거죠.

쓰레드 2가 a의 값을 0으로 바꾼 후에 쓰레드 1이 b / a를 실행하려 한다? 바로 런타임 에러를 박아버릴 겁니다.

 

그래서 나온 방안이 어느 한 쓰레드가 a를 쓰고 있다면, 다른 쓰레드는 a에 접근하지 못하게 하는 겁니다.

그게 semaphore, 아니면 mutex이구요.

mutex는 반드시 하나만 사용할 수 있고, semaphore는 사용 가능한 최대 쓰레드 수가 있습니다.

예를 들어 thread-safe하게 변수 a를 사용하기 위해, mutex를 이용하여 구현하면 쓰레드 하나가 a를 독점으로 사용하게 됩니다.

semaphore는 공유 가능한 최대 쓰레드 수를 지정하고, 예를 들어 최대 수가 3이면 최대 3개의 쓰레드가 a를 동시에 사용할 수 있습니다.

 

여기서는 mutex를 쓰고 있죠.

렌더링용 mutex를 하나 이미 쓰고 있고, 이벤트 처리용 mutex를 하나 더 만들 필요가 있었습니다.

렌더링은 윈도우마다 하는 것이기 때문에 각 윈도우마다 멤버 변수로 선언해줘도 문제가 없는데,

이벤트 처리는 인게임에서 일어나는 일이기 때문에 글로벌한 mutex가 필요했습니다.

//Global.h
namespace Global
{
    extern std::mutex EventMutex;
}

//Global.cpp
namespace Global
{
    std::mutex EventMutex;
}

이렇게 해서 작성한 코드 (최종 결과)는 다음과 같습니다.

 

void Window::signal()
{
    while (window.isOpen())
    {
        while (window.pollEvent(e))
        {
            ... //event preprocess
            
            {
                std::lock_guard<std::mutex> guard(Global::EventMutex);
                std::lock_guard<std::mutex> render_guard(render_mutex);

                for (auto& p : receivers) //event listeners
                    if (p.second) p.first->dispatch(e);
            }
        }
    }
}

void Window::render()
{
    ...//double lock check here
    
    render_mutex.lock();
    ... //render
    render_mutex.unlock();
}
void GameEngine::run()
{
    while (window->isRenderable())
    {
        sf::Time delta = clock.restart();

        loop(delta.asSeconds());
        window->render();
    }
}

void GameEngine::loop(float delta)
{
    std::lock_guard<std::mutex> guard(Global::EventMutex);
    ...
}

 

여기서부터는 삽질한 내용입니다.

 

결국 이벤트 호출은 인게임 루프와도, 렌더링과도 병행할 수 없습니다.

따라서 loop 안에서 mutex lock을 걸지 말고, run 안에서 mutex lock을 걸어도 될 겁니다.

이벤트 처리 구간에서 lock을 두 개나 걸기 때문에, deadlock이 걸리는 상황이 있지는 않을지 노심초사한 탓도 있습니다.

 

while (window->isRenderable())
{
    sf::Time delta = clock.restart();

    Global::EventMutex.lock();
    loop(delta.asSeconds());
    window->render();
    Global::EventMutex.unlock();
}

...라고 생각했습니다.

그런데 돌려보니 메인 쓰레드가 lock을 거의 독점하고 있더군요.

아무래도 unlock - lock 사이의 주기가 너무 짧아서 시스템 쓰레드가 끼어들 여지가 없어보였습니다.

 

그래서 결국 loop 안에서만 lock - unlock하도록 구간을 쪼갰고 (어차피 render 안에서도 mutex lock을 거니까)

일단은 잘 돌아가는 것 같습니다. 제 코드가 굉장히 의심스러워지긴 했습니다만...

 

여기서 쓰레드가 공정하게 lock을 가져가게 배분할 수는 없을까 해서 여러 대체재를 찾아봤는데,

그닥 효과는 없었습니다.

 

우선... 이렇게 어느 쓰레드가 lock을 독점하느라 다른 쓰레드가 오랜 시간 돌아가지 못하는 상황을

starvation이라고 합니다. 사용자는 이러한 상황을 해결하기 위해 공정하게 (fair) 스케줄링해줄 필요가 있습니다.

다만 공정함은 cost를 요구하기 때문에, 미리 구현되어 있거나 그러진 않습니다.

 

그래서... condition variable의 notify를 이용하면 간접적으로 해결할 수 있지 않을까 하는 생각을 해봤습니다.

혹시 대기 중인 다른 쓰레드가 있으면, 그 쪽으로 양보하고 보는 겁니다.

 

condition variable은 mutex lock에 있어서 조건을 덧붙이는 겁니다.

condition variable이 필요한 예시로 Producer/Consumer Problem을 정말 많이 듭니다.

생산자 쓰레드와 소비자 쓰레드가 따로 돌아가는 상황에서, 생산자는 별다른 선행 조건 없이 상품을 생산할 수 있지만

소비자는 소비할 상품이 반드시 필요합니다.

따라서 소비자 쓰레드는 상품이 생산될 때까지 대기해야 합니다.

 

namespace Global
{
    extern std::mutex EventMutex;
    extern std::condition_variable EventCV;
}

void Window::signal()
{
    while (window.isOpen())
    {
        while (window.pollEvent(e))
        {
            ... //event preprocess
            
            {
                std::unique_lock<std::mutex> lk(Global::EventMutex);
                Global::EventCV.wait(lk);
                
                std::lock_guard<std::mutex> render_guard(render_mutex);

                for (auto& p : receivers) //event listeners
                    if (p.second) p.first->dispatch(e);
            }
        }
    }
}

void GameEngine::run()
{
    while (window->isRenderable())
    {
        sf::Time delta = clock.restart();

        Global::EventMutex.lock();
        loop(delta.asSeconds());
        window->render();
        Global::EventMutex.unlock();
        Global::EventCV.notify_one();
    }
}

EventMutex에 대해 lock을 걸고, condition variable이 wait을 합니다. (wait 하는 동안에는 다시 unlock을 풀고, notify로 신호를 받으면 다시 lock합니다.)

이렇게 해서 쓰려고 봤더니

(일단 코드가 제대로 안 돌아가는 건 둘째치고) condition variable에서 발생할 수 있는 문제점이 있다더군요.

spurious wakeup과 lost wakeup입니다.

 

spurious wakeup은 아직 wakeup signal을 호출하지 않았는데도, 자고 있던 쓰레드가 깨어나는 현상을 말합니다.

대충 원인이 시스템 콜 차원에서 어쩔 수 없이 발생한다고 하네요.

 

이게 뭔가 싶어서 좀 더 찾아보니, 만약 프로그램이 잘 돌아가다가 갑작스런 블랙아웃이 발생하게 되는 경우에

시스템은 혹시라도 그 동안에 wakeup signal을 놓쳤을 가능성 때문에 일단 자고 있던 쓰레드를 다 깨우고 본다는 군요.

그러지 않으면 쓰레드가 모두 영원히 놓친 신호를 기다리면서 자고 있을테니 말입니다.

관련 내용은 링크에서 잘 설명하고 있습니다.

이처럼 spurious wakeup이 발생할 가능성은 굉장히 낮지만, 안 하는 건 아닙니다.

 

lost wakeup은 반대로, signal을 호출했지만 쓰레드가 이를 받지 못하는 현상입니다.

producer과 consumer 사례를 예로 들면, consumer 쓰레드가 시작하기 전에 producer 혼자서 상품을 생산하고 신호를 보내고 끝내는 과정을 다 해버리는 겁니다. 이런 상황에서는 consumer 쓰레드가 뒤늦게 대기를 하더라도, 신호는 이미 지나갔기 때문에 더이상 진행할 수가 없습니다.

 

이에 대한 해결책으로 predicate를 사용합니다. 별 건 아니고, 조건을 하나 더 붙여주는 겁니다.

namespace Global
{
    extern std::mutex EventMutex;
    extern std::condition_variable EventCV;
    extern std::atomic<bool> EventReady; //init true
}

void Window::signal()
{
    while (window.isOpen())
    {
        while (window.pollEvent(e))
        {
            ... //event preprocess
            
            {
                std::unique_lock<std::mutex> lk(Global::EventMutex);
                Global::EventCV.wait(lk, [] () { return Global::EventReady.load(); });
                
                Global::EventReady.store(false);
                std::lock_guard<std::mutex> render_guard(render_mutex);

                for (auto& p : receivers) //event listeners
                    if (p.second) p.first->dispatch(e);
                
                Global::EventReady.store(true);
                lk.unlock();
            }
        }
    }
}

void GameEngine::run()
{
    while (window->isRenderable())
    {
        sf::Time delta = clock.restart();

        Global::EventMutex.lock();
        Global::EventReady.store(false);
        loop(delta.asSeconds());
        window->render();
        Global::EventReady.store(true);
        Global::EventMutex.unlock();
        Global::EventCV.notify_one();
    }
}

 

위 코드에서는 EventReady가 true가 될 때까지 wait을 실행합니다.

while (!Global::EventReady.load())
{
    Global::EventCV.wait(lk);
}

얘라고 보시면 됩니다.

여기서 predicate에 사용하는 bool 변수는 그냥 사용하면 thread-safe하지 않을 수 있어서, atomic을 사용해야 했습니다.

 

spurious wakeup이 발생해도, EventReady가 false이면 아직 쓰레드가 처리할 수 없는 상황이니 다시 wait을 실행합니다.

lost wakeup도 해결이 되는데 notify 이후의 시점에서는 EventReady가 true이기 때문에 wait을 할 필요가 없으므로 그대로 진행하기 때문입니다.

참조한 자료는 이렇습니다. 링크1 링크2

 

이렇게 엄청 돌아돌아 왔더니 작동이 했느냐? 하면 위의 최종 결과에 condition variable이 있었겠죠.

인게임 쓰레드에 wait과 notify를 넣어도 별 차이가 없더군요.

notify와 쓰레드 스케줄링은 별개였나 봅니다.

 

그리하여 좀 더 찾아보니, yield가 있었습니다.

while (window->isRenderable())
{
    sf::Time delta = clock.restart();

    Global::EventMutex.lock();
    loop(delta.asSeconds());
    window->render();
    Global::EventMutex.unlock();
    
    std::this_thread::yield();
}

이러면 yield를 호출한 쓰레드에서는 잠시 다른 쓰레드에게 양보해줍니다.

sleep과의 차이점은 쓰레드가 쉬지는 않는다는 점입니다.

와! 이걸 두고 spurious가 어쩌고 공부하고 있었네!


뭐... 이러고 나서도 결국 처음으로 돌아온 것이, 의미가 없어 보였기 때문입니다.

어차피 시스템 쓰레드는 이벤트가 발생하지 않는 한 거의 돌아가지 않습니다.

인게임 쓰레드가 독점적으로 돌아가는 게 당연한 거죠.

 

또한 event mutex로 loop와 render를 한꺼번에 lock한다고, render 안에서 mutex lock을 걸지 않아도 되는 것도 아닙니다.

언제 또다른 thread가 나타나서 어지럽힐지 모르니까요.

또한 이벤트 호출 시점에서는 이벤트가 render을 할지 뭘 할지 모르니, 둘 다 lock을 걸어야 한다는 점은 변치 않았습니다.

 

추가로, 디버깅에 cout을 쓰고 있는데 얘도 thread-safe하게 써줄 필요가 있었습니다.

C++20에서는 osyncstream을 지원하니, 이걸로 바꿔줬습니다.

#include <iostream>
#include <syncstream>

std::osyncstream out(std::cout);
out << "test" << '\n';

endl은 출력 버퍼를 비우는 거라서 그런지 쓰려고 하니 에러가 나더군요.

 

그리고.. 이러다보니 알게 된 건데, 프로그램 내에서의 메모리 관리가 되게 모호하더군요.

기존에는 raw pointer로 생성한 그래픽 같은 걸 Component 내에서 가르키고 있다가, 소멸자가 호출될 때 같이 삭제되게끔 했는데 생각해보니 다른 곳에서 사용 중인 경우에는 처리하기가 번거로워지더군요.

 

그래서 전체적으로 shared_ptr를 사용하도록 교체했습니다.

shared_ptr는 use_count가 0이 되면 자동으로 메모리가 해제되는 스마트 포인터입니다.

하면서 이게 맞나 라는 생각을 계속했는데, raw pointer 쓰면서 계속 lifetime을 신경쓰나 shared_ptr로 복잡하게 쓰나 큰 차이가 없을 것 같았습니다.

 

포인터 중에서도, parent-child relationship처럼 순환 참조가 발생할 수가 있어 weak_ptr를 사용해야 하는 경우도 있었습니다. parent entity가 child entity를, child entity가 parent entity를 참조할 테니 count가 0이 되지 않기 때문입니다.

weak_ptr는 use_count를 올리지 않으면서 shared_ptr를 가리킬 수 있습니다.

 

대충 정리하면 다음과 같습니다.

1. T*를 관리하거나, 관리 주체를 모르는 경우에는 shared_ptr

2. 1에 해당하지 않는 경우에는 weak_ptr

3. 애초에 동적 할당된 객체가 아니라면 그냥 raw pointer를 그대로 쓴다.

 

std::vector<std::shared_ptr<Entity>> entities;

typename <EntityType = Entity> requires IsEntity<EntityType>
std::weak_ptr<EntityType> createEntity()
{
    ...//init entity

    std::shared_ptr<EntityType> ptr = std::shared_ptr<EntityType>(new EntityType(...));
    entities.push_back(std::dynamic_pointer_cast<Entity>(ptr));

    if (eventManager)
        eventManager->dispatch(EventType::EntityCreateEvent, this);
    return ptr;
}

 

weak_ptr를 사용하기 위해서는 lock 함수를 통해 shared_ptr로 변환시켜줄 필요가 있습니다.

auto entity = entityManager.getEntity(id); //std::weak_ptr<Entity>
auto ptr = entity.lock(); //std::shared_ptr<Entity>

if (ptr)
{
    ...
}

이 때 가리키는 shared_ptr가 use_count가 0이 되어 해제되었다면 nullptr를 반환합니다.

그렇지 않다면, 사용하는 도중에는 shared_ptr가 지역변수로라도 존재하기 때문에, 메모리 해제가 발생하지 않습니다.

 

다만 유의할 점이, weak_ptr이 메모리 해제가 되었는지 확인하는 함수 expired가 있는데

if (!entity.expired())
{
    //can be deallocated here in multi-threaded program!
    auto ptr = entity.lock();
    ...
}

멀티쓰레드 환경에서, 이런 식의 코드는 race condition이 발생할 수 있습니다.

expired가 true일 경우의 조건문만 실행할 경우에만 thread-safe하다고 볼 수 있겠네요.

 

그래서... 다 바꾸고 보니 되게 귀찮고 복잡해졌습니다.

포인터를 쓰려면 무조건 weak_ptr를 가져와서, lock을 호출하고 nullptr은 아닌지 검사해야 합니다.

뭐 메모리 누수 발생하는 것보단 낫겠죠. 어차피 내가 쓸 거고...

 

이제 윈도우 여러 개 돌아가는 것만 구현하면, 얼추 끝날 것 같습니다.

좀 쉬어야겠다...