동기화 문제의 본질
공유 데이터의 비원자적 연산에서 경쟁 조건이 생기는 과정을 추적하고 임계 영역이 만족해야 할 조건을 정리합니다.
멀티스레드 코드에서 가끔 결과가 다르게 나오는 버그를 만난 적이 있다면, 그것이 바로 동기화 문제입니다.
열 번 실행하면 아홉 번은 정상이고 한 번만 이상한 결과가 나와서, 재현이 어렵고 디버깅이 까다롭습니다.
이런 버그가 무서운 이유는 개발 중에는 발견되지 않다가 프로덕션의 고부하 상황에서 주로 드러나기 때문입니다.
4장에서 스레드는 메모리를 공유한다가 장점이라고 했습니다.
IPC 없이 전역 변수로 데이터를 주고받을 수 있으니까요.
하지만 이 공유가 바로 동기화 문제의 원인입니다.
공유 데이터를 여러 스레드가 동시에 읽고 쓰면 예측 불가능한 일이 벌어집니다.
경쟁 조건 (Race Condition)
두 스레드가 공유 변수 counter = 0을 각각 1씩 증가시킨다고 합시다.
기대 결과는 2입니다.
그런데 counter++는 고급 언어에서 한 줄이지만, 개념적으로는 세 단계로 나뉩니다.
- 메모리에서
counter값을 레지스터에 읽기 (LOAD) - 레지스터 값에 1을 더하기 (ADD)
- 결과를 메모리에 쓰기 (STORE)
이 세 단계 사이에 다른 스레드가 끼어들 수 있습니다.
스레드 A와 B가 교차 실행되면:
두 실행 흐름이 같은 이전 값을 읽고 각각 계산한 결과를 저장하면, 나중 STORE가 앞선 변경을 덮어 기대한 증가 하나가 사라진다.
- 11 · A LOAD
· A LOAD A가 0을 읽는다. 메모리 0.
- 22 · B LOAD
· B LOAD B도 0을 읽는다. 메모리 0.
- 33 · A STORE
· A STORE A가 계산한 1을 쓴다. 메모리 1.
- 44 · B STORE
· B STORE B도 “+1”이 아니라 계산해 둔 1을 써서 결과는 1.
- 5Mutex · 복합 invariant
여러 필드와 검사·변경을 하나의 임계 영역으로 묶는다.
- 6Atomic RMW · 단일 값
지원되는 fetch_add 같은 원자적 read-modify-write와 memory order를 쓴다.
- 7Single owner · 메시지
한 실행 흐름만 상태를 바꾸고 나머지는 요청을 전달한다.
두 스레드가 모두 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원을 인출하면
# 둘 다 성공 처리되지만 최종 잔액은 200원으로 덮어써질 수 있음TOCTOU (Time-of-Check to Time-of-Use): 파일 시스템에서 흔합니다.
파일 존재 여부를 확인한 후 사용하는 사이에 다른 프로세스가 파일을 삭제하면, 사용 시 오류가 발생합니다.
임계 영역 (Critical Section)
동기화 문제의 본질에서는 공유 자원, 임계 영역 경계, 보호 전후의 차이를 확인합니다.
두 스레드가 같은 값을 읽고 각각 증가 값을 저장하면 마지막 store가 앞선 결과를 덮어쓴다. 임계구역은 이 구간을 하나의 순서로 묶는다.
- 1counter=10
read counter=10 둘 다 같은 값 확인
- 2+1
compute +1 지역 레지스터에서 계산
- 311
store 11 마지막 저장이 이김
- 4one at a time
lock one at a time read-add-store를 묶음
경쟁 조건이 발생하는 코드 영역을 임계 영역(Critical Section)이라고 합니다.
위 예제에서 counter++가 임계 영역이고, Check-then-Act의 두 단계 전체가 임계 영역입니다.
임계 영역 문제를 올바르게 해결하려면 세 가지 조건을 모두 만족해야 합니다.
1. 상호 배제 (Mutual Exclusion)
한 스레드가 임계 영역에 진입하면, 다른 스레드는 진입할 수 없어야 합니다.
이것이 가장 기본적인 요구입니다.
한 번에 하나만 들어갈 수 있어야 합니다.
상호 배제가 없으면 → 경쟁 조건 발생
2. 진행 (Progress)
임계 영역에 아무도 없을 때, 진입하려는 스레드 중 하나가 유한한 시간 안에 들어갈 수 있어야 합니다.
아무도 임계 영역에 없는데도 아무도 들어가지 못하는 상황(예: 안전하지 않은 알고리즘에서 두 스레드가 서로 양보만 계속하는 라이브락)이 발생하면 안 됩니다.
진행이 보장되지 않으면 → 교착 상태 또는 라이브락
3. 한정 대기 (Bounded Waiting)
스레드가 임계 영역 진입을 요청한 후, 다른 스레드가 진입하는 횟수에 한계가 있어야 합니다.
특정 스레드가 운 나쁘게 영원히 들어가지 못하는 기아(Starvation)가 발생하면 안 됩니다.
한정 대기가 없으면 → 기아
세 조건이 모두 만족되면, 임계 영역을 안전하게 다루기 위한 기본 요구를 충족합니다.
하나라도 위반되면 실전에서 문제가 발생합니다.
경쟁 조건, 교착 상태, 기아는 동시성에서 대표적으로 조심해야 할 문제입니다.
동기화 버그를 분류할 때는 어떤 요구 조건이 깨졌는지 먼저 나누면 원인과 해결책을 빠르게 좁힐 수 있습니다.
상호 배제, 진행, 한정 대기는 서로 다른 실패 모드를 막기 위한 최소 요구 조건이다.
- 한 번에 하나만
Mutual Exclusion 둘 이상이 동시에 들어가면 공유 값이 덮어써진다. 실패: 경쟁 조건
- 비어 있으면 진입
Progress 아무도 쓰지 않는데 모두 기다리면 시스템이 멈춘다. 실패: 교착 또는 라이브락
- 기다림에 한계
Bounded Waiting 특정 스레드가 계속 밀리면 공정성이 깨진다. 실패: 기아
소프트웨어적 해결 시도의 역사
하드웨어 지원 없이 소프트웨어만으로 상호 배제를 해결하려는 시도가 있었습니다.
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은 순차 일관성(Sequential Consistency)을 가정하면 세 조건을 모두 만족합니다.
하지만 현대 CPU와 컴파일러 환경에서는 그대로 옮겨 쓰면 안전하지 않을 수 있습니다.
CPU가 명령어를 순서 바꿔(Out-of-Order) 실행하거나, 컴파일러가 최적화를 위해 메모리 접근 순서를 변경할 수 있기 때문입니다.
flag[i] = 1과 turn = j의 순서가 바뀌면 알고리즘이 깨집니다.
이 문제를 해결하려면 언어의 atomic 타입과 메모리 순서 제약, 필요할 경우 메모리 배리어(Memory Barrier) 같은 순서 보장 장치를 써야 합니다.
하지만 이미 현대 시스템에는 하드웨어 원자적 명령어(Test-and-Set, CAS)와 검증된 동기화 라이브러리가 있으므로, Peterson's Algorithm은 역사적·교육적 의미가 큽니다.
경쟁 조건을 찾는 도구
경쟁 조건은 가끔 발생하므로 일반 디버거로는 잡기 어렵습니다.
전문 도구가 있습니다.
ThreadSanitizer (TSan): GCC와 Clang에 내장된 동적 분석 도구입니다.
컴파일 시 -fsanitize=thread를 추가하면, 실행 중에 데이터 레이스(data race)를 감지하여 보고합니다.
Check-then-Act 같은 논리적 경쟁 조건은 별도의 설계 검토가 필요할 수 있습니다.
gcc -fsanitize=thread -g -o program program.c -pthread
./program
# 경쟁 조건 발생 시 상세 보고서 출력Valgrind의 Helgrind: 또 다른 동적 분석 도구입니다.
valgrind --tool=helgrind ./program으로 실행합니다.
Python에서는 전용 도구가 부족하지만, concurrent.futures나 asyncio 같은 고수준 API를 사용하면 저수준 동기화 실수를 줄일 수 있습니다.
아래 다이어그램은 경쟁 조건을 의심했을 때 공유 데이터, 원자성, 도구 확인을 어떤 순서로 점검할지 정리한 것입니다.
공유 데이터에 쓰기가 있고, 그 접근이 하나의 원자적 동작으로 보호되지 않으면 실행 순서가 결과를 바꿉니다.
- 1공유 상태
전역 변수, 캐시, 파일, DB 행처럼 둘 이상이 보는 값을 찾습니다.
- 2쓰기 포함
읽기만 하는지, read-modify-write가 섞였는지 분리합니다.
- 3원자성
확인 후 행동, 잔액 차감 같은 묶음이 끊기지 않는지 봅니다.
- 4도구 검증
TSan, Helgrind, 스레드 덤프로 재현 어려운 순서를 잡습니다.
다음 절에서는 경쟁 조건을 해결하기 위한 하드웨어 원자적 연산과 뮤텍스를 다루겠습니다.
아래 다이어그램은 위험한 동시성 코드 패턴과 필요한 점검 기준을 비교한 것입니다.
count++처럼 한 줄로 보이는 코드도 읽기, 수정, 쓰기로 나뉩니다. 같은 값을 여러 실행 흐름이 바꾸고 중간에 끼어들 수 있으면 동기화 문제가 됩니다.
- mutual exclusion
같은 임계 영역은 동시에 통과하지 못해야 합니다.
- progress
비어 있는데도 아무도 못 들어가는 설계는 실패입니다.
- bounded waiting
특정 흐름이 계속 밀려나는 기아를 막아야 합니다.
- tools
ThreadSanitizer와 Helgrind로 실제 경합 지점을 찾습니다.
| 질문 | 확인 기준 | 위험 신호 | 보호 방향 |
|---|---|---|---|
| share | 같은 데이터나 장치를 함께 보는가 | 전역 변수, heap 객체, file descriptor | 소유권 분리 또는 접근 규칙 명시 |
| write | 하나 이상의 흐름이 값을 바꾸는가 | 읽기와 쓰기가 섞인 누적 연산 | 원자 연산 또는 임계 영역 |
| interleave | 중간 상태에 다른 흐름이 들어올 수 있는가 | 체크 후 사용, read-modify-write | 검사와 수정을 한 구간으로 묶기 |
| protect | 보호 규칙이 모든 경로에 적용되는가 | 일부 함수만 lock을 잡는 구조 | 공통 API, lock order, sanitizer |
경쟁 조건은 두 실행 흐름이 같은 상태를 읽고 쓰는 순서가 바뀌면서 결과가 달라질 때 생긴다. 보호할 대상은 변수 하나가 아니라 상태 사이의 약속이다.
- 공유 상태 표시
전역 변수, heap 객체, 파일 offset, queue처럼 여러 스레드가 접근하는 값을 먼저 찾는다.
- 불변식을 적는다
size와 buffer 내용, head와 tail, balance와 ledger처럼 같이 맞아야 하는 조건을 문장으로 만든다.
- interleaving을 검사한다
읽기와 쓰기 사이에 다른 스레드가 끼어들 때 lost update나 깨진 상태가 생기는지 본다.
- 공유 여부
두 실행 흐름이 같은 메모리나 외부 자원에 접근하는가.
- 원자성
읽기와 쓰기 사이에 끼어들면 깨지는 연산이 있는가.
- 범위
lock이 변수 하나가 아니라 불변식 전체를 감싸는가.
tmp = counter;
tmp = tmp + 1;
counter = tmp;
// 세 단계 사이에 다른 스레드가 끼어들 수 있다.