포스트

동기화 (1) — 경쟁 상태와 임계 구역, 뮤텍스 vs 스핀락

경쟁 상태(race condition)와 임계 구역, 원자적 연산(CAS), 뮤텍스와 스핀락의 차이·선택 기준을 그림과 코드로 설명하는 동기화 입문.

동기화 (1) — 경쟁 상태와 임계 구역, 뮤텍스 vs 스핀락

“count++ 한 줄이 왜 깨지나”

스레드 두 개가 전역 변수 count를 각각 100만 번 1씩 올립니다. 끝나면 200만이어야 합니다. 그런데 돌릴 때마다 1,983,442 … 1,996,051 … 매번 다른, 200만보다 작은 수가 나옵니다. 코드는 분명 count++ 한 줄인데요.

범인은 “한 줄”이라는 착각입니다. 앞 글에서 봤듯 스레드는 같은 주소 공간을 공유하고, 단일 CPU 위에서 명령들이 언제든 인터리빙될 수 있습니다. count++는 사실 세 개의 기계 명령이고, 그 사이를 다른 스레드가 비집고 들어오면 갱신이 통째로 증발합니다. 이 글은 그 증발이 일어나는 정확한 순간을 들여다보고, 그걸 막는 가장 기본 도구인 락 — 그중 뮤텍스와 스핀락을 언제 무엇을 써야 하는지까지 따라갑니다.

count++는 원자적이지 않다

고급 언어의 count++는 컴파일하면 보통 세 단계로 펼쳐집니다.

1
2
3
load   r1 ← [count]    ; 메모리에서 레지스터로 읽기
add    r1 ← r1 + 1     ; 레지스터에서 1 더하기
store  [count] ← r1    ; 다시 메모리로 쓰기

이 세 단계 사이 어디서든 스케줄러가 다른 스레드로 전환할 수 있습니다(타이머 인터럽트 한 번이면 충분). 두 스레드가 둘 다 “옛날 값”을 읽은 뒤 각자 +1 해서 쓰면, 두 번 올렸는데 결과는 한 번만 오른 것처럼 됩니다 — 갱신 분실(lost update).

아래 타임라인에서 스레드 A(파랑)와 B(주황)가 count=0에서 시작합니다. 둘 다 0을 읽고, 둘 다 1을 계산하고, 둘 다 1을 씁니다. 두 번 증가시켰는데 결과는 1입니다.

스레드 A 스레드 B 시간 → (둘 다 count=0을 읽었다) load 0 load 0 add → 1 add → 1 store 1 store 1 결과 count = 1 (2가 아니라!) — 갱신 하나가 증발

핵심은 “동시에 일어나서”가 아니라 “끼어들 수 있어서” 깨진다는 점입니다. 단일 코어에서도, 인터리빙 순서가 비결정적이기 때문에 똑같이 터집니다. 그래서 결과가 입력이 아니라 타이밍에 의존하게 되는 상황을 경쟁 상태(race condition) 라 부릅니다.

임계 구역과 상호 배제

문제의 본질은 “공유 자원을 만지는 코드 조각이 중간에 끼어들기를 허용한다”는 것입니다. 이 조각이 임계 구역(critical section) 이고, 한 번에 한 스레드만 들어가게 만드는 것이 상호 배제(mutual exclusion) 입니다.

올바른 동기화 도구가 갖춰야 할 세 가지 성질:

  • 상호 배제: 임계 구역 안에는 최대 한 스레드.
  • 진행(progress): 아무도 안 들어가 있으면, 들어가려는 스레드 중 하나는 반드시 들어갈 수 있어야 한다(불필요한 차단 금지).
  • 한정 대기(bounded waiting): 들어가려는 스레드는 유한 시간 안에 차례가 온다(굶기지 않음).
flowchart LR
  A["진입 구역<br/>lock()"] --> C["임계 구역<br/>공유 자원 접근<br/>(한 번에 하나)"]
  C --> B["퇴장 구역<br/>unlock()"]
  B --> R["나머지 구역"]
  R -.다시.-> A

소프트웨어만으로(Peterson 알고리즘 등) 상호 배제를 만들 수도 있지만, 현대 CPU의 명령 재배치(out-of-order)와 캐시 때문에 순수 소프트웨어 방식은 깨지기 쉽습니다. 그래서 실전에서는 하드웨어가 보장하는 원자 명령 위에 락을 쌓습니다.

원자성의 뿌리: TAS와 CAS

하드웨어는 “읽고-수정하고-쓰기”를 쪼갤 수 없는 한 명령으로 제공합니다. 이게 모든 락의 토대입니다.

  • TAS(Test-And-Set): 메모리를 1로 세팅하면서 이전 값을 돌려준다. 이전이 0이었으면 내가 락을 잡은 것.
  • CAS(Compare-And-Swap): “이 주소가 아직 expected면 new로 바꿔라”를 원자적으로. 바뀌면 성공, 아니면 누가 먼저 손댔다는 뜻 → 재시도. 락 없는(lock-free) 자료구조의 심장.
  • LL/SC(Load-Linked/Store-Conditional): ARM·RISC-V 계열. 읽은 뒤 그 사이 아무도 안 건드렸을 때만 쓰기 성공.
