Chapter 6

동시성

  • 6.1 왜 스레드인가
  • 6.2 스레드 API
  • 6.3 락
  • 6.4 락 기반 자료구조
  • 6.5 조건 변수
  • 6.6 세마포어
  • 6.7 동시성 버그
  • 6.8 이벤트 기반 프로그래밍

여러 일이 동시에 일어나는 세계에 온 걸 환영해. 병행성(concurrency) 파트의 핵심 추상화는 **스레드(thread)**야. 스레드를 쓰는 이유는 두 가지 — 멀티코어에서 작업을 나눠서 빠르게 처리하는 병렬성, 그리고 하나가 디스크 I/O를 기다리는 동안 다른 게 CPU 작업을 하는 I/O와 계산의 중첩이지. 스레드는 프로세스 안에서 실행되는 경량 실행 단위인데, 같은 프로세스의 스레드들은 주소 공간을 공유(코드, 힙)하고, 각자 자기만의 PC, 레지스터, 스택을 가져. 주소 공간 전환이 없으니까 프로세스 스위치보다 가볍지.

근데 여기서 재앙이 시작돼. 두 스레드가 공유 변수 counter를 각각 1,000,000번 증가시키면 결과가 2,000,000이어야 하는데, 실제로는 더 작은 엉뚱한 값이 나와. 매번 다르게. 왜냐면 counter = counter + 1이 실제로는 로드-증가-저장 세 개의 어셈블리 명령어인데, 두 스레드가 이걸 **인터리빙(interleaving)**하면서 한 스레드의 증가가 다른 스레드의 증가에 덮어씌워지거든. 이게 **경쟁 조건(race condition)**이야. 공유 데이터에 접근하는 **임계 영역(critical section)**은 한 번에 하나의 스레드만 실행해야 하고, 이걸 **상호 배제(mutual exclusion)**라고 불러. 해결하려면 하드웨어가 제공하는 원자적 연산(test-and-set, compare-and-swap 등)을 기반으로 락(lock) 같은 동기화 메커니즘을 구축해야 해.

실전에서는 POSIX 스레드(pthreads) API를 써. pthread_create()로 스레드를 만들고, pthread_join()으로 완료를 기다려. 스택에 있는 변수의 주소를 스레드에 넘기면 함수 리턴 후 스택이 해제되니까 위험해 — 힙에 할당하거나 전역 변수를 써야 해. 뮤텍스는 pthread_mutex_lock()/unlock()으로 임계 영역을 보호하고, 초기화 잊기, 락 없이 접근, 에러 체크 안 하기가 흔한 실수야. -pthread 플래그로 컴파일하고, Thread sanitizer(-fsanitize=thread)로 경쟁 조건을 검출할 수 있어.

락의 내부 구현을 파보면, 인터럽트를 끄거나 플래그 변수를 쓰는 건 다 실패해. 멀티코어에서 안 되거나 원자적이지 않거든. 결국 하드웨어의 Test-And-Set(TAS) 명령어가 필요해 — 메모리 값을 읽고 새 값을 쓰는 걸 하나의 원자적 명령어로 수행하는 거야. TAS를 while 루프에 넣으면 **스핀 락(spin lock)**이 되는데, CPU를 낭비하는 게 문제지. **Compare-And-Swap(CAS)**은 "현재 값이 expected와 같으면 바꿔라"로 더 유연하고, lock-free 자료구조의 기반이 돼. Fetch-And-Add로는 티켓 락을 만들어서 공정성을 보장할 수 있어. 스핀 대신 **양보(yield)**를 하면 좀 나아지지만 스레드가 많으면 여전히 비효율적이고, 최선은 큐 기반 + 잠들기 방식이야. 락을 못 잡으면 대기 큐에 들어가서 잠들고, 풀릴 때 깨우는 거. Linux의 futex(fast userspace mutex)가 이 방식인데, 경합이 없을 때는 커널 호출 없이 유저스페이스에서 처리하고, 경합이 있을 때만 커널로 가서 잠드는 하이브리드야. **두 단계 락(two-phase lock)**은 먼저 짧게 스핀하고, 그래도 못 잡으면 잠드는 절충안이고.

락을 사용해서 자료구조를 스레드 안전하게 만들 때, 가장 단순한 건 하나의 빅 락으로 모든 연산을 보호하는 거야. 근데 모든 스레드가 하나의 락을 놓고 경합하면 코어 수가 늘어도 성능이 안 올라가지. 카운터의 경우 **근사 카운터(sloppy counter)**가 해결책인데, 각 CPU마다 로컬 카운터를 두고 주기적으로 글로벌에 합산하면 경합이 사라져. 연결 리스트에서 각 노드마다 락을 붙이는 핸드오버 핸드 락은 이론적으로는 좋지만, 실전에서는 락 잡기/풀기 오버헤드가 커서 단일 락보다 오히려 느린 경우가 많아. "더 세밀한 락이 항상 빠른 건 아니다"라는 교훈이지. 반면 해시 테이블은 버킷별 락으로 확장성이 우수해 — 서로 다른 버킷 연산이 완전히 독립적이라 코어 수에 비례하는 성능 향상을 보여줘. 먼저 단순하게 구현하고, 프로파일링으로 병목을 확인한 후 최적화하는 게 정석이야.

