안동민 개발노트

본문 시작

동기화 문제의 본질

공유 데이터의 비원자적 연산에서 경쟁 조건이 생기는 과정을 추적하고 임계 영역이 만족해야 할 조건을 정리합니다.

멀티스레드 코드에서 가끔 결과가 달라진다면 동기화 문제를 의심할 수 있습니다. 다만 입력·외부 상태 등 다른 원인도 있으므로 실행 순서와 공유 상태를 함께 확인해야 합니다.

열 번 실행하면 아홉 번은 정상이고 한 번만 이상한 결과가 나와서, 재현이 어렵고 디버깅이 까다롭습니다.

이런 버그가 무서운 이유는 개발 중에는 발견되지 않다가 프로덕션의 고부하 상황에서 주로 드러나기 때문입니다.

4장에서 스레드는 메모리를 공유한다가 장점이라고 했습니다.

IPC 없이 전역 변수로 데이터를 주고받을 수 있으니까요.

하지만 이 공유가 바로 동기화 문제의 원인입니다.

공유 데이터를 여러 스레드가 동시에 읽고 쓰면 예측 불가능한 일이 벌어집니다.


경쟁 조건 (Race Condition)

두 스레드가 공유 변수 counter = 0을 각각 1씩 증가시킨다고 합시다.

기대 결과는 2입니다.

그런데 counter++는 고급 언어에서 한 줄이지만, 개념적으로는 세 단계로 나뉩니다.

  1. 메모리에서 counter 값을 레지스터에 읽기 (LOAD)
  2. 레지스터 값에 1을 더하기 (ADD)
  3. 결과를 메모리에 쓰기 (STORE)

이 세 단계 사이에 다른 스레드가 끼어들 수 있습니다.

스레드 A와 B가 교차 실행되면:

두 번 증가해도 1이 남는 교차 순서

읽기와 계산과 저장을 분리한 추상 모델의 가능한 한 순서. 초기 counter는 0이고 ADD는 각 스레드의 지역 값만 바꾼다.

두 번 증가해도 1이 남는 교차 순서
순서스레드의 지역 계산공유 counter
1 · A 읽기A가 0을 읽고 1을 계산합니다.0
2 · B 읽기B도 0을 읽고 1을 계산합니다.0
3 · A 저장A가 계산해 둔 1을 저장합니다.1
4 · B 저장B가 계산해 둔 1을 다시 저장합니다.1 · 증가 한 번 유실
1 · A 읽기
스레드의 지역 계산: A가 0을 읽고 1을 계산합니다.
공유 counter: 0
2 · B 읽기
스레드의 지역 계산: B도 0을 읽고 1을 계산합니다.
공유 counter: 0
3 · A 저장
스레드의 지역 계산: A가 계산해 둔 1을 저장합니다.
공유 counter: 1
4 · B 저장
스레드의 지역 계산: B가 계산해 둔 1을 다시 저장합니다.
공유 counter: 1 · 증가 한 번 유실

표는 읽기와 쓰기를 분리한 추상 모델의 가능한 순서입니다. 실제 실행을 관측한 결과나 모든 언어의 허용 결과 전체를 뜻하지 않습니다.

이처럼 실행 순서(타이밍)에 따라 결과가 달라지는 상황을 경쟁 조건(Race Condition)이라고 합니다.

경쟁이라는 이름은 여러 스레드가 공유 자원에 먼저 접근하려고 경쟁하는 것에서 유래합니다.

다음은 공유 메모리의 데이터 레이스를 점검할 때 보는 조건입니다. 논리적 경쟁 조건은 개별 접근이 원자적이어도 검사와 변경을 따로 수행하면 생길 수 있습니다.

  • 여러 스레드(또는 프로세스)가 공유 데이터에 접근합니다.
  • 최소 하나가 쓰기(Write)를 합니다.
  • 접근이 동기화되지 않았습니다.

같은 메모리 위치를 읽기만 하는 접근끼리는 데이터 레이스를 만들지 않습니다.

위험한 것은 읽기+쓰기 또는 쓰기+쓰기의 동시 발생입니다.

실무에서의 경쟁 조건

경쟁 조건은 교과서적인 counter++만의 문제가 아닙니다.

실무에서 자주 만나는 패턴들을 보겠습니다.

Check-then-Act 패턴: "확인하고 행동"하는 두 단계가 원자적이지 않을 때 발생합니다.

check_then_act_race.py
# 위험한 코드
if username not in database:
    database[username] = create_user()
    # 두 스레드가 동시에 "없다"고 판단하면 중복 생성

