사전 데이터 레이스
문제

데이터 레이스

gabury1고친 사람 github-actions[bot]

데이터 레이스는 두 스레드가 같은 변수를 순서 없이 읽고 쓰는 고장입니다. 한쪽이 쓴 값을 다른 쪽이 언제 볼지 아무도 보장하지 않습니다. 대개는 멀쩡히 돌다가 가끔 엉뚱한 값을 읽거나 멈춰 섭니다.

쉽고 빠른 이해

두 스레드가 락 같은 장치 없이 같은 변수를 만지는 고장입니다. 한 스레드가 stop = true 로 멈추라고 써도 다른 스레드는 그 값을 영영 못 보고 계속 돌 수 있습니다.

컴파일러와 프로세서가 프로그램을 고쳐 쓰기 때문에 생깁니다. 같은 값을 한 번만 읽고 다시 안 읽거나, 읽기와 쓰기의 순서를 바꿉니다. 둘 다 스레드 하나만 보고 빨라지도록 고칩니다. 다른 스레드가 끼어 있다고 알려 주지 않으면 그 고쳐 쓰기가 다른 스레드의 눈에 이상하게 보입니다.

이렇게 터집니다. 넷이 다 맞아야 합니다:

  1. 두 스레드가 같은 변수를 만집니다
  2. 그중 하나가 값을 씁니다
  3. 적어도 한쪽은 한 번에 읽고 쓰도록 만든 원자 변수가 아닌, 보통 변수 접근입니다
  4. 둘 사이에 락처럼 순서를 정해 주는 장치가 없습니다

막으려면 순서를 정해 주는 장치를 넣어야 합니다. 그 장치는 컴파일러와 프로세서가 순서를 바꿔 얻던 속도를 일부 내려놓게 합니다.

상세

이 절은 데이터 레이스가 무엇인지와 터지는 조건 넷부터 세웁니다. 이어서 멈추라는 신호를 못 보는 루프 하나를 최소 재현으로 보고, 그 뒤에서 컴파일러와 프로세서가 무엇을 하는지 봅니다.

그다음 순서를 세우는 장치, 언어마다 다른 결과, 경쟁 상태와 다른 점을 차례로 다룹니다. 끝에서는 같은 고장이 그래픽 카드에서 나는 모습과 찾아내는 방법을 짚습니다.

같은 변수를 순서 없이 만지는 일

스레드는 한 프로그램 안에서 따로 도는 실행 흐름입니다. 스레드 여럿은 같은 메모리를 함께 씁니다. 그래서 한 스레드가 고친 변수를 다른 스레드가 읽을 수 있습니다.

메모리 위치는 값 하나가 놓인 메모리 칸입니다. 변수 하나, 객체의 필드 하나, 배열 칸 하나가 각각 메모리 위치 하나입니다. 데이터 레이스는 이 단위로 따집니다.

데이터 레이스는 두 스레드가 같은 메모리 위치를 만지는 고장입니다. 이때 둘 중 적어도 하나는 쓰기입니다. 그리고 둘 사이에 순서를 정해 주는 장치가 없습니다. 한 스레드가 주문 상태 필드를 「결제됨」으로 바꾸는 동안 다른 스레드가 락 없이 그 필드를 읽는 것이 그런 경우입니다.

「순서가 없다」는 것은 시계로 잰 순서를 말하지 않습니다. 한 스레드가 1초 먼저 썼어도 다른 스레드가 그 값을 본다는 보장은 없습니다. 보장은 순서를 정해 주는 장치가 만들어 줄 때만 생깁니다. 스레드 사이에 이렇게 순서를 정해 주는 일을 동기화라고 부릅니다.

터지는 조건

조건은 넷입니다. 넷이 모두 맞을 때만 데이터 레이스입니다.

원자적 연산은 프로세서가 한 번에 끝내 주는 읽기나 쓰기입니다. 도중에 다른 스레드가 반쯤 된 값을 볼 수 없습니다. 언어들은 이런 연산을 원자 변수라는 이름으로 내줍니다. 자바의 AtomicInteger 가 그런 예입니다.

happens-before는 「앞의 일이 남긴 결과를 뒤의 일이 반드시 본다」는 관계입니다. 시각이 아니라 장치가 이 관계를 세웁니다. 한 스레드가 락을 놓고 다른 스레드가 같은 락을 잡으면, 놓기 전에 한 일이 잡은 뒤에 반드시 보입니다.