**조건 변수(condition variable)**는 "특정 조건이 될 때까지 기다리기"를 위한 도구야. 락만으로는 이게 안 되거든. wait(cond, mutex)는 락을 풀고 잠들고, 다른 스레드가 signal(cond)을 보내면 깨어나서 락을 다시 잡아. 이 두 동작이 원자적으로 일어나는 게 핵심이야 — 아니면 깨울 신호를 놓칠 수 있어. 반드시 상태 변수와 함께, while 루프 안에서 wait()해야 해. if가 아니라 while인 이유는 **허위 깨어남(spurious wakeup)**이 있을 수 있고, 여러 스레드가 대기 중일 때 하나가 먼저 처리하면 나머지는 다시 자야 하니까. 대표적인 활용이 생산자/소비자(유한 버퍼) 문제야. 조건 변수를 하나만 쓰면 소비자끼리 서로 깨우다가 아무도 진행 못 하는 문제가 생기니까, emptyfill 두 개를 분리해서 생산자는 fill에 signal, 소비자는 empty에 signal. 어떤 스레드를 깨울지 모를 때는 broadcast()로 전부 깨우는 커버링 컨디션 전략도 있는데, 성능은 떨어지지만 정확성은 보장돼.

**세마포어(semaphore)**는 다익스트라가 만든 동기화 도구로, 정수 값을 가진 객체야. sem_wait()(=P)는 값을 1 감소시키고 음수가 되면 블록, sem_post()(=V)는 값을 1 증가시키고 대기 스레드를 깨워. 초기값이 1이면 이진 세마포어 = , 초기값이 0이면 순서 제어용이야. 조건 변수와 달리 상태가 세마포어 값 자체에 내장돼 있어서 시그널을 놓치는 문제가 자연스럽게 해결되지. 생산자/소비자 문제를 세마포어로 풀면 empty(초기값=MAX), full(초기값=0), mutex(초기값=1) 세 개를 쓰는데, mutex를 empty/full 안쪽에서 잡아야 교착 상태를 피해 — 순서가 바뀌면 생산자가 mutex를 잡고 empty를 기다리는데 소비자가 mutex를 못 잡는 상황이 벌어지거든. 독자-작가 락은 여러 독자가 동시에 읽되 작가는 독점 접근하는 패턴인데, 독자가 계속 들어오면 작가 기아가 생길 수 있어. 식사하는 철학자 문제는 모두 왼쪽 포크를 먼저 잡으면 교착 상태가 발생하니까, 한 명만 순서를 바꿔서 대칭을 깨는 전략으로 해결하지.

실제 프로젝트(MySQL, Apache, Mozilla)에서 발견된 동시성 버그를 보면, **비교착 버그가 전체의 97%**로 교착 상태보다 훨씬 흔해. 원자성 위반은 원자적이어야 할 코드가 그렇지 않은 것(NULL 체크 후 사용 사이에 다른 스레드가 NULL로 바꿈), 순서 위반은 초기화 전에 다른 스레드가 사용하는 것. 각각 락과 조건 변수로 해결할 수 있지. 교착 상태는 네 가지 조건(상호 배제, 점유 대기, 비선점, 순환 대기)이 모두 충족돼야 발생하고, 하나만 깨면 돼. 가장 실용적인 건 락 순서 지정 — 항상 정해진 순서로 잡으면 순환 대기가 불가능해. trylock()으로 시도하고 실패하면 가진 락을 풀고 재시도하는 방법도 있지만 라이브락 위험이 있어. lock-free 자료구조(CAS 기반)는 교착 상태를 원천 차단하지만 구현이 매우 어렵지. 데이터베이스에서는 자원 할당 그래프에서 사이클을 탐지하고 프로세스를 하나 죽이는 감지/복구 방식을 많이 써.

스레드 없이 병행성을 달성하는 방법도 있어 — 이벤트 기반 프로그래밍이야. Node.js가 대표적이지. 단일 스레드에서 이벤트를 하나씩 처리하는 이벤트 루프가 핵심이고, select()/epoll/kqueue로 여러 파일 디스크립터를 모니터링하면서 준비된 것만 처리해. 한 번에 하나만 실행되니까 락이 필요 없어서 교착 상태나 경쟁 조건이 없지. 하지만 블로킹 I/O가 치명적이야 — 하나의 핸들러가 디스크 I/O를 기다리면 전체 서버가 멈추거든. 그래서 비동기 I/O(Linux의 io_uring)나 스레드 풀 하이브리드가 필요해. 멀티코어 활용이 어렵고(단일 스레드니까), 콜백 지옥으로 코드가 복잡해지고, 핸들러 간 상태를 수동으로 관리해야 하는 게 단점이야. 스레드 기반과 이벤트 기반은 각각 장단점이 있어서 워크로드에 맞게 골라야 해.


정리

6장 읽고 기억할 거 세 가지:

  1. 스레드의 공유 데이터 문제는 락, 조건 변수, 세마포어로 해결해. 락의 내부는 하드웨어 원자적 명령어(TAS, CAS) 위에 구축되고, futex는 경합 없을 때 유저스페이스에서 처리하는 하이브리드야
  2. 동시성 버그의 97%는 비교착 버그(원자성 위반, 순서 위반)야. 교착 상태보다 훨씬 흔하고, 락과 조건 변수로 잡을 수 있어. 교착 상태 예방의 가장 실용적인 방법은 락 순서 지정이지
  3. 이벤트 기반(Node.js 스타일)은 락 없이 병행성을 달성하지만, 블로킹 I/O 회피와 멀티코어 활용이 과제야. 스레드 기반과 이벤트 기반은 트레이드오프이고 워크로드에 따라 선택해야 해