사전 경쟁 상태
문제

경쟁 상태

gabury1고친 사람 github-actions[bot]

경쟁 상태는 여러 실행 흐름이 같은 데이터를 겹쳐 만질 때 누가 먼저 닿느냐에 따라 결과가 달라지는 고장입니다. 코드도 입력도 바꾸지 않았는데 돌릴 때마다 답이 달라집니다. 대개 오류 하나 없이 틀린 값만 조용히 남습니다.

쉽고 빠른 이해

여럿이 같은 값을 동시에 고치다가 한쪽이 한 일이 사라지는 고장입니다. 조회수를 두 번 올렸는데 1만 오르는 것이 그런 경우입니다.

코드 한 줄이 기계에서는 여러 단계로 나뉘기 때문에 생깁니다. 값을 읽고 고쳐서 다시 쓰는 사이에 남이 끼어들 틈이 있습니다.

이렇게 터집니다:

  1. A 가 값을 읽습니다
  2. A 가 쓰기 전에 B 도 같은 값을 읽습니다
  3. 둘이 각자 계산해 쓰고, 나중에 쓴 값이 앞의 것을 덮습니다

막으려면 속도를 내줘야 합니다. 한 번에 하나만 들어가게 문을 잠그면 나머지 흐름은 그 앞에서 기다립니다. 게다가 잠그는 순서를 잘못 짜면 서로를 기다리며 멈춰 서는 다른 고장이 납니다.

상세

이 절은 먼저 무엇과 무엇이 경쟁하는지를 가릅니다. 이어서 이 고장이 터지는 조건 셋을 세우고, 그 조건을 그대로 만든 최소 재현 코드와 그때의 진행 순서를 봅니다.

마지막으로 조건을 하나씩 깨뜨리는 방법, 조건이 맞아 보여도 안 터지는 경우, 그리고 이 고장이 유난히 안 잡히는 까닭을 봅니다.

무엇이 경쟁하나

은행 창구가 하나인데 두 사람이 같은 통장을 들고 왔다고 해 봅시다. 직원이 한 사람 것을 처리하다 말고 다른 사람 것을 처리하기 시작하면, 먼저 온 사람이 확인한 잔액은 이미 옛날 값이 됩니다. 창구가 둘이어도 통장이 하나면 같은 일이 납니다.

먼저 이 편에서 줄곧 쓸 낱말을 하나 풀어 둡니다. 실행 흐름은 명령을 차례차례 밟아 나가는 단위 하나를 말합니다. 한 프로그램 안에서 따로 도는 흐름이 스레드이고, 저마다 메모리를 따로 쥔 흐름이 프로세스이며, 한 스레드 안에서 번갈아 도는 코루틴도 같은 노릇을 합니다.

경쟁 상태는 여러 실행 흐름이 같은 데이터를 겹쳐 다룰 때, 그 흐름들이 끼어드는 순서에 따라 결과가 달라지는 고장입니다. 재고를 깎는 요청 둘이 같은 순간에 들어와 재고가 한 번만 줄어드는 것이 그런 경우입니다.

이름에 「경쟁」이 붙은 까닭은 두 흐름이 먼저 닿으려 다투는 것처럼 보이기 때문입니다. 실은 다투는 것이 아니라 순서가 아무도 정해 주지 않은 채로 남아 있는 것입니다. 그 순서를 정하는 쪽은 흐름에게 차례를 나눠 주는 스케줄러인데, 스케줄러는 매번 같은 선택을 하지 않습니다.

경쟁 상태는 동시성이 있는 곳에서만 납니다. 동시성은 여러 흐름이 같은 기간에 걸쳐 진행되는 성질입니다. 흐름이 하나뿐인 프로그램에서는 끼어들 것이 없으니 이 고장이 날 수 없습니다.

터지는 조건

셋이 모두 맞을 때만 터집니다. 하나라도 빠지면 아무리 흐름이 많아도 결과가 안 갈립니다.

조건 무슨 뜻인가
공유 같은 데이터를 흐름 둘 이상이 만진다
쓰기 그중 적어도 하나가 그 데이터를 고친다
틈 읽기부터 쓰기까지가 한 덩어리로 안 끝난다

셋째 조건을 뒤집어 말하면 그 구간이 원자성을 못 지킨다는 뜻입니다. 원자성은 여러 단계로 된 일이 전부 된 것이나 전혀 안 된 것 중 하나로만 보이게 하는 성질입니다.