조건 무슨 뜻인가
같은 메모리 위치 두 스레드가 같은 변수·필드·배열 칸을 만진다
쓰기 둘 중 적어도 하나가 값을 쓴다
보통 접근 둘 중 적어도 하나가 원자적 연산이 아니다
순서 없음 두 접근 사이에 happens-before 가 없다

넷째 조건이 이 고장의 본체입니다. 앞의 셋은 흔해서 멀티스레드 코드라면 대개 갖추고 있습니다. 순서를 정해 주는 장치 하나가 빠질 때 그 셋이 고장으로 바뀝니다.

셋째 조건은 원자 변수끼리의 접근을 빼 줍니다. 두 접근이 모두 원자적 연산이면 둘 사이에 순서가 없어도 데이터 레이스로 치지 않습니다. 반쯤 된 값을 볼 일이 없기 때문입니다.

원자 변수는 넷째 조건을 깨는 데도 쓰입니다. 원자 변수에 쓰고 그 값을 읽으면 happens-before 가 섭니다. 그러면 그 앞뒤에 있는 보통 변수 접근에도 순서가 생깁니다. 뒤의 「순서를 세우는 장치」 소절이 이 쓰임을 다룹니다.

최소 재현

멈추라는 신호를 변수 하나로 주고받는 코드가 제일 작은 재현입니다. 스레드 A 는 stop 이 참이 될 때까지 돕니다. 스레드 B 는 잠시 뒤 stop 을 참으로 바꿉니다.

Java
boolean stop = false;

// 스레드 A
while (!stop) { }  // 안 끝날 수 있다

// 스레드 B
stop = true;       // A 는 못 볼 수 있다

B 가 stop 을 바꿨습니다. 그래도 A 가 끝나지 않을 수 있습니다. 같은 코드가 어떤 기계에서는 바로 멈추고 어떤 기계에서는 영영 돕니다.

두 스레드가 stop 을 만집니다. 그중 B 가 씁니다. 둘 다 보통 접근입니다. 둘 사이에 순서를 정한 장치도 없습니다. 조건 넷이 다 맞았습니다.

고치는 방법은 넷째 조건을 깨는 것입니다. 자바에서는 stop 을 volatile 로 선언하면 B 의 쓰기와 A 의 읽기 사이에 happens-before 가 생깁니다. 두 접근을 같은 락으로 감싸도 같은 효과가 납니다.

컴파일러와 프로세서가 하는 일

A 가 왜 멈추지 않는지는 컴파일러와 프로세서를 보면 풀립니다. 둘은 프로그램을 빠르게 하려고 읽기와 쓰기를 고쳐 씁니다. 같은 값을 다시 읽지 않고 한 번만 읽기도 하고, 읽기와 쓰기의 순서를 바꾸기도 합니다. 그 기준은 언제나 스레드 하나입니다.

컴파일러는 A 의 루프 안에 stop 을 고치는 코드가 없다는 것을 봅니다. 스레드 하나만 놓고 보면 stop 은 루프 도중에 바뀔 수 없습니다. 그래서 stop 을 한 번만 읽어 레지스터에 넣어 둡니다. 레지스터는 프로세서 안에 있는 저장 칸이라 메모리를 거치지 않습니다.

Java
// 컴파일러가 바꿔 쓴 모양
if (!stop) {
    while (true) { }  // 다시 안 읽는다
}

방금 본 코드는 스레드 하나에게는 원래 코드와 똑같이 동작합니다. B 가 끼어 있다는 것을 컴파일러가 모르니 이렇게 고쳐 써도 틀린 것이 아닙니다.

프로세서도 비슷한 일을 합니다. 코어는 저마다 쓰기를 잠시 모아 두었다가 메모리로 내보내는 쓰기 버퍼를 갖고 있습니다. B 의 코어가 쓴 true 가 이 버퍼에 머무는 동안 A 의 코어는 옛 값 false 를 읽습니다.

아래 그림은 두 경우를 한데 놓은 것입니다. A 는 옛 값을 레지스터에 쥐고 있습니다. B 의 새 값은 아직 메모리에 닿지 않았습니다.