1
2
3
4
5
6
7
8
9
/* CAS로 직접 만든 카운터 — 락 없이 원자적 증가 */
#include <stdatomic.h>
atomic_int count = 0;

void inc(void) {
    int old = atomic_load(&count);
    while (!atomic_compare_exchange_weak(&count, &old, old + 1))
        ; /* 실패 = 그 사이 누가 바꿈 → old 갱신되어 자동 재시도 */
}

현실 체크 — 원자 ≠ 가시성. 원자 명령이 “찢긴 쓰기”는 막아도, 한 코어가 쓴 값을 다른 코어가 언제 보는지는 별개 문제입니다(캐시·명령 재배치). 그래서 volatile만으론 부족하고 메모리 배리어(혹은 acquire/release 시맨틱)가 필요합니다. pthread_mutex·C11 atomic은 이 배리어를 내부에 포함하므로, 직접 lock-free를 짜는 게 아니라면 표준 동기화 객체를 쓰는 게 안전합니다.

뮤텍스 vs 스핀락 — 기다리는 방식의 차이

락을 못 잡았을 때 어떻게 기다리느냐가 둘을 가릅니다.

  • 스핀락: 락이 풀릴 때까지 while(test_and_set) 루프를 계속 돌며 기다린다(busy-wait). 잠들지 않으니 깨어나는 비용이 0 → 임계 구역이 아주 짧을 때 유리. 대신 기다리는 내내 CPU를 태운다.
  • 뮤텍스: 락을 못 잡으면 커널에 요청해 스레드를 재운다(sleep). CPU를 양보하니 다른 일이 돌 수 있지만, 재우고 깨우는 데 컨텍스트 스위치 비용이 든다 → 임계 구역이 길거나 경합이 심할 때 유리.

아래에서 두 스레드가 각각 락을 기다립니다. 위(스핀락)는 대기 스레드가 CPU를 계속 태우며 회전하고, 아래(뮤텍스)는 잠들었다가 락이 풀리는 순간 깨어납니다.

스핀락 · 락 풀릴 때까지 계속 확인 (CPU 소모) 대기 스레드 ↻ test_and_set 루프 — CPU 100% 점유 뮤텍스 · 못 잡으면 sleep → 락 해제 시 커널이 깨움 대기 스레드 z z Z sleep 중 CPU 양보 → 다른 스레드 실행 가능 락 해제 신호 → 깨어나 진입 (전환 비용 발생)
 스핀락뮤텍스
대기 방식busy-wait(계속 확인)sleep(재움)
대기 중 CPU태움양보
깨어나는 비용거의 0컨텍스트 스위치
유리한 상황임계 구역 매우 짧음, 멀티코어임계 구역 길거나 경합 심함
인터럽트 컨텍스트가능(잠들면 안 되는 곳)불가(잠들 수 있어서)

현실 체크 — 단일 코어 스핀락은 자살. 코어가 하나인데 스핀락을 잡은 스레드가 선점당하면, 대기 스레드는 있지도 않은 락 해제를 기다리며 자기 타임 슬라이스를 통째로 낭비합니다(락 가진 스레드는 CPU를 못 받아 영영 못 풉니다). 그래서 리눅스 커널은 스핀락을 잡는 동안 선점·인터럽트를 끄고, 유저 공간은 보통 둘을 섞은 적응형 뮤텍스(잠깐 스핀하다 안 되면 sleep)를 씁니다 — glibc의 PTHREAD_MUTEX_ADAPTIVE_NP.

직접 재현하고 관찰하기

1
2
3
4
5
6
7
8
9
10
11
12
13
/* 락 없이 = 깨지고, 뮤텍스 씌우면 = 정확해진다 */
#include <pthread.h>
long count = 0;
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;

void *worker(void *_) {
    for (int i = 0; i < 1000000; i++) {
        pthread_mutex_lock(&m);     /* ← 빼고 돌리면 결과가 2,000,000 미만 */
        count++;                    /* 임계 구역 */
        pthread_mutex_unlock(&m);
    }
    return NULL;
}
1
2
3
4
5
# 락 경합이 성능을 어떻게 갉아먹는지 — 시스템콜(futex) 폭증을 본다
strace -c -f ./a.out          # 경합 심하면 futex 호출 수가 치솟는다
perf stat -e task-clock,context-switches ./a.out
# false sharing(서로 다른 변수가 같은 캐시라인) 진단
perf c2c record ./a.out && perf c2c report

