dh_0e

[C++/Game Server] Spin Lock, Event Lock, Condition Variable 본문

C++/Game Server

[C++/Game Server] Spin Lock, Event Lock, Condition Variable

dh_0e 2025. 8. 5. 11:05

Spin Lock

  • lock을 얻을 수 있을 때까지 루프를 돌며 lock을 얻으려고 계속 시도(busy waiting)하는 lock
  • std::mutex는 커널 모드 진입 등의 오버헤드가 있으나, SpinLock은 사용자 모드에서만 동작하기 때문에 오버헤드가 적음
  • CPU 자원을 계속 소비한다는 단점이 있음
class SpinLock
{
public:
    void lock() {
        bool expected = false;
        bool desired = true;
        while (_locked.compare_exchange_strong(expected, desired) == false) {
            expected = false;

            this_thread::sleep_for(100ms);  // chrono 헤더 필요
            // this_thread::sleep_for(1000ms); //  너무 바쁘게 돌지 않도록 약간 양보, 실제 상황에서는 조정 필요

            // 또는
            // this_thread::yield(); 
            // 컨텍스트 스위칭을 유도하여 CPU 양보
            // this_thread::sleep_for(0ms); 랑 같은 의미
        }
    }

    void unlock() {
        _locked.store(false);  // 원자적으로 잠금 해제
    }

private:
    atomic<bool> _locked = false;  // CAS 기반 원자적 플래그
};

 

  • this_thread::를 사용하기 위해 <chrono> 헤더 필요
  • sleep_for(0ms) or yield()로 CPU 점유율을 줄이고 컨텍스트 스위칭 기회를 줄 수 있음
    • sleep_for(0ms): 잠깐 쉬겠다
    • yield(): 지금 당장 CPU 양보하겠다 
  • 단순 루프만 돌 경우 CPU 100%를 차지하게 됨 

 

사용 예제

SpinLock spinLock;
int sum = 0;

void Add() {
    for (int i = 0; i < 100'000; i++) {
        lock_guard<SpinLock> guard(spinLock);
        sum++;
    }
}

void Sub() {
    for (int i = 0; i < 100'000; i++) {
        lock_guard<SpinLock> guard(spinLock);
        sum--;
    }
}

int main() {
    thread t1(Add);
    thread t2(Sub);

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

    cout << "최종 결과: " << sum << endl;
    return 0;
}

 

  • SpinLock을 std::mutex처럼 사용하여 sum++, sum--의 임계 구역을 보호할 수 있음

 

SpinLock vs Mutex

항목 SpinLock std::mutex
구현 방식 사용자 모드 (CAS 루프) 커널 모드 진입 (OS 스케줄링)
성능 경합 짧을 때 빠름 경합 길어지면 더 효율적
CPU 사용량 높음 (busy waiting) 낮음 (sleep, block)
공정성 낮음 (양보 없음) 높음
사용 상황 짧고 빠른 임계 영역 긴 임계 영역 또는 IO 포함 시

 

  • 짧은 시간 동안만 잠금이 필요한 작업에선 SpinLock이 유리
  • 경합이 심하거나 긴 작업에는 std::mutex가 훨씬 안정적
  • std::this_thread::yield() or sleep_for(0ms)을 조합하여 CPU 낭비를 줄일 수 있음
  • 과도한 spin은 오히려 성능을 저하시킬 수 있으므로 적절한 타이밍 조절이 필요함

 

Event Lock

  • 스레드 간 동기화를 할 때 일반적으로는 mutex, condition_variable, semaphore 같은 동기화 도구를 사용하지만,
    Windows API에서는 Event Object를 사용한 동기화도 매우 중요하고 강력한 방식 중 하나
  • 이벤트는 커널 오브젝트의 일부이며, Usage Count는 현재 오브젝트를 참조하고 있는 핸들의 수를 말함
mutex m;
queue<int32> q;
HANDLE handle; // Windows Event Object

// 이벤트 오브젝트 생성
handle = ::CreateEvent(
    NULL,   // 보안 속성: 기본값
    FALSE,  // bManualReset: 자동 리셋 (FALSE)
    FALSE,  // bInitialState: 초기 상태는 Non-Signaled (빨간불)
    NULL    // 이름 없음
);
  • <windows.h> 헤더가 필요함
  • bManualreset: 자동 리셋(false)으로 WaitForSignalObject가 깨어나면 자동으로 다시 빨간불(Non-Signaled)로 바뀜
  • bInitialState: 시작 시 대기 상태를 나타냄 false(Non-Signaled) / true(Signal)
void Producer()
{
	while (1)
	{
		{
			unique_lock<mutex> lock(m);
			q.push(100); // 데이터 생산
		}

		::SetEvent(handle); // 🔵 이벤트를 Signal 상태로 설정 (파란불)
		this_thread::sleep_for(100ms);
	}
}

