아래는 네가 붙여준 “제미나이 해석본” + 내가 전에 작성한 자료를 합쳐서, 더 정확하고 실전 관점까지 보강한 “완전판 학습 자료(심화 + 함정/레이스까지 포함)”이야.
(특히 이벤트는 카운팅을 못 한다는 점 때문에, 예제 그대로 쓰면 신호 유실(lost wake-up) 같은 함정이 생길 수 있어서 그 부분을 확실히 잡아줄게.)
[C++ 게임 서버] 이벤트(Event)로 동기화하기 — 완전판 학습 자료
이 문서의 목표
Busy Waiting(바쁜 대기)가 왜 CPU를 낭비하는지 정확히 이해
1-1. Busy Waiting이 생기는 이유
SpinLock이나 단순 폴링(polling)은 “조건이 만족될 때까지 계속 확인”하는 구조라서,
조건이 자주 만족되면: 오히려 빠를 수도 있음(커널 왕복 비용 없음)
조건이 드물게 만족되면: 대부분 시간이 “아무 일도 안 하면서 CPU만 사용”
1-2. 이벤트 방식의 핵심 아이디어(강의 비유 정제)
기존(폴링): “문고리를 계속 돌려보며 확인”
이벤트: “관리자(커널)에게 ‘열리면 깨워줘’ 요청하고 잠듦”
대기 중에는 스레드가 Block 상태로 들어가 CPU를 거의 쓰지 않음
핵심 문장: “제 3자(커널)가 깨워주는 동기화”
[Code 1] 이벤트 적용 전 (폴링 → CPU 낭비)
mutex m;
queue q;
void Producer() {
for (int i = 0; i < 10000; ++i) {
{
unique_lock lock(m); // (1) 공유 큐 보호
q.push(i); // (2) 데이터 생산
} // (3) unlock
this_thread::sleep_for(100ms); // (4) "가끔 들어오는" 상황 가정
}
}
void Consumer() {
while (true) {
unique_lock lock(m); // (1) 매번 락 획득 시도
if (!q.empty()) { // (2) 데이터 없으면 아무것도 안 함
int data = q.front();
q.pop();
cout << data << endl;
}
// (3) 문제: 데이터 없어도 while(true)가 계속 돌아감 = Busy Waiting
}
}
🔴 핵심 문제 2가지
3-1. Event 상태(신호등 비유)
Non-signaled(빨간불): Wait 호출 스레드는 잠든다(Block)
Signaled(파란불): Wait 호출 스레드는 깨어난다(Wake)
3-2. 핵심 API 정확한 의미
API 역할 정확한 기술적 의미
CreateEvent 이벤트 생성 커널 이벤트 오브젝트 생성 후 HANDLE 반환
SetEvent 신호 발생 이벤트를 Signaled로 만들고 대기 스레드 깨움
WaitForSingleObject 대기 이벤트가 Signaled가 될 때까지 스레드를 Block
ResetEvent 신호 해제 (Manual-reset일 때) 다시 Non-signaled로 복구
제미나이 텍스트에서 “SetEvent = 관리자가 신호를 보냄”처럼 느껴질 수 있는데,
SetEvent를 호출하는 건 Producer(혹은 신호를 발생시키는 코드)이고,
커널은 그 신호를 근거로 대기 스레드를 깨우는 주체야.
4-1. Auto-reset (bManualReset = FALSE)
SetEvent() → 대기 중 스레드 1개를 깨움
깨우는 순간 이벤트는 자동으로 Non-signaled로 돌아감
“1회 신호 = 1명 통과”에 가까움
단, 카운팅(몇 개 들어왔는지) 기능은 없음 ← 여기서 함정이 생김
4-2. Manual-reset (bManualReset = TRUE)
SetEvent() → 이벤트가 Signaled 상태로 유지
여러 스레드가 기다리고 있으면 우르르 깨어날 수 있음(broadcast 성격)
반드시 적절한 시점에 ResetEvent()로 다시 Non-signaled로 바꿔야 함
5-1. 이벤트는 “카운팅”을 못 한다
Auto-reset 이벤트는 신호 1개를 기억하는 큐가 아니야.
즉, Producer가 아래처럼 빠르게 여러 번 SetEvent()를 호출해도,
소비자가 아직 Wait에 들어가지 않았거나,
이벤트가 이미 Signaled 상태라면, 추가 SetEvent()는 누적되지 않는다.
예: 신호 유실(lost wake-up) 시나리오
강의 예제는 “개념 입문용”이라 단순화된 형태고, 실전에서는 반드시 보강 패턴이 필요해.
이 패턴은 Event가 카운팅이 아닌 점을 현실적으로 보완하는 가장 흔한 방식이야.
6-1. 핵심 아이디어
Producer:
push하기 전에 큐가 비었으면(0개였다면) → SetEvent()
이미 데이터가 있었다면 → 굳이 SetEvent 안 해도 됨(어차피 Consumer는 깨어서 처리 중이거나 곧 처리할 것)
Consumer:
깨어난 다음에는 큐를 비울 때까지(drain) 처리
큐가 비면 다시 Wait
6-2. 코드(완전판, 줄단위 해설 포함)
#include <windows.h>
#include
#include
#include
#include
#include
using namespace std;
mutex m;
queue q;
HANDLE gEvent = NULL;
atomic gStop{false};
void Producer()
{
for (int i = 0; i < 10000; ++i)
{
bool wasEmpty = false;
{ // (1) 공유 큐 보호
lock_guard<mutex> lock(m);
// (2) "이 push로 인해 Consumer를 깨워야 하는가?" 판단
// 큐가 비어있던 상태(0개)에서 1개가 되는 순간이면 깨우는 게 의미 있음
wasEmpty = q.empty();
// (3) 데이터 생산
q.push(i);
} // (4) unlock
// (5) 큐가 비어있던 순간만 깨움 → 불필요한 SetEvent 남발 방지 + 신호 유실 위험 감소
if (wasEmpty)
::SetEvent(gEvent);
this_thread::sleep_for(100ms);
}
// (6) 종료 신호: stop 설정 + 한 번 깨워서 Consumer가 빠져나오게 함
gStop.store(true, memory_order_release);
::SetEvent(gEvent);
}
void Consumer()
{
while (true)
{
// (1) 먼저 큐를 비울 수 있으면 비운다(락 안에서 pop만, 처리는 락 밖에서)
while (true)
{
int data = 0;
bool hasItem = false;
{
lock_guard<mutex> lock(m);
if (!q.empty())
{
data = q.front();
q.pop();
hasItem = true;
}
}
if (!hasItem)
break;
// (2) 처리(락 밖)
cout << "Data: " << data << "\n";
}
// (3) 종료 조건: 큐도 비었고 stop이면 종료
if (gStop.load(memory_order_acquire))
return;
// (4) 큐가 비었으니 이벤트가 올 때까지 잠든다(= CPU 거의 0)
::WaitForSingleObject(gEvent, INFINITE);
// (5) 깨어났으면 다시 위 루프에서 큐 drain 시도
}
}
int main()
{
// Auto-reset(FALSE), 초기 Non-signaled(FALSE)
gEvent = ::CreateEvent(NULL, FALSE, FALSE, NULL);
if (!gEvent) return 1;
thread t1(Producer);
thread t2(Consumer);
t1.join();
t2.join();
::CloseHandle(gEvent);
return 0;
}
6-3. 이 버전이 “강의 예제”보다 더 완벽한 이유
신호 유실 위험을 줄임: “0 → 1” 변화에서만 깨움
불필요한 SetEvent 남발을 줄임
Consumer는 깨면 큐를 비울 때까지 처리해서 “신호 1번에 1개만 pop하고 다시 잠드는 문제”를 줄임
종료 처리(stop)까지 포함: 실전에서 꼭 필요
이건 “Event 강의”의 연장선에서 실무적으로 제일 깔끔한 답이야.
Event는 “신호 on/off”만 있음(카운팅 불가)
Producer-Consumer는 “몇 개 들어왔는지(카운트)”가 중요함
→ 그래서 Windows에서는 보통 Semaphore(카운팅 커널 오브젝트)를 쓴다.
강의 다음 챕터가 Condition Variable로 가는 이유도 같은 맥락이야.
“조건+카운트” 문제는 Event만으로는 깔끔하게 안 풀리는 경우가 많거든.
원하면 이 세마포어 버전도 “줄단위 해설 포함”으로 같이 완성해줄게.
Manual-reset은 “모두 깨우기(broadcast)”가 필요할 때 유용해. 예:
“설정 로딩 완료” 같은 전역 상태가 준비되면,
기다리던 여러 스레드가 한꺼번에 진행해도 되는 상황.
다만 Producer-Consumer 큐처럼 “아이템 단위 동기화”에는
Manual-reset은 과하게 많은 스레드를 깨워서(Thundering Herd) 비효율이 나기 쉬워서 신중히 써야 해.
Busy Waiting은 조건이 드문 상황에서 CPU를 낭비한다.
Event는 “대기 스레드를 커널이 재워서 CPU 낭비를 줄이는 도구”다.
하지만 Event는 카운팅이 안 된다 → Producer-Consumer에는 함정이 있다.
그래서 실전에서는
Event를 쓰더라도 “empty→non-empty에서만 SetEvent + drain 처리” 같은 보강이 필요하거나,
더 정답에 가까운 방식(세마포어/조건변수)로 간다.
원하면 다음 중 하나로 이어서 “완전 완전체”로 만들어줄게(너는 확인만 하면 됨, 난 바로 작성 가능):
어느 루트로 갈까? (나는 개인적으로 1→2 순서 추천)