동기화 문제의 본질
멀티스레드 코드에서 가끔 결과가 다르게 나오는 버그를 만난 적이 있다면, 그것이 바로 동기화 문제입니다. 열 번 실행하면 아홉 번은 정상이고 한 번만 이상한 결과가 나와서, 재현이 어렵고 디버깅이 까다롭습니다. 이런 버그가 무서운 이유는 개발 중에는 발견되지 않다가 프로덕션의 고부하 상황에서만 나타나기 때문입니다.
4장에서 스레드는 메모리를 공유한다가 장점이라고 했습니다. IPC 없이 전역 변수로 데이터를 주고받을 수 있으니까요. 하지만 이 공유가 바로 동기화 문제의 원인입니다. 공유 데이터를 여러 스레드가 동시에 읽고 쓰면 예측 불가능한 일이 벌어집니다.
경쟁 조건 (Race Condition)
두 스레드가 공유 변수 counter = 0을 각각 1씩 증가시킨다고 합시다. 기대 결과는 2입니다.
그런데 counter++는 고급 언어에서 한 줄이지만, CPU 입장에서는 세 단계의 기계어 명령입니다.
- 메모리에서
counter값을 레지스터에 읽기 (LOAD) - 레지스터 값에 1을 더하기 (ADD)
- 결과를 메모리에 쓰기 (STORE)
이 세 단계 사이에 다른 스레드가 끼어들 수 있습니다. 스레드 A와 B가 교차 실행되면:
시간 스레드 A 스레드 B counter(메모리)
───────────────────────────────────────────────────────────────
1 LOAD counter → R1=0 0
2 LOAD counter → R2=0 0
3 ADD R1=0+1=1 0
4 ADD R2=0+1=1 0
5 STORE R1=1 → counter 1
6 STORE R2=1 → counter 1 ← 기대값 2, 실제 1두 스레드가 모두 counter를 0으로 읽었고, 각각 1을 더해서 1을 썼습니다. 하나의 증가가 유실되었습니다.
이처럼 실행 순서(타이밍)에 따라 결과가 달라지는 상황을 경쟁 조건(Race Condition)이라고 합니다. 경쟁이라는 이름은 여러 스레드가 공유 자원에 먼저 접근하려고 경쟁하는 것에서 유래합니다.
경쟁 조건은 다음 조건이 모두 만족될 때 발생합니다.
- 여러 스레드(또는 프로세스)가 공유 데이터에 접근합니다.
- 최소 하나가 쓰기(Write)를 합니다.
- 접근이 동기화되지 않았습니다.
모두 읽기만 하면 경쟁 조건은 발생하지 않습니다. 위험한 것은 읽기+쓰기 또는 쓰기+쓰기의 동시 발생입니다.
실무에서의 경쟁 조건
경쟁 조건은 교과서적인 counter++만의 문제가 아닙니다. 실무에서 자주 만나는 패턴들을 보겠습니다.
Check-then-Act 패턴: "확인하고 행동"하는 두 단계가 원자적이지 않을 때 발생합니다.
# 위험한 코드
if username not in database:
database[username] = create_user()
# 두 스레드가 동시에 "없다"고 판단하면 중복 생성두 요청이 동시에 유저가 존재하지 않음을 확인하고, 둘 다 유저를 생성합니다. 데이터베이스에는 두 개의 레코드가 생깁니다.
Read-Modify-Write 패턴: 앞의 counter++가 이 패턴입니다. 잔액 확인 → 잔액 차감도 같은 구조입니다.
# 위험한 코드
balance = get_balance(account) # 읽기
if balance >= amount:
set_balance(account, balance - amount) # 수정-쓰기
# 두 스레드가 동시에 잔액 1000원을 읽고 각각 800원을 인출하면
# 잔액이 -600원이 됩니다TOCTOU (Time-of-Check to Time-of-Use): 파일 시스템에서 흔합니다. 파일 존재 여부를 확인한 후 사용하는 사이에 다른 프로세스가 파일을 삭제하면, 사용 시 오류가 발생합니다.
임계 영역 (Critical Section)
경쟁 조건이 발생하는 코드 영역을 임계 영역(Critical Section)이라고 합니다. 위 예제에서 counter++가 임계 영역이고, Check-then-Act의 두 단계 전체가 임계 영역입니다.
임계 영역 문제를 올바르게 해결하려면 세 가지 조건을 모두 만족해야 합니다.
1. 상호 배제 (Mutual Exclusion)
한 스레드가 임계 영역에 진입하면, 다른 스레드는 진입할 수 없어야 합니다. 이것이 가장 기본적인 요구입니다. 한 번에 하나만 들어갈 수 있어야 합니다.
상호 배제가 없으면 → 경쟁 조건 발생
2. 진행 (Progress)
임계 영역에 아무도 없을 때, 진입하려는 스레드 중 하나가 유한한 시간 안에 들어갈 수 있어야 합니다. 아무도 임계 영역에 없는데도 아무도 들어가지 못하는 상황(예: 안전하지 않은 알고리즘에서 두 스레드가 서로 양보만 계속하는 라이브락)이 발생하면 안 됩니다.
진행이 보장되지 않으면 → 교착 상태 또는 라이브락
3. 한정 대기 (Bounded Waiting)
스레드가 임계 영역 진입을 요청한 후, 다른 스레드가 진입하는 횟수에 한계가 있어야 합니다. 특정 스레드가 운 나쁘게 영원히 들어가지 못하는 기아(Starvation)가 발생하면 안 됩니다.
한정 대기가 없으면 → 기아
세 조건이 모두 만족되면, 임계 영역은 안전합니다. 하나라도 위반되면 실전에서 문제가 발생합니다. 경쟁 조건, 교착 상태, 기아 — 이 세 가지가 동시성의 삼대 악입니다.
소프트웨어적 해결 시도의 역사
하드웨어 지원 없이 소프트웨어만으로 상호 배제를 해결하려는 시도가 있었습니다.
Peterson's Algorithm은 두 프로세스(스레드)에 대한 소프트웨어 상호 배제 해결법입니다.
int flag[2] = {0, 0}; /* 각 스레드의 진입 의사 */
int turn; /* 누구 차례인지 */
/* 스레드 i (i=0 또는 1) */
void enter_section(int i) {
int j = 1 - i; /* 상대방 */
flag[i] = 1; /* 나 들어가고 싶어 */
turn = j; /* 근데 너 먼저 가 */
while (flag[j] && turn == j) {
/* 상대도 들어가고 싶고, 상대 차례면 대기 */
}
}
void leave_section(int i) {
flag[i] = 0;
}Peterson's Algorithm은 세 조건을 모두 만족합니다. 하지만 현대 CPU에서는 동작하지 않을 수 있습니다. CPU가 명령어를 순서 바꿔(Out-of-Order) 실행하거나, 컴파일러가 최적화를 위해 메모리 접근 순서를 변경할 수 있기 때문입니다. flag[i] = 1과 turn = j의 순서가 바뀌면 알고리즘이 깨집니다.
이 문제를 해결하려면 메모리 배리어(Memory Barrier)를 삽입해야 합니다. 하지만 이미 현대 시스템에는 하드웨어 원자적 명령어(Test-and-Set, CAS)가 있으므로, Peterson's Algorithm은 역사적·교육적 의미가 큽니다.
경쟁 조건을 찾는 도구
경쟁 조건은 가끔 발생하므로 일반 디버거로는 잡기 어렵습니다. 전문 도구가 있습니다.
ThreadSanitizer (TSan): GCC와 Clang에 내장된 동적 분석 도구입니다. 컴파일 시 -fsanitize=thread를 추가하면, 실행 중에 경쟁 조건을 감지하여 보고합니다.
gcc -fsanitize=thread -g -o program program.c -pthread
./program
# 경쟁 조건 발생 시 상세 보고서 출력Valgrind의 Helgrind: 또 다른 동적 분석 도구입니다. valgrind --tool=helgrind ./program으로 실행합니다.
Python에서는 전용 도구가 부족하지만, concurrent.futures나 asyncio 같은 고수준 API를 사용하면 저수준 동기화 실수를 줄일 수 있습니다.
다음 절에서는 경쟁 조건을 해결하기 위한 하드웨어 원자적 연산과 뮤텍스를 다루겠습니다.