C++ 게임 서버 프로그래밍: 동기화를 위한 Condition Variable 이해하기

나무에물주기·2023년 6월 12일
1
post-thumbnail

다음과 같은 헤더 파일들이 추가되어 있습니다.

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>
#include <atomic>
#include <mutex>
#include <windows.h>

condition_variable은 C++ 표준 라이브러리에서 제공하는 동기화 메커니즘으로, 특정 조건을 만족할 때까지 스레드를 대기 상태로 둘 수 있습니다. 이를 통해 스레드 간의 작업 순서를 보장하고, 자원 경쟁 및 동시성 문제를 해결할 수 있습니다.

먼저 Producer와 Consumer라는 두 개의 스레드 함수를 살펴봅시다.

void Producer()
{
    while (true)
    {
        {
            unique_lock<mutex> lock(m);
            q.push(100);
        }

        cv.notify_one(); 
    }
}

void Consumer()
{
    while (true)
    {
        unique_lock<mutex> lock(m);
        cv.wait(lock, []() { return q.empty() == false; });

        {
            int32 data = q.front();
            q.pop();
            cout << q.size() << '\n';
        }
    }
}

Producer 함수는 무한 루프를 돌면서 q라는 큐에 값을 넣습니다. 이 때, unique_lock lock(m);를 사용하여 동시 접근을 막습니다. 그리고 cv.notify_one();을 통해 대기 중인 스레드가 있으면 하나를 깨웁니다.

Consumer 함수는 무한 루프를 돌면서 q라는 큐에서 값을 가져옵니다. 이 때, cv.wait(lock, { return q.empty() == false; });를 통해 큐에 값이 들어올 때까지 대기하게 됩니다.

주의점

cv.notify_one();을 사용할 때는 lock을 잡고 있지 않기 때문에 Spurious Wakeup이 발생할 수 있습니다. 이는 컴퓨터의 하드웨어나 운영 체제의 특성 때문에 스레드가 깨어나기도 하는데, 이를 방지하기 위해 cv.wait() 함수에서 조건을 체크합니다.

cv.wait(lock, { return q.empty() == false; }); 코드에서는 큐가 비어있지 않을 때까지 스레드를 대기 상태로 둡니다. 이렇게 하면, 큐에 값이 추가되어 cv.notify_one();에 의해 깨어난 스레드가 실제로 처리해야 할 작업이 있는 경우에만 작업을 수행하게 됩니다.

다음은 이들 함수를 main() 함수에서 실행하는 코드입니다.

int main()
{
    thread t1(Producer);
    thread t2(Consumer);

    t1.join();
    t2.join();
}

ProducerConsumer 함수를 각각 다른 스레드에서 실행시킵니다. 이 때, join() 함수를 사용하여 ProducerConsumer 스레드가 모두 종료될 때까지 메인 스레드의 실행을 블록합니다.

Condition Variable과 Event를 사용한 Lock의 차이점

condition_variableEvent는 모두 스레드 동기화에 사용되는 메커니즘입니다. 하지만 사용 방식과 원리에는 몇 가지 중요한 차이점이 있습니다.

  • Condition Variable: :condition_variable은 스레드가 특정 조건이 충족될 때까지 대기하게 하는 동기화 메커니즘입니다. 스레드는 wait 함수를 호출하여 특정 조건이 참이 될 때까지 대기 상태로 전환될 수 있습니다. 다른 스레드에서 notify_one 또는 notify_all을 호출하면 대기 중인 스레드 중 하나 또는 모든 스레드가 깨어나게 됩니다. condition_variable은 직접적으로 "signal / non-signal" 상태를 가지지 않으며, 조건 검사는 사용자 코드에서 명시적으로 수행해야 합니다.

  • Event (Windows): EventWindows에서 제공하는 동기화 객체로, 두 가지 상태인 "signaled" 상태와 "non-signaled" 상태를 가집니다. 스레드는 이벤트가 signaled 상태가 될 때까지 대기하게 됩니다. 이벤트는 자동 리셋 이벤트와 수동 리셋 이벤트 두 가지 유형이 있습니다. 자동 리셋 이벤트는 signaled 상태로 설정된 후 첫 번째 대기 스레드를 깨우고 바로 non-signaled 상태로 돌아갑니다. 수동 리셋 이벤트는 수동으로 리셋할 때까지 signaled 상태를 유지합니다.

Spurious Wakeup (가짜 기상)

Spurious wakeup 혹은 가짜 기상condition_variable을 사용할 때 발생할 수 있는 현상입니다. 이는 스레드가 아무런 notify 없이 wait 함수에서 반환되는 경우를 말합니다. 이러한 현상은 시스템 성능 최적화나 스레드 간의 경쟁 상황 등에 의해 발생할 수 있습니다.

가짜 기상condition_variablewait 함수가 항상 조건이 참인 경우에만 반환된다는 보장이 없다는 점에서 나옵니다. 따라서 대기 조건을 검사하는 루프를 사용하여 wait 함수를 호출하면 가짜 기상에 대응할 수 있습니다. 이를 통해 스레드가 wait 함수에서 반환할 때 조건이 충족되지 않은 경우 다시 대기 상태로 돌아가게 할 수 있습니다.

cv.wait(lock, []() { return q.empty() == false; });

위 코드에서 cv.wait 함수의 두 번째 인자로 주어진 람다 함수가 바로 조건을 검사하는 부분입니다. wait 함수가 반환된 후에도 이 조건이 거짓인 경우에는 다시 wait 함수를 호출하여 스레드를 대기 상태로 만듭니다. 이렇게 하면 가짜 기상에 대비할 수 있습니다.

즉, std::condition_variablewait 메서드는 두 개의 인자를 받는데, 첫 번째는 std::unique_lock 객체, 두 번째는 조건을 검사하는 함수입니다. 이 함수가 false를 반환하면 wait 함수는 계속해서 스레드를 대기 상태로 만듭니다. 이 함수가 true를 반환할 때에만 wait 함수가 스레드를 깨우고 반환합니다. 이렇게 하면 notify_one이 호출되었을 때 조건이 만족되지 않더라도 스레드가 계속 대기 상태로 있을 수 있게 됩니다. 이는 가짜 기상을 방지하고, 스레드가 필요한 작업이 준비될 때까지 대기 상태로 있도록 하는 데 도움이 됩니다.

이런 특성으로 인해 condition_variable을 사용하면 다양한 복잡한 동기화 문제를 해결할 수 있습니다. 하지만 잘못 사용하면 교착 상태(deadlock)와 같은 동시성 문제를 일으킬 수 있으므로 주의가 필요합니다.

profile
개인 공부를 정리함니다

0개의 댓글