flowchart TD
    subgraph CA["스레드 A 의 코어"]
        RA["레지스터 · stop = false"]
    end
    M["메모리 · stop"]
    subgraph CB["스레드 B 의 코어"]
        WB["쓰기 버퍼 · stop = true"]
    end
    M -.->|"한 번만 읽었다"| RA
    WB -.->|"아직 안 내보냈다"| M

A 의 레지스터에는 B 가 쓴 true 가 없습니다. 그 true 는 B 의 쓰기 버퍼에 머물러 메모리에도 아직 없습니다.

프로세서는 이 밖에도 읽기와 쓰기가 다른 코어에 보이는 순서를 코드 순서와 다르게 만들 수 있습니다. 쓰기 버퍼에 머문 쓰기가 뒤에 적힌 읽기보다 늦게 보이는 것이 그런 경우입니다.

고쳐 쓰기 가운데 이렇게 읽기와 쓰기의 순서를 바꾸는 쪽을 명령어 재배치라고 부릅니다.

각 스레드를 따로 보면 틀린 일이 하나도 없습니다. 틀림은 두 스레드를 같이 놓을 때만 보입니다. 그래서 순서가 중요한 곳은 프로그래머가 직접 알려 줘야 합니다.

순서를 세우는 장치

순서를 알려 주는 방법은 happens-before 를 세우는 것입니다. 이 관계가 선 두 접근은 컴파일러도 프로세서도 순서를 뒤집지 못합니다. 언어마다 이 관계를 세우는 장치를 정해 둡니다.

장치 앞의 일 뒤의 일
락 한 스레드가 락을 놓는다 다른 스레드가 같은 락을 잡는다
원자 변수 한 스레드가 값을 쓴다 다른 스레드가 그 값을 읽는다
스레드 시작 새 스레드를 띄우기 전의 일 새 스레드 안의 일
스레드 종료 대기 끝난 스레드 안의 일 그 스레드를 기다린 뒤의 일

이 표에 규칙 둘이 더해집니다. 첫째, 한 스레드 안에서는 코드에 먼저 적힌 일이 happens-before 로 앞입니다. 실행 순서가 앞이라는 말이 아닙니다. 컴파일러와 프로세서가 실제 순서를 바꿔도, 이 관계로 이어진 스레드에게는 결과가 코드 순서대로 일어난 것처럼 보인다는 말입니다.

둘째, 앞뒤가 이어지면 처음과 끝도 앞뒤가 됩니다. 가가 나보다 앞이고 나가 다보다 앞이면, 가도 다보다 앞입니다.

두 규칙 덕분에 장치 하나가 그 앞의 쓰기를 전부 뒤 스레드에 보이게 합니다. 아래는 스레드 A 가 count 를 고친 뒤 락을 놓고, 스레드 B 가 같은 락을 잡는 순서입니다.

sequenceDiagram
    participant A as 스레드 A
    participant L as 락
    participant B as 스레드 B
    A->>A: count 를 고친다
    A->>L: 놓는다
    B->>L: 잡는다
    B->>B: count 를 읽는다
    Note over A,B: 고친 값이 반드시 보인다

count 를 고치는 일은 락 밖에서 보면 그냥 보통 쓰기입니다. 그래도 순서가 이렇게 이어집니다. A 의 쓰기 → A 의 락 놓기 → B 의 락 잡기 → B 의 읽기.

첫 화살표와 끝 화살표는 한 스레드 안의 코드 순서입니다. 가운데 화살표는 락 규칙입니다. 셋이 이어지므로 A 의 쓰기가 B 의 읽기보다 앞이 됩니다.

언어마다 다른 결과

데이터 레이스가 있는 프로그램이 어떻게 되는지는 언어가 정합니다.

언어들이 공통으로 내거는 약속이 하나 있습니다. 데이터 레이스가 없고 원자 변수도 기본 설정으로 쓰는 프로그램은, 모든 읽기와 쓰기가 한 줄로 차례차례 일어난 것처럼 보입니다. 이 성질을 순차 일관성이라고 부릅니다. 데이터 레이스만 없애면 앞 절의 고쳐 쓰기를 신경 쓰지 않아도 된다는 뜻입니다.