뮤텍스의 sleep/wake는 리눅스에서 futex(fast userspace mutex)로 구현됩니다 — 경합이 없으면 유저 공간에서 원자 연산만으로 끝내고, 경합할 때만 커널로 내려가 재우고 깨웁니다. strace에서 futex 호출이 폭증한다면 락 경합이 병목이라는 신호입니다.

면접/리뷰 단골 질문

  • Q. count++가 왜 race가 되나? → load·add·store 3단계라 그 사이 인터리빙되면 두 스레드가 같은 옛 값을 읽고 덮어써 갱신이 분실된다. 단일 코어에서도 발생한다.
  • Q. 임계 구역의 정확성 조건 셋은? → 상호 배제, 진행, 한정 대기.
  • Q. 뮤텍스와 스핀락은 언제 무엇을? → 임계 구역이 매우 짧고 멀티코어면 스핀락(전환 비용 회피), 길거나 경합이 심하면 뮤텍스(CPU 양보). 인터럽트 컨텍스트처럼 잠들면 안 되는 곳은 스핀락.
  • Q. CAS가 뭐고 왜 중요한가? → “값이 기대치면 교체”를 원자적으로 수행. 실패 시 재시도하는 패턴으로 락 없는 자료구조를 만든다.
  • Q. 원자 연산만 쓰면 동기화 끝인가? → 아니다. 찢긴 쓰기는 막아도 가시성·재배치는 메모리 배리어(acquire/release)가 필요하다.

정리

  • count++는 load·add·store 3단계라 원자가 아니다 → 인터리빙되면 갱신 분실(race condition).
  • 공유 자원을 만지는 코드가 임계 구역, 한 번에 하나만 들이는 게 상호 배제. 올바른 락은 상호배제·진행·한정대기를 만족해야 한다.
  • 모든 락의 뿌리는 하드웨어 원자 명령(TAS·CAS·LL/SC). CAS 재시도로 lock-free도 가능.
  • 스핀락은 busy-wait(짧은 임계구역), 뮤텍스는 sleep(긴 임계구역·심한 경합). 단일 코어 스핀락은 위험, 실전은 적응형 뮤텍스.
  • 원자성과 가시성은 별개 — 메모리 배리어가 필요하고, 표준 동기화 객체(futex 기반 뮤텍스)는 이를 포함한다.

다음 글: 락만으론 “자원 N개”나 “순서 조건”을 못 다룹니다. 카운팅 세마포어·컨디션 변수, 그리고 락이 만드는 함정인 데드락으로 이어집니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

시리즈 · 운영체제 A-Z (20편)

  1. 운영체제란 무엇인가 — 커널·유저모드·시스템콜, 왜 프로그램은 하드웨어를 직접 못 만지나
  2. 부팅 — 전원 버튼에서 커널까지, 그 1초 사이에 일어나는 모든 일
  3. 프로세스란 무엇인가 — 주소 공간·PCB·fork와 exec, 그리고 COW의 마법
  4. 스레드와 동시성 — 한 주소 공간 속 여러 실행 흐름, 그리고 비결정성의 시작
  5. CPU 스케줄링 — 누구에게 CPU를 줄까, FCFS부터 리눅스 CFS까지
  6. 컨텍스트 스위치 — 전환은 공짜가 아니다, 직접비용과 캐시·TLB 오염
  7. 프로세스 간 통신(IPC) — 격리된 프로세스가 협력하는 법, 파이프부터 공유 메모리까지
  8. 동기화 (1) — 경쟁 상태와 임계 구역, 뮤텍스 vs 스핀락 지금 보는 글
  9. 동기화 (2) — 세마포어·조건 변수, 그리고 데드락의 네 가지 조건
  10. 가상 메모리 — 모든 프로세스가 같은 주소에서 시작하는데 왜 안 겹치나
  11. 페이징·페이지 테이블·TLB — 4단계 주소 변환과 그 캐시
  12. 페이지 폴트·디맨드 페이징·스왑 — malloc 1GB가 즉시 성공하는 이유
  13. 페이지 교체 알고리즘 — 메모리가 꽉 찼을 때 누구를 쫓아낼까
  14. 메모리 할당자 — malloc은 어떻게 힙을 쪼개나, buddy와 slab까지
  15. 파일 시스템 (1) — 파일명은 파일이 아니다, inode와 VFS의 세계
  16. 파일 시스템 (2) — 쓰는 중 전원이 꺼지면? 저널링과 크래시 일관성
  17. I/O·인터럽트·DMA — CPU보다 백만 배 느린 장치와 효율적으로 대화하기
  18. 블록 I/O와 페이지 캐시 — 같은 파일을 두 번째 읽으면 왜 빠른가
  19. 가상화와 컨테이너 — VM과 컨테이너, 격리 스펙트럼의 양 끝
  20. 성능 관찰과 병목 분석 — "느려요"를 추측이 아니라 측정으로 (시리즈 마무리)