← 문제로

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 유실은 그대로다.

꼬리질문 대비

  1. Q: 임계 구역을 보호하는 조건 3가지(상호배제 외)는? A: 상호 배제(mutual exclusion), 진행(progress, 아무도 안 들어가 있으면 대기자 중 하나는 들어갈 수 있어야 함), 한정 대기(bounded waiting, 무한정 굶지 않음). 고전적 임계구역 해법 요건.

  2. Q: 컨텍스트 스위칭은 왜 비싼가? A: 레지스터 저장/복원, 스케줄러 실행, 캐시·TLB 무효화로 인한 콜드 캐시 비용이 든다. 특히 캐시가 식으면 직접적인 스위칭 비용보다 후속 메모리 접근 지연이 더 크다.

  3. Q: 원자 연산도 결국 락 아닌가? A: 하드웨어 수준 lock 프리픽스/LL-SC로 단일 RMW를 원자화하는 것이며, OS 스케줄링(sleep/wake)이 개입하지 않는 lock-free 도구다. 임계 구역이 "한 변수의 한 연산"으로 압축될 때만 적용 가능하고, 여러 변수를 묶어 보호해야 하면 락이나 더 정교한 lock-free 자료구조가 필요하다.