두 요청이 동시에 유저가 존재하지 않음을 확인하고, 둘 다 유저를 생성합니다.

이 코드에서 database가 딕셔너리라면 같은 키의 대입은 덮어쓰기가 될 수 있고, create_user()의 부수 효과는 두 번 발생할 수 있습니다. 실제 DB 레코드 중복 여부는 구현과 유일성 제약에 달려 있습니다.

Read-Modify-Write 패턴: 앞의 counter++가 이 패턴입니다.

잔액 확인 → 잔액 차감도 같은 구조입니다.

bank_race.py
# 위험한 코드
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)

경쟁 조건이 발생하는 코드 영역을 임계 영역(Critical Section)이라고 합니다.

위 예제에서 counter++가 임계 영역이고, Check-then-Act의 두 단계 전체가 임계 영역입니다.

임계 영역 문제를 올바르게 해결하려면 세 가지 조건을 모두 만족해야 합니다.

1. 상호 배제 (Mutual Exclusion)

한 스레드가 임계 영역에 진입하면, 다른 스레드는 진입할 수 없어야 합니다.

이것이 가장 기본적인 요구입니다.

한 번에 하나만 들어갈 수 있어야 합니다.

상호 배제가 필요한 복합 갱신을 보호하지 않으면 값이 유실될 수 있습니다. 원자적 연산이나 단일 소유 방식으로 해결하는 설계도 있습니다.

2. 진행 (Progress)

임계 영역에 아무도 없을 때, 진입하려는 스레드 중 하나가 유한한 시간 안에 들어갈 수 있어야 합니다.

아무도 임계 영역에 없는데도 아무도 들어가지 못하는 상황(예: 안전하지 않은 알고리즘에서 두 스레드가 서로 양보만 계속하는 라이브락)이 발생하면 안 됩니다.

진행이 보장되지 않으면 → 교착 상태 또는 라이브락

3. 한정 대기 (Bounded Waiting)

스레드가 임계 영역 진입을 요청한 후, 다른 스레드가 진입하는 횟수에 한계가 있어야 합니다.

특정 스레드가 운 나쁘게 영원히 들어가지 못하는 기아(Starvation)가 발생하면 안 됩니다.

한정 대기 보장이 없으면 기아 가능성을 따로 검토해야 합니다. 실제 한 번의 실행에서 반드시 기아가 발생한다는 뜻은 아닙니다.

세 조건이 모두 만족되면, 임계 영역을 안전하게 다루기 위한 기본 요구를 충족합니다.

상호 배제의 안전성과 진행·한정 대기의 생존성은 별도로 평가합니다. 일반 락 API가 모두 FIFO나 한정 대기를 보장하는 것은 아닙니다.

경쟁 조건, 교착 상태, 기아는 동시성에서 대표적으로 조심해야 할 문제입니다.

동기화 버그를 분류할 때는 어떤 요구 조건이 깨졌는지 먼저 나누면 원인과 해결책을 빠르게 좁힐 수 있습니다.


소프트웨어적 해결 시도의 역사

하드웨어 지원 없이 소프트웨어만으로 상호 배제를 해결하려는 시도가 있었습니다.

Peterson's Algorithm은 두 프로세스(스레드)에 대한 소프트웨어 상호 배제 해결법입니다.

peterson.c (개념)
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)을 가정하면 세 조건을 모두 만족합니다.

위 코드는 알고리즘을 표현한 의사코드입니다. 실제 C 스레드가 일반 int를 이렇게 공유하면 데이터 레이스로 정의되지 않은 동작이 되며, 결과를 특정 교차 순서로만 제한할 수 없습니다.

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를 추가하면 계측된 실행 중 발견한 데이터 레이스를 보고합니다. 미발견 결과가 모든 실행 경로의 안전성을 증명하지는 않습니다.

Check-then-Act 같은 논리적 경쟁 조건은 별도의 설계 검토가 필요할 수 있습니다.

TSan 사용
gcc -fsanitize=thread -g -o program program.c -pthread
./program
# 경쟁 조건 발생 시 상세 보고서 출력

Valgrind의 Helgrind: 또 다른 동적 분석 도구입니다.

valgrind --tool=helgrind ./program으로 실행합니다.

Python의 concurrent.futures나 asyncio로 실행 구조를 단순화할 수는 있지만, 공유 상태의 검사·변경을 자동으로 원자화하지는 않습니다. 보호할 불변식과 모든 접근 경로를 함께 검토해야 합니다.

다음 절에서는 경쟁 조건을 해결하기 위한 하드웨어 원자적 연산과 뮤텍스를 다루겠습니다.