1. 동시성 기초 개념: 프로세스/스레드, race condition, 동기화 도구
난이도 하모범답안 — 동시성 기초 개념: 프로세스/스레드, race condition, 동기화 도구
난이도: 하
핵심 답변
- 프로세스 vs 스레드: 프로세스는 독립된 가상 주소 공간(코드/데이터/힙)을 가진 실행 단위이고, 스레드는 그 주소 공간을 공유하면서 각자 스택과 레지스터/PC만 따로 갖는 실행 흐름이다. 같은 프로세스의 스레드끼리는 힙·전역 데이터를 공유하므로 통신 비용이 싸지만, 그 공유 때문에 동기화가 필요하다. 게임서버는 수만 개의 세션을 하나의 프로세스 안에서 공유 상태(룸, 매칭 풀 등)와 함께 다루므로, 컨텍스트 스위칭과 IPC가 비싼 멀티프로세스보다 멀티스레드가 유리하다.
- race condition: 두 개 이상의 스레드가 공유 자원에 동시에 접근하고 그중 최소 하나가 쓰기이며, 결과가 실행 순서(스케줄링)에 의존해 비결정적으로 달라지는 상황.
- critical section: 공유 자원에 접근하는 코드 영역으로, 한 번에 한 스레드만 실행되어야 정확성이 보장되는 구간. race condition을 없애려면 임계 구역을 상호 배제(mutual exclusion)로 보호한다.
- mutex vs spinlock: 둘 다 상호 배제 도구지만, 락을 못 얻었을 때 mutex는 스레드를 재워(block) OS가 다른 일을 시키고, spinlock은 CPU를 점유한 채 계속 재시도(busy-wait) 한다.
깊이 있는 설명 (메커니즘, 왜)
프로세스/스레드의 핵심: 무엇을 공유하는가
| 항목 | 프로세스 간 | 같은 프로세스의 스레드 간 |
|---|---|---|
| 코드/전역/힙 | 분리(별도 주소공간) | 공유 |
| 스택, 레지스터, PC | 분리 | 분리 |
| 통신 | IPC(파이프/소켓/공유메모리) 필요 | 그냥 메모리 접근 |
| 컨텍스트 스위칭 비용 | 큼(주소공간/TLB 교체) | 작음(주소공간 유지) |
| 한 쪽 크래시 영향 | 격리됨 | 프로세스 전체 다운 |
스레드 공유가 빠른 만큼 위험하다 — 공유 메모리에 여러 스레드가 동시에 쓰면 곧바로 race condition이 된다.
g_playerCount++가 원자적이지 않은 이유
한 줄처럼 보이지만 CPU 입장에서는 읽기-수정-쓰기(RMW) 3단계다.
mov eax, [g_playerCount] ; (1) load
add eax, 1 ; (2) modify
mov [g_playerCount], eax ; (3) store
스레드 A가 (1)에서 값 10을 읽고, store 전에 스레드 B도 10을 읽어 둘 다 11을 쓰면, 증가는 두 번 일어났는데 결과는 11이다. 증가 하나가 통째로 유실(lost update) 된다. 그래서 1000번 증가시켰는데 987이 나온다. 임계 구역은 정확히 이 (1)~(3) RMW 시퀀스다.
mutex와 spinlock의 동작 차이
- mutex: 락 획득 실패 시 스레드를 대기 큐에 넣고 sleep시킨다(커널 개입). 대기 동안 CPU는 다른 일을 한다. 깨어날 때 컨텍스트 스위칭 비용(수 µs 급)이 든다. → 임계 구역이 길거나, 대기 시간이 길거나, 대기 스레드가 코어 수보다 많을 때 유리.
- spinlock:
while(!try_lock()) { /* pause */ }형태로 CPU를 돌리며 재시도한다. sleep/wake가 없어 락이 아주 짧은 시간 안에 풀릴 때는 컨텍스트 스위칭 비용 없이 즉시 진입한다. 하지만 락 보유 시간이 길면 대기 코어가 CPU를 통째로 낭비한다. 단일 코어에서 spinlock은 거의 금물(락 보유 스레드가 스케줄되어야 풀리는데 대기 스레드가 코어를 점유).
요약 기준: 임계 구역이 매우 짧고 경합이 가벼우면 spinlock, 그 외/길면 mutex. 실무에서는 짧을 때만 잠깐 spin하다 안 되면 sleep으로 전환하는 adaptive(하이브리드) 락(예: .NET lock/Monitor, glibc futex)이 기본값이다.
응용/실무 연결 (게임서버에서)
- 시나리오의
g_playerCount는 임계 구역이 RMW 한 번뿐이라 락보다 원자 연산이 정답이다. C#이면Interlocked.Increment(ref g_playerCount), C++이면std::atomic<int>의fetch_add. 락의 진입/이탈 오버헤드와 컨텍스트 스위칭 없이 단일 명령(lock xadd)으로 해결된다. - 게임서버에서 spinlock이 어울리는 곳: 락 보유 구간이 수십 ns 수준인 잡큐 인덱스 갱신, 짧은 카운터 보호 등. mutex가 어울리는 곳: 룸 전체 상태 갱신처럼 보유 구간이 길거나 I/O를 포함하는 경우.
- 더 근본적으로, 게임서버는 "공유를 줄여 동기화를 없애는" 설계(룸=단일 스레드, 잡큐, 스레드 로컬 누적)를 선호한다. 락은 마지막 수단이다.
흔한 오답·함정
- "스레드는 메모리를 공유하니 스택도 공유한다" → 틀림. 스택과 레지스터/PC는 스레드마다 별도다. 공유되는 건 코드/전역/힙.
- "race condition = 데드락" → 다른 개념. race는 결과가 순서에 의존해 망가지는 것, 데드락은 서로의 락을 기다리며 영원히 멈추는 것.
- "spinlock이 mutex보다 항상 빠르다" → 짧은 경합에서만. 길게 보유하거나 대기자가 많으면 CPU만 태워 더 느려지고 단일코어에선 진행 불가.
- "
volatile을 붙이면count++가 안전해진다" → 아니다.volatile은 가시성/재배열만 제어할 뿐 원자성을 주지 않는다. RMW 유실은 그대로다.
꼬리질문 대비
-
Q: 임계 구역을 보호하는 조건 3가지(상호배제 외)는? A: 상호 배제(mutual exclusion), 진행(progress, 아무도 안 들어가 있으면 대기자 중 하나는 들어갈 수 있어야 함), 한정 대기(bounded waiting, 무한정 굶지 않음). 고전적 임계구역 해법 요건.
-
Q: 컨텍스트 스위칭은 왜 비싼가? A: 레지스터 저장/복원, 스케줄러 실행, 캐시·TLB 무효화로 인한 콜드 캐시 비용이 든다. 특히 캐시가 식으면 직접적인 스위칭 비용보다 후속 메모리 접근 지연이 더 크다.
-
Q: 원자 연산도 결국 락 아닌가? A: 하드웨어 수준
lock프리픽스/LL-SC로 단일 RMW를 원자화하는 것이며, OS 스케줄링(sleep/wake)이 개입하지 않는 lock-free 도구다. 임계 구역이 "한 변수의 한 연산"으로 압축될 때만 적용 가능하고, 여러 변수를 묶어 보호해야 하면 락이나 더 정교한 lock-free 자료구조가 필요하다.