아래 그림은 세 조건을 차례로 물었을 때 어디서 갈라지는지를 보인 것입니다.

flowchart TD
    A["같은 데이터를 흐름 둘 이상이 만지나"] -->|아니오| N1["안 터진다"]
    A -->|예| B["그중 하나라도 쓰나"]
    B -->|아니오| N2["안 터진다"]
    B -->|예| C["읽기부터 쓰기까지 끼어들 틈이 있나"]
    C -->|아니오| N3["안 터진다"]
    C -->|예| R["경쟁 상태"]

세 갈림을 다 지나야 맨 아래에 닿는다는 것이 이 그림에서 볼 점입니다. 조건을 깨뜨려 막는 방법도 이 갈림 셋을 거꾸로 밟습니다.

조건은 이 정도로 좁혀야 재현이 됩니다. 「부하가 높을 때 터집니다」는 조건이 아닙니다. 「스레드 둘이 같은 정수를 각각 1000번 올리면 합이 2000보다 작아진다」가 조건입니다.

최소 재현

정수 하나를 두 스레드가 나눠 올리는 것이 제일 작은 재현입니다. c = c + 1 은 코드에서 한 줄이지만 기계에서는 읽기와 더하기와 쓰기 셋으로 나뉩니다.

Java
int c = 0;

// 스레드 둘이 각자 1000번
c = c + 1;      // 읽고 더하고 쓴다

// 둘 다 끝난 뒤
c               // 1961 — 2000 이 아니다

세 단계 사이에 끼어들 틈이 둘 있습니다. 읽고 나서 더하기 전, 더하고 나서 쓰기 전입니다. 두 스레드가 같은 값을 읽어 버리면 각자 더한 결과가 같아지고, 나중에 쓴 쪽이 앞의 결과를 덮습니다.

아래는 둘 다 0을 읽은 경우의 진행 순서입니다.

sequenceDiagram
    participant A as 스레드 A
    participant C as c
    participant B as 스레드 B
    A->>C: 읽기
    C-->>A: 0
    B->>C: 읽기
    C-->>B: 0
    A->>C: 1 을 쓴다
    B->>C: 1 을 쓴다
    Note over C: 두 번 올렸는데 1

두 번 올렸으니 2가 되어야 하는데 1이 남았습니다. 이렇게 한 흐름의 갱신이 다른 흐름의 쓰기에 덮여 사라지는 것을 갱신 손실이라고 부릅니다. 경쟁 상태가 남기는 결과 중 제일 흔한 모양입니다.

같은 프로그램을 여러 번 돌리면 1961이 나왔다가 1987이 나왔다가 어떤 날은 2000이 나옵니다. 답이 매번 다르다는 것 자체가 이 고장의 표지입니다.

검사한 뒤 고치는 코드

카운터처럼 한 줄짜리만 걸리는 것이 아닙니다. 값을 먼저 확인하고 그 결과를 보고 고치는 코드는 확인과 고침 사이가 통째로 틈이 됩니다.

SQL
SELECT qty FROM stock WHERE id = 7;  -- 1
-- 그 사이 다른 요청도 같은 1 을 읽는다
UPDATE stock SET qty = 0 WHERE id = 7;

재고가 1이라는 것을 확인하고 0으로 깎는 코드입니다. 두 요청이 모두 1을 읽으면 둘 다 주문을 받아들이고 재고는 0이 됩니다. 실제로 팔린 것은 둘인데 하나만 빠진 셈입니다.

이 꼴에는 따로 이름이 붙어 있습니다. 확인한 때와 실제로 쓰는 때가 달라서 생기는 문제를 TOCTOU(Time-Of-Check to Time-Of-Use, 검사한 때와 쓰는 때)라고 부릅니다. 파일이 있는지 확인하고 만드는 코드, 권한을 확인하고 여는 코드가 같은 모양입니다.

조건을 깨뜨려 막는다

막는 방법은 앞의 조건 셋을 하나씩 없애는 것입니다. 어느 조건을 없앨지는 그 코드가 무엇을 하느냐가 정합니다.

틈을 없애는 쪽이 제일 흔합니다. 락을 걸어 한 번에 한 흐름만 들여보내면 읽기부터 쓰기까지가 한 덩어리가 됩니다. 그렇게 한 흐름만 들어가야 하는 코드 구간을 임계 구역이라고 합니다. 짧은 갱신은 CAS(Compare-And-Swap, 비교 후 교체)처럼 기계가 한 번에 처리해 주는 명령으로 틈 없이 끝냅니다.

