데이터 경합과 경합 조건

안전한 러스트는 다음과 같이 정의된 데이터 경합이 발생하지 않는 것을 보장합니다:

  • 2개 이상의 스레드들이 메모리의 한 곳을 동시적으로 접근하고
  • 그 중 1개 이상이 쓰기 작업을 하며
  • 그 중 1개 이상이 동기화되지 않은

데이터 경합은 미정의 동작을 유발하므로, 안전한 러스트에서는 발생할 수 없습니다. 데이터 경합은 러스트의 소유권 규칙을 통해 대부분 방지됩니다: 가변 레퍼런스를 복제하는 것이 불가능하므로, 데이터 경합을 일으키는 것은 불가능합니다. 내부 가변성은 이 문제를 더 복잡하게 만드는데, 이것이 SendSync 트레잇이 있는 이유의 대부분을 차지합니다 (이것에 대해서는 다음 섹션에서 더 다룹니다).

하지만 안전한 러스트는 일반적인 경합 조건을 방지하지 않습니다.

이것은 스케줄러를 통제하지 않는 이상 수학적으로 불가능한데, 보통의 운영 체제 환경은 스케줄러를 통제할 수 없습니다. 프로세스 선점을 통제한다면, 일반적인 경합을 해결할 수 있습니다 - 이러한 기법은 RTIC와 같은 프레임워크에서 사용합니다. 하지만, 스케줄링을 실제로 통제하는 것은 매우 희귀한 경우입니다.

이러한 이유 때문에, 러스트에서는 데드락에 걸리거나 올바르지 않은 동기화를 가지고 무언가 말도 안 되는 짓을 하는 것을 "안전하다"고 봅니다: 이것은 일반적인 경합 조건 혹은 자원 경합으로 알려져 있습니다. 당연히 이런 프로그램은 좋지 않지만, 러스트가 모든 논리 오류를 잡을 수는 없기 마련입니다.

어떤 경우에서건, 경합 조건은 러스트 프로그램에서 그 자체만으로는 메모리 안전성을 침해할 수 없습니다. 반드시 어떤 다른 불안전한 코드와 엮여야만 경합 조건은 실제로 메모리 안전성을 해칠 수 있게 됩니다. 예를 들어, 올바른 프로그램은 이와 같습니다:

#![allow(unused)]
fn main() {
use std::thread;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

let data = vec![1, 2, 3, 4];
// 우리가 실행을 마치고 나서라도 다른 스레드에서 AtomicUsize가 저장되어 있는 메모리를
// 증가시킬 수 있도록 Arc를 사용합니다. Arc가 없으면 러스트는 프로그램 컴파일을 거부할 겁니다,
// thread::spawn의 수명 요구사항 때문이죠!
let idx = Arc::new(AtomicUsize::new(0));
let other_idx = idx.clone();

// `move`는 other_idx를 값으로 흡수합니다, 이 스레드로 옮기면서 말이죠
thread::spawn(move || {
    // idx를 변경해도 됩니다, 이 값은 원자값이므로
    // 변경해도 데이터 경합을 초래할 수 없습니다.
    other_idx.fetch_add(10, Ordering::SeqCst);
});

// 원자값에서 가져온 값으로 인덱싱합니다. 이것이 안전한 이유는 우리가 원자값 메모리를 단 한 번만
// 읽고, 그 복사값을 Vec의 인덱싱 구현에 넘겨주기 때문입니다. 이 인덱싱은 똑바로 경계가 검사될
// 것이고, 중간에 값이 변경될 가능성은 없습니다. 하지만 우리의 프로그램은 이것이 실행되기 전에
// 우리가 생성한 스레드가 이 값을 증가시켰다면 panic!할 수 있습니다. 이것은 경합 조건인데, 올바른
// 프로그램 실행은 (panic!하는 것은 올바른 경우가 매우 희귀합니다) 스레드 실행의 순서에 좌우되기
// 때문입니다.
println!("{}", data[idx.load(Ordering::SeqCst)]);
}

이 코드 대신 우리가 경계 검사를 직접 하고, 데이터를 검사받지 않은 값으로 불안전하게 접근하면 데이터 경합을 일으킬 수 있습니다:

#![allow(unused)]
fn main() {
use std::thread;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

let data = vec![1, 2, 3, 4];

let idx = Arc::new(AtomicUsize::new(0));
let other_idx = idx.clone();

// `move`는 other_idx를 값으로 흡수합니다, 이 스레드로 옮기면서 말이죠
thread::spawn(move || {
    // idx를 변경해도 됩니다, 이 값은 원자값이므로
    // 변경해도 데이터 경합을 초래할 수 없습니다.
    other_idx.fetch_add(10, Ordering::SeqCst);
});

if idx.load(Ordering::SeqCst) < data.len() {
    unsafe {
        // 경계 검사를 한 후에 idx를 올바르지 않게 가져옵니다. idx는 변했을 수도 있습니다.
        // 이것은 경합 조건이고, *위험한데*, 그 이유는 우리가 `unsafe`한 `get_unchecked`를
        // 하기로 결정했기 때문입니다.
        println!("{}", data.get_unchecked(idx.load(Ordering::SeqCst)));
    }
}
}