기본 설정이 따로 있는 까닭은 이렇습니다. C++ 같은 언어는 원자 변수마다 순서 보장을 약하게 고르는 설정을 둡니다. 약하게 고르면 빨라지는 대신, 앞 절 표처럼 순서를 세워 주지 않을 수 있습니다. 그래서 그 설정을 고른 프로그램은 순차 일관성을 약속받지 못합니다.

언어가 내놓는 답 가운데 한쪽 끝은 정의되지 않은 동작입니다. 언어가 결과를 아무것도 약속하지 않는 상태를 말합니다. 컴파일러는 「이런 일은 없다」고 가정하고 앞뒤 코드를 고쳐 씁니다. 그래서 고장이 엉뚱한 곳에서 드러납니다.

언어 데이터 레이스가 있으면
C · C++ 프로그램 전체가 정의되지 않은 동작이 된다
자바 동작은 정의돼 있다. 다만 읽기가 어느 쓰기를 볼지는 정해지지 않는다
Rust 안전한 코드에서는 컴파일러가 데이터 레이스를 막는다

자바처럼 동작이 정의된 언어에서도 깨진 값은 나옵니다. 자바에서 volatile 이 아닌 long·double 같은 64비트 값은 32비트씩 두 번에 나눠 쓰일 수 있습니다. 그래서 반쯤 쓰인 채로 읽힐 수 있습니다. 앞 절반은 새 값이고 뒤 절반은 옛 값인 숫자가 나오는 것입니다. 이것을 찢어진 읽기라고 부릅니다.

Rust 코드는 안전한 코드와 unsafe 로 표시한 코드로 나뉩니다. 안전한 코드는 컴파일러가 규칙을 모두 검사하는 코드입니다. unsafe 블록 안에서는 그 검사 일부를 프로그래머가 맡습니다.

Rust 는 한 값에 쓰기용 참조를 하나만 두거나, 읽기용 참조만 여럿 두게 합니다. 이 규칙을 컴파일할 때 검사하므로 안전한 코드에서는 넷째 조건이 성립할 틈이 없습니다.

이 검사는 두 장치가 맡습니다. 소유권은 값마다 주인 변수가 하나라는 규칙입니다. 빌림 검사기는 참조 규칙을 지켰는지 컴파일할 때 따지는 컴파일러의 한 부분입니다.

경쟁 상태와 다른 점

데이터 레이스는 경쟁 상태와 이름이 비슷해서 자주 섞입니다. 경쟁 상태는 스레드들이 끼어드는 순서에 따라 결과가 달라지는 고장입니다. 데이터 레이스는 메모리 접근마다 순서가 정해졌는지를 따집니다. 경쟁 상태는 결과가 맞는지를 따집니다.

그래서 둘은 서로를 품지 않습니다. 아래 표처럼 한쪽만 있는 경우가 양쪽으로 다 있습니다.

코드 데이터 레이스 경쟁 상태
락 없이 카운터를 올린다 ✓ ✓
잔액 확인과 출금을 각각 락으로 감쌌다 ✗ ✓
두 스레드가 락 없이 같은 플래그에 true 를 쓴다 ✓ ✗
카운터를 락 안에서만 만진다 ✗ ✗

둘째 줄은 접근마다 락이 있어서 데이터 레이스가 없습니다. 그래도 잔액을 확인한 뒤 출금하기 전에 다른 스레드가 먼저 출금할 수 있습니다. 결과가 순서에 따라 갈리니 경쟁 상태입니다.

셋째 줄은 누가 먼저 쓰든 결과가 true 라서 경쟁 상태가 아닙니다. 그래도 순서 없는 쓰기가 둘이므로 데이터 레이스입니다. C 나 C++ 이라면 이것만으로 정의되지 않은 동작입니다.

데이터 레이스를 없애도 경쟁 상태는 남을 수 있습니다. 락으로 감쌀 범위는 결과가 맞는지를 따로 따져서 정합니다.

그래픽 카드에서 나는 데이터 레이스

같은 고장이 GPU(Graphics Processing Unit, 그래픽 처리 장치)에서도 납니다. GPU 는 같은 코드를 실행 흐름 수천 개로 나눠 동시에 돌립니다. 흐름이 많은 만큼 같은 메모리를 겹쳐 만질 일도 많습니다.

