컴파일 타임에 데이터 레이스를 막는 법
Rust 공식 문서와 발표 자료는 동시성을 다룰 때 "fearless concurrency"라는 표현을 자주 씁니다. 마케팅 문구처럼 들리지만, 실제로 근거가 있는 표현입니다. 데이터 레이스(data race) — 두 스레드가 동기화 없이 같은 메모리를 동시에 읽고 쓰는 상황 — 는 지금까지 이 책에서 다룬 소유권·빌림 규칙만으로 컴파일 타임에 대부분 걸러집니다. 별도의 동시성 전용 규칙을 새로 배우는 게 아니라, 이미 배운 규칙이 스레드 경계에서도 그대로 적용되는 겁니다.
스레드에 값을 넘기면 소유권도 넘어간다
use std::thread;
fn main() {
let data = vec![1, 2, 3, 4, 5];
let handle = thread::spawn(move || {
let sum: i32 = data.iter().sum();
println!("스레드 안 합계: {sum}");
});
handle.join().unwrap();
}move 클로저는 data의 소유권을 새 스레드로 완전히 옮깁니다. 옮긴 다음에는 main 스레드에서 data를 다시 쓸 수 없습니다 — 이건 새로운 규칙이 아니라 2챕터에서 배운 move 규칙 그대로입니다. 다른 언어라면 "두 스레드가 같은 데이터를 동시에 건드리면 안 되니 락을 걸어야 한다"는 걸 개발자가 기억해야 하는데, Rust에서는 애초에 두 스레드가 같은 데이터를 동시에 들고 있는 코드 자체가 컴파일되지 않습니다.
move를 빼면 어떻게 될까요.
use std::thread;
fn main() {
let data = vec![1, 2, 3, 4, 5];
let handle = thread::spawn(|| {
println!("{:?}", data); // 에러: data가 얼마나 오래 살지 보장 안 됨
});
handle.join().unwrap();
}error[E0373]: closure may outlive the current function, but it borrows
`data`, which is owned by the current function새 스레드는 main 함수보다 더 오래 살 수도 있습니다. 클로저가 data를 참조로만 빌리면, main이 먼저 끝나서 data가 drop된 뒤에도 스레드가 그 참조를 쓰려고 할 가능성을 배제할 수 없습니다. 그래서 컴파일러는 move로 소유권을 완전히 넘기라고 요구합니다.
여러 스레드가 값을 공유해야 한다면
소유권을 하나의 스레드에 완전히 넘기는 것만으로는 부족한 경우가 많습니다. 여러 스레드가 같은 카운터를 함께 늘려야 하는 상황을 봅시다.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("최종 값: {}", *counter.lock().unwrap());
}Arc<T>는 앞 챕터의 Rc<T>와 하는 일이 같습니다 — 참조 카운트로 여러 소유자를 관리합니다. 차이는 Arc(Atomically Reference Counted)는 카운트 증감이 원자적이라 여러 스레드에서 동시에 써도 안전하다는 점뿐입니다. Mutex<T>는 앞 챕터의 RefCell<T>와 역할이 비슷합니다 — 내부 가변성을 제공하되, 한 번에 한 스레드만 .lock()을 통해 값에 접근하도록 강제합니다. Rc<RefCell<T>>가 단일 스레드용 조합이라면, Arc<Mutex<T>>는 그 멀티스레드 버전이라고 보면 됩니다.
컴파일러가 실수를 막아주는 지점
여기서 진짜 흥미로운 부분은, Arc::clone 없이 counter를 그대로 여러 클로저에서 쓰려고 하면 무슨 일이 일어나는지입니다.
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
let mut handles = vec![];
for _ in 0..10 {
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap(); // 에러
*num += 1;
});
handles.push(handle);
}
// ...
}두 번째 반복에서 counter는 이미 첫 번째 클로저로 옮겨졌으므로 컴파일이 안 됩니다. 자바나 C++였다면 이 코드는 아무 경고 없이 컴파일되고, 실행했을 때 운이 나쁜 타이밍에만 카운터 값이 틀리게 나오는 데이터 레이스가 됩니다. 이런 버그는 로컬 테스트에서는 안 나타나다가 운영 환경의 부하 아래에서만, 그것도 재현이 거의 불가능한 형태로 나타나는 경우가 많습니다. Rust는 그 코드 자체를 애초에 짤 수 없게 만듭니다.
sequenceDiagram
participant M as main 스레드
participant T1 as 스레드 1
participant T2 as 스레드 2
M->>T1: Arc::clone(&counter) 전달
M->>T2: Arc::clone(&counter) 전달
T1->>T1: lock() 획득, +1, lock 해제
T2->>T2: lock() 대기 후 획득, +1, lock 해제
T1-->>M: join()
T2-->>M: join()Send와 Sync라는 두 트레잇이 이 모든 검증의 배후에 있습니다. Send는 "이 타입의 소유권을 다른 스레드로 넘겨도 안전하다"는 표시이고, Sync는 "이 타입의 참조를 여러 스레드가 동시에 들고 있어도 안전하다"는 표시입니다. 대부분의 표준 타입은 컴파일러가 자동으로 이 트레잇을 부여하고, Rc처럼 스레드 안전성이 없는 타입에는 일부러 부여하지 않습니다. 그래서 Rc를 스레드 경계 너머로 넘기려고 하면 "Rc<T>는 Send가 아니다"라는 에러가 나고, 이게 바로 "이 타입은 애초에 멀티스레드용으로 설계되지 않았다"는 컴파일러의 경고입니다. 실무에서 이 에러를 만나면 대부분 Rc를 Arc로 바꾸면 해결됩니다 — 다만 원자적 연산은 일반 연산보다 비용이 조금 더 들기 때문에, 애초에 스레드를 넘나들 일이 없는 값이라면 굳이 Arc를 쓸 이유는 없습니다.