쓰기를 없애는 쪽도 있습니다. 한 번 만들고 고치지 않는 불변 객체는 아무리 여럿이 읽어도 결과가 안 갈립니다.

공유를 없애는 쪽이 남은 하나입니다. 흐름마다 값을 따로 쥐게 하거나, 값을 주고받는 대신 메시지를 보내 한 흐름만 그 값을 만지게 합니다.

데이터베이스에는 이 셋을 감싼 수단이 이미 있습니다. 여러 읽기와 쓰기를 한 묶음으로 처리하는 단위인 트랜잭션이 바탕이고, 그 묶음끼리 서로의 중간 결과를 얼마나 보게 할지 정하는 격리 수준이 세기를 고릅니다. 그 위에 고칠 행을 미리 잠그는 비관적 잠금과, 쓰기 직전에 값이 바뀌었는지 확인하고 바뀌었으면 되돌리는 낙관적 잠금이 얹힙니다.

막는 데는 값이 따릅니다. 기다리는 흐름이 생기니 처리량이 줄고, 잠그는 순서를 잘못 짜면 서로를 기다리며 멈춰 서는 데드락이 납니다.

안 터지는 경우

조건 셋 중 하나만 빠져도 안 터지므로, 흐름이 여럿이라는 것만으로는 이 고장을 의심할 근거가 못 됩니다.

여럿이 읽기만 하는 값은 안전합니다. 설정값을 띄울 때 한 번 읽고 그 뒤로 고치지 않는다면 흐름이 백 개여도 결과가 같습니다.

기계가 한 번에 처리해 주는 연산도 안전합니다. 다만 어디까지가 한 번인지는 언어와 기계가 정하므로, 한 줄로 보인다고 한 번이라고 볼 수는 없습니다.

결과가 달라도 상관없는 값이면 고장이 아닙니다. 어느 흐름이 마지막으로 쓴 시각을 남기는 값은 누가 이겨도 뜻이 안 바뀝니다.

왜 찾기 어려운가

재현이 잘 안 됩니다. 터지려면 끼어드는 순간이 아주 좁은 구간에 들어와야 하는데, 그 순간을 부르는 쪽이 스케줄러라 부르는 쪽 마음입니다.

들여다보면 사라지는 것도 이 고장의 특징입니다. 디버거를 붙이거나 로그를 늘리면 흐름이 느슨해져 틈을 지나가는 방식이 달라집니다. 이렇게 관찰하면 사라지는 버그를 하이젠버그라고 부릅니다.

테스트를 통과하고 운영에서 터지는 것도 같은 까닭입니다. 개발 기계는 요청이 하나씩 들어오고 운영은 겹쳐 들어옵니다. 겹치는 횟수가 늘수록 좁은 틈에 들어맞는 경우가 나옵니다.

그래서 이 고장은 재현해서 잡기보다 조건으로 찾는 편이 낫습니다. 공유하는 값이 무엇이고, 그 값을 누가 쓰고, 읽기부터 쓰기까지가 한 덩어리인지를 코드에서 직접 짚는 것입니다.

관련 항목

경쟁 상태가 자라는 실행 구조

동시성 · 병렬성 · 스레드 · 프로세스 · 코루틴 · 문맥 교환 · 스케줄러 · 선점 · 공유 자원

경쟁 상태를 막는 장치

락 · 뮤텍스 · 세마포어 · 임계 구역 · 상호배제 · CAS · 원자적 연산 · 메모리 배리어 · 불변 객체 · 스레드 로컬 · 메시지 전달

경쟁 상태가 남기는 증상

갱신 손실 · 더티 리드 · 찢어진 쓰기 · 중복 결제 · 하이젠버그

경쟁 상태와 나란히 나는 동시성 고장

데드락 · 라이브락 · 기아 · ABA 문제 · 우선순위 역전 · 스플릿 브레인

데이터베이스에서 경쟁을 다루는 수단

트랜잭션 · 격리 수준 · 낙관적 잠금 · 비관적 잠금 · 행 잠금 · 직렬화 가능성 · 팬텀 읽기 · 원자성

경쟁 상태를 찾아내는 도구

ThreadSanitizer · 정적 분석 · 스트레스 테스트 · TOCTOU · 코드 리뷰

다른 이름: race condition · 레이스 컨디션 · 경합 조건