컴퓨트 셰이더는 GPU 에서 그림이 아닌 일반 계산을 돌리는 프로그램입니다. 그 실행 흐름들은 작업 그룹이라는 묶음으로 나뉩니다. 묶음으로 나누는 까닭은 한 묶음 안의 흐름끼리만 칩 안의 메모리를 나눠 쓰게 하려는 것입니다.

그 메모리가 공유 메모리입니다. 앞에서 말한 「스레드 여럿이 함께 쓰는 메모리」 일반과는 다릅니다. 작업 그룹 하나에 딸린, GPU 칩 안의 작은 메모리를 가리키는 이름입니다.

한 흐름이 공유 메모리에 쓴 값을 다른 흐름이 읽으려면 둘 사이에 배리어를 둬야 합니다. 배리어는 묶음 안의 흐름이 모두 그 줄에 닿을 때까지 기다리게 하는 장치입니다. 이것을 빼먹으면 데이터 레이스입니다.

GPU 명령 사이에서도 같은 일이 납니다. GPU 명령은 프로그램이 GPU 에 보내는 작업 하나입니다. 「이 이미지에 그려라」나 「이 셰이더를 돌려라」 같은 것입니다. 한 명령이 이미지를 다 그리기 전에 다른 명령이 그 이미지를 읽으면 데이터 레이스입니다.

명령 사이에서는 메모리 위치를 이미지 같은 자원 하나 단위로 따집니다. 스레드 대신 명령이 접근할 뿐 조건 넷은 같습니다. 명령 사이의 순서는 흔히 파이프라인 배리어라고 부르는 장치가 세웁니다. 앞 명령의 쓰기가 끝나고 보일 때까지 뒤 명령을 기다리게 합니다.

찾아내는 방법

데이터 레이스는 테스트로 잘 안 잡힙니다. 터지려면 두 접근이 좁은 틈에서 엇갈려야 합니다. 그 틈은 기계와 컴파일러 설정에 따라 생기기도 하고 안 생기기도 합니다.

그래서 실행 중에 순서를 기록하는 도구를 씁니다. ThreadSanitizer는 메모리 접근마다 어느 스레드가 만졌는지와 그 사이의 happens-before 를 기록합니다. 순서 없는 두 접근을 만나면 그 두 줄을 알려 줍니다.

대가는 속도와 메모리입니다. 기록을 남기느라 프로그램이 눈에 띄게 느려지므로 운영보다 테스트에서 켭니다. 그리고 그 실행에서 엇갈린 접근만 보므로 테스트가 지나가지 않은 코드의 데이터 레이스는 못 잡습니다.

관련 항목

데이터 레이스가 깨뜨리는 순서 규칙

happens-before · 메모리 모델 · 순차 일관성 · 명령어 재배치 · 가시성 · 메모리 위치

데이터 레이스를 막는 동기화 수단

동기화 · 락 · 뮤텍스 · 원자적 연산 · volatile · 메모리 배리어 · CAS · 임계 구역 · 불변 객체 · 스레드 로컬

데이터 레이스가 남기는 증상

정의되지 않은 동작 · torn read · 갱신 손실 · 하이젠버그 · 무한 루프

데이터 레이스와 나란히 나는 동시성 고장

경쟁 상태 · 데드락 · 라이브락 · ABA 문제 · TOCTOU · 거짓 공유

데이터 레이스가 나는 실행 구조

스레드 · 동시성 · 병렬성 · 멀티스레딩 · 멀티코어

데이터 레이스 뒤에서 순서를 바꾸는 하드웨어와 컴파일러

컴파일러 · 컴파일러 최적화 · 레지스터 · 쓰기 버퍼 · CPU 캐시 · 캐시 일관성 · 코어

GPU 에서 데이터 레이스가 나는 구조

GPU · 컴퓨트 셰이더 · 작업 그룹 · 공유 메모리 · 배리어 · 파이프라인 배리어 · 렌더 패스 데이터 레이스

데이터 레이스를 찾아내는 도구

ThreadSanitizer · 동적 분석 · 정적 분석 · 스트레스 테스트

데이터 레이스를 컴파일할 때 막는 언어 장치

Rust · 소유권 · 빌림 검사기 · 타입 시스템

다른 이름: data race · 데이터 경합