void Consumer() 
{
	while (1)
	{
		::WaitForSingleObject(handle, INFINITE); // 🔴 기다림 → 🔵 되면 깨움
		// 자동 리셋이므로 깨어나는 순간 다시 Non-Signaled 상태로 됨

		unique_lock<mutex> lock(m); // 생산자와 소비자 간의 mutex
		if (!q.empty()) {
			int32 data = q.front();
			q.pop();
			cout << data << endl;
		}
	}
}
  1. Producer() 스레드에서 데이터를 큐에 넣은 뒤, 이벤트를 Signal(파란불) 상태로 바꿔서 소비자를 깨움
  2. Consumer() 스레드의 WaitForSignalObject가 이벤트가 Signal(파란불)일 때까지 대기하다가 Signal 상태가 되면 깨어나서 큐에 접근
  3. 자동 리셋 상태이므로 깨어난 스레드는 바로 다시 이벤트를 Non-Signal(빨간불) 상태로 바꿈
    • 수동 리셋 상태일 경우 ::ResetEvent()를 수동으로 호출하여 빨간불로 전환해야 함 
int main()
{   
	handle = ::CreateEvent(NULL, FALSE, FALSE, NULL);

	thread t1(Producer);
	thread t2(Consumer);

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

	::CloseHandle(handle);
    
	return 0;
}
  • CloseHandle() 함수로 이벤트 오브젝트 정리

 

Condition Variable

  • <mutex> 헤더에 정의된 User-Level 동기화 객체 (커널 오브젝트가 아닌 경량 동기화 수단)
  • 커널 오브젝트가 아니기 때문에 동일 프로세스 내에서만 사용 가능
  • 항상 std::mutex 또는 std::unique_lock<mutex>와 함께 사용됨 
  • mutex만 사용하면 조건이 충족되지 않아도 lock을 잡으려는 CPU 낭비를 방지
  • wait()로 조건이 만족될 때까지 스레드를 잠재우고, 다른 스레드가 신호를 줄 때만 깨어나게 해줌

wait() 내부 로직

  1. wait는 lock을 잠시 풀고, 대기 상태에 진입
  2. notify_one() or notify_all()로 깨어남
  3. 조건(predicate에 입력한 함수 or 람다)을 직접 확인
경우에 따라서 wait 함수 안에서 lock을 풀어줘야 하기 때문에 lockGuard가 아닌 unique_lock을 받음

 

사용 예시

mutex m;
condition_variable cv;
queue<int32_t> q;

void Producer() {
    while (1) {
        {
            unique_lock<mutex> lock(m); // 1. 락 획득
            q.push(100);                // 2. 공유 자원 수정
        }                                // 3. 락 해제

        cv.notify_one(); // 4. 대기 중인 소비자 하나 깨움

        this_thread::sleep_for(100ms);
    }
}

void Consumer() {
    while (1) {
        unique_lock<mutex> lock(m); // 1. 락 획득
        cv.wait(lock, [] { return !q.empty(); }); // 2. 조건 만족까지 대기
        // while(!q.empty())cv.wait(lock); 과 같지만 가짜 깨어남(Spurious Wakeup)을 방지하지 못하는 Old(노가다) 방식

        // 3. 조건 만족 시 큐에서 데이터 꺼냄
        int32_t data = q.front();
        q.pop();
        cout << data << endl;
    }
}

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

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

    return 0;
}

 

단계 스레드 설명
A Producer lock(m)으로 락 획득 → q.push(100) → 락 해제
B Producer notify_one() 호출 → Consumer가 대기 중이면 깨움
C Consumer lock(m)으로 락 획득 후 wait() 진입
D Consumer 조건이 false면 → lock.unlock() → 대기 상태 진입
E Consumer notify를 받으면 → 다시 lock.lock() → 조건 재확인 후 처리

시나리오 1: "정상적인 순서" (A → C → D → B → E)

  1. Consumer가 먼저 wait()에 진입해 대기 중
  2. Producer가 q.push() 후 notify_one() 호출
  3. Consumer가 깨어남 → 조건 확인 → 처리
  • notify가 씹히지 않고 정확하게 작업을수행 

시나리오 2: "Producer가 먼저 notify" (A B → C)

  1. Consumer가 wait()에 진입하기 전에 Producer가 notify_one()을 호출
  2. Consumer가 나중에 wait() 진입
  • notify_one()은 그 순간 wait 중인 스레드에만 유효하므로 나중에 wait()에 들어간 스레드는 한 번 씹힐 수 있음

 

Spurious Wakeup (가짜 기상)

cv.wait(lock);
  • 다음과 같이 predicate 없는 wait 함수는 notfiy가 위와 같이 한 번 씹히면 무한 sleep(deadlock) 위험이 있음
  • wait()은 가끔 조건이 만족되지 않아도 깨어나는 경우가 있음
  • 그래서 반드시 predicate (람다 또는 조건식)를 통해 조건을 재확인해야 함

 

condition_variable vs Windows Event

항목 condition_variable Windows Event (HANDLE)
커널 오브젝트 여부 ❌ (User-level) ✅ 커널 오브젝트
락 필요 여부 ✅ 락과 항상 함께 사용됨 ❌ 이벤트 단독 사용 가능
스레드 간 알림 범위 동일 프로세스 내에서만 프로세스 간(IPC)도 가능
리셋 방식 predicate로 자체 체크 Auto / Manual 리셋 필요