안전한 Rust는 모든 데이터 레이스를 제거하지만, 데드락·라이브락·잘못된 동기화에서 비롯되는 논리 버그 같은 더 넓은 의미의 레이스 컨디션까지 막아주지는 않는다.
안전한 Rust는 모든 데이터 레이스를 제거합니다. 하지만 하지 못하는 것은 더 넓은 의미의 레이스 컨디션, 즉 데드락, 라이브락, 그리고 동기화 로직의 버그를 막는 일입니다.
무슨 차이가 있을까요?
이 두 용어는 아주 자주, 심지어 숙련된 개발자들 사이에서도 서로 바꿔 써집니다. 그래서 Rust가 정확히 무엇을 보장하고 무엇은 보장하지 않는지 적어둘 가치가 있습니다.
Rustonomicon의 설명을 인용하면:
Safe Rust guarantees an absence of data races, which are defined as:
- two or more threads concurrently accessing a location of memory
- one or more of them is a write
- one or more of them is unsynchronized
세 조건은 반드시 동시에 성립해야 합니다. 모든 접근이 읽기뿐이라면 데이터 레이스가 아닙니다. 접근들이 동기화되어 있다면(예를 들어 락 뒤에 있다면) 역시 데이터 레이스가 아닙니다. 데이터 레이스는 구체적으로 말해, 적어도 한쪽이 쓰기를 수행하는 동기화되지 않은 동시 접근입니다.
이 점이 중요한 이유는 데이터 레이스가 Undefined Behavior이기 때문입니다! 데이터 레이스는 단지 “오래된” 값을 읽을 수도 있다는 뜻이 아닙니다. 컴파일러가 쓰기를 반쯤 찢어버리거나 재정렬하는 등 무엇이든 해도 된다는 뜻입니다.
그리고 이것을 우연히 잘 동작하는 무해한 경쟁 상태라고 넘길 수는 없습니다. Raph Levien이 With undefined behavior, anything is possible에서 말했듯이:
It used to be thought that data races could be classified into “benign” and dangerous categories, but research strongly suggests that the former category doesn’t exist.
즉, 모든 데이터 레이스는 실제 버그입니다! 그리고 이것은 Undefined Behavior이므로, 증상은 원인과 한참 떨어진 곳에서, 한참 뒤에, 손상된 값이나 크래시, 혹은 높은 부하에서만 드러나는 보안 취약점의 형태로 나타날 수 있습니다.
예를 들어, 여기 동일한 카운터를 증가시키는 두 스레드가 있습니다:
use std::thread;
fn main() {
let mut counter = 0;
thread::scope(|s| {
for _ in 0..2 {
s.spawn(|| {
counter += 1; // unsynchronized write to shared memory
});
}
});
}
많은 언어에서는 이에 해당하는 코드가 컴파일되고 실행되며, 두 스레드가 동시에 counter에 쓰면 값이 손상될 수 있습니다. 결과는 타이밍에 따라 달라지므로, 이 버그는 코드가 부하를 받기 전까지 드러나지 않을 수도 있습니다.
Rust에서는 아예 컴파일되지 않습니다:
error[E0499]: cannot borrow `counter` as mutable more than once at a time
--> ex1_data_race.rs:8:21
|
8 | s.spawn(|| {
| - ^^ `counter` was mutably borrowed here
| in the previous iteration of the loop
9 | counter += 1;
| ------- borrows occur due to use of `counter` in closure
borrow checker가 프로그램이 존재하기도 전에 이를 막아줍니다. 두 스레드 모두 counter에 대한 가변 참조를 원하지만, Rust의 핵심 규칙은 같은 데이터에 대해 동시에 두 개의 가변 참조를 가질 수 없다는 것입니다. 데이터 레이스는 그것이 요구하는 aliasing 자체가 불가능하기 때문에 일어날 수 없습니다.
이것이 바로 Nomicon이 말하는 핵심입니다:
Data races are prevented mostly through Rust’s ownership system alone: it’s impossible to alias a mutable reference, so it’s impossible to perform a data race.
그렇다면 두 스레드에서 카운터를 올바르게 증가시키려면 어떻게 해야 할까요? 접근을 동기화하면 됩니다. 그러면 데이터 레이스 정의에서 세 번째 조건이 사라집니다. 값을 Mutex로 감싸면 한 번에 하나의 스레드만 그 값에 접근할 수 있습니다:
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
thread::scope(|s| {
for _ in 0..2 {
s.spawn(|| {
*counter.lock().unwrap() += 1;
});
}
});
println!("{}", counter.into_inner().unwrap());
}
이 코드는 컴파일되고, 항상 2를 출력합니다.
컴파일러는 이를 두 개의 마커 트레이트, Send와 Sync를 통해 강제합니다. 대략 말해 Send는 값을 다른 스레드로 이동시킬 수 있음을 뜻하고, Sync는 참조를 통해 여러 스레드 사이에서 공유할 수 있음을 뜻합니다.
평범한 i32는 공유 참조를 통해 변경할 수 없고, 가변 참조는 스레드 사이에서 복사할 수 없습니다. 이를 공유하면서 변경하려면 내부 가변성을 제공하면서도 스레드 안전성(Sync)을 유지하는 타입이 필요하며, 바로 Mutex<i32>가 그것을 해줍니다.
Rc<T>나 RefCell<T>처럼 Sync가 아닌 것을 공유하려고 하면 컴파일 오류가 납니다.
(여기서는 스레드가 counter보다 오래 살 수 없으므로 직접 빌려 씁니다. 만약 thread::spawn처럼 스코프보다 오래 살아야 한다면, 소유권을 공유하기 위해 Arc로 감싸야 합니다: Arc<Mutex<T>>는 그런 경우의 대표적인 도구입니다.)
이것이 전체 아이디어입니다. Rust는 동시성 안전성 검사의 많은 부분을 런타임에서 타입 시스템으로 밀어 올립니다.
Mutex는 스레드 간에 가변 상태를 공유하는 표준적인 방법입니다(스레드가 생성된 스코프보다 오래 살면 Arc<Mutex<T>>).Send와 Sync 트레이트를 통해 컴파일러는 무엇을 스레드 간에 안전하게 이동하거나 공유할 수 있는지 판단합니다. 스레드에 안전하지 않은 타입은 멀티스레드 문맥에서 컴파일되지 않습니다.지금까지 우리는 데이터 레이스를 불가능하게 만들었습니다. 하지만 데이터 레이스는 동시성 버그의 한 종류일 뿐입니다. 더 넓은 범주의 개념이 _레이스 컨디션_입니다. 즉, 결과가 스레드의 타이밍이나 인터리빙에 의존하는 모든 버그를 말합니다. Rust는 이것들로부터는 여러분을 보호하지 않습니다.
다음 예제에서 코드는 공유된 은행 계좌에서 돈을 인출합니다. 꽤 무섭게 들리지만, 모든 접근에서 Mutex를 확실히 잠그므로 어디에도 데이터 레이스는 없습니다.
use std::sync::Mutex;
use std::thread;
fn main() {
// A shared account with $100 in it
let balance = Mutex::new(100);
thread::scope(|s| {
for _ in 0..2 {
s.spawn(|| {
// Is there enough money?
let can_withdraw = *balance.lock().unwrap() >= 100;
// ...
// withdraw the money, with a fresh, separate lock.
if can_withdraw {
*balance.lock().unwrap() -= 100;
}
});
}
});
println!("final balance: {}", balance.into_inner().unwrap());
}
가능한 출력 중 하나는 다음과 같습니다:
final balance: -100
하지만 출력은 실행마다 달라집니다.
두 스레드 모두 이전에 mutex를 잠그고 잔액을 확인했는데, 어떻게 최종 잔액이 음수가 될 수 있을까요?
여기에는 미묘한 문제가 있습니다. 두 스레드는 mutex를 올바르게 잠갔지만, 확인 결과를 바탕으로 행동하기 전에 락을 해제했습니다. 스레드들이 전체 시간 동안 락을 쥐고 있지 않았던 것입니다. 그래서 어느 스레드도 실제로 인출을 수행하기 전에 두 스레드 모두 인터리빙되면서 잔액을 확인해 $100을 볼 수 있고, 둘 다 진행해도 된다고 판단하게 됩니다. 그 다음에 둘 다 인출을 진행합니다. 계좌는 음수가 됩니다.
개별 접근 하나하나는 모두 동기화되어 있으므로 borrow checker는 전혀 문제 삼지 않습니다. 버그는 _확인_과 _행동_이 서로 분리된 두 개의 임계 구역이라는 점입니다. 그 사이에 세상은 바뀔 수 있습니다. 이것은 레이스 컨디션입니다(구체적으로는 TOCTOU, time-of-check-to-time-of-use 버그입니다). 어떤 타입 시스템도 이것을 대신 잡아줄 수는 없습니다. 올바름은 락이 무엇을 _의미_하도록 의도했는지에 달려 있기 때문입니다.
이 점을 이해하고 나면 수정은 간단합니다. 확인과 행동을 하나의 원자적 연산으로 만들고, 둘 모두에 걸쳐 락을 유지하면 됩니다:
let mut balance = balance.lock().unwrap();
if *balance >= 100 {
*balance -= 100;
}
이 코드가 원래 코드와 동일하다고 생각할 수 있지만, 그렇지 않습니다. lock()은 MutexGuard를 반환하며, 여기서는 그것을 즉시 버리지 않고 balance 바인딩에 유지합니다. 락은 그 가드가 살아 있는 동안 계속 유지되고, Rust의 다른 모든 값과 마찬가지로 그것은 스코프 끝까지를 뜻합니다. 따라서 이제 확인과 인출은 하나의 임계 구역 안에서 일어나며, 다른 어떤 스레드도 그 사이에 끼어들 수 없습니다. balance가 스코프를 벗어나면 Drop 구현이 자동으로 락을 해제합니다.
원래 코드에서는 각 *balance.lock().unwrap()가 임시 가드를 만들고, 그 문장이 끝나는 즉시 드롭되었습니다. 그래서 각 접근이 끝나는 순간 락이 해제되었고, 그 사이 틈으로 레이스 컨디션이 발생할 수 있었습니다.
컴파일러는 여러분이 어떤 동작을 원했는지 알 수 없습니다. Nomicon의 표현을 빌리면:
It is considered “safe” for Rust to get deadlocked or do something nonsensical with incorrect synchronization.
잘못된 락 사용이 “안전”하다면, 끝나지 않는 락 사용도 마찬가지로 “안전”합니다. 가장 단순한 예는 같은 스레드에서 같은 mutex를 두 번 잠그는 것입니다. Rust 표준 Mutex는 재진입 가능하지 않으므로, 두 번째 lock()은 결코 해제되지 않을 가드를 기다리게 됩니다.
use std::sync::Mutex;
fn main() {
let data = Mutex::new(0);
let _first = data.lock().unwrap();
println!("got the first lock");
// std's Mutex is not reentrant: this second lock waits
// forever for a guard that will never be dropped.
let _second = data.lock().unwrap();
println!("got the second lock"); // never reached
}
이 코드는 경고 하나 없이 컴파일됩니다. 실행하면:
got the first lock
[hangs forever]
첫 줄을 출력한 뒤 무기한 기다립니다. borrow checker는 여기에 대해 할 말이 없습니다. 여기에는 메모리 관점에서 unsafe한 것이 없기 때문입니다. 데드락된 프로그램은 잘못된 메모리를 읽고 있는 것이 아니라, 그저 진전을 이루지 못하고 있을 뿐입니다.
재진입 가능한 mutex라면 이미 락을 쥔 상태에서도 다시 잠글 수 있을 것입니다. 문제는 Rust의 Mutex::lock이 보호된 데이터에 대한 &mut T를 준다는 점입니다. 재잠금이 허용된다면 두 번째 lock()을 호출해서 첫 번째가 아직 살아 있는 동안 동일한 값에 대한 두 번째 &mut T를 얻게 될 수 있고, 이것은 바로 borrow checker가 막기 위해 존재하는 aliasing입니다.
그래서 Rust에서 재진입 가능한 mutex는 안전하게 &mut T가 아니라 공유 참조 &T만 내줄 수 있습니다. 그런데 이것은 그다지 유용하지 않습니다. 보통 Mutex를 쓰는 이유는 내부 값을 _변경_하기 위해서이기 때문입니다. (역사적인 이유도 있을 수 있습니다. std의 Mutex는 처음에 운영체제 기본 기능을 얇게 감싼 래퍼로 시작했고, 그중 일부는 재진입 가능하지도 않았습니다.)
정말 재진입성이 필요하다면 parking_lot::ReentrantMutex가 그것을 제공하며, &T만 내줍니다. 실제 변경을 위해서는 Cell이나 RefCell과 함께 사용합니다. 자세한 내용은 이 포럼 스레드를 보세요.
현실의 데드락은 보통 이보다 더 미묘합니다. 교과서적인 예는 두 스레드가 두 개의 락을 반대 순서로 잡아, 서로가 쥐고 있는 락을 기다리는 경우입니다. 하지만 일반적인 문제는 liveness(프로그램이 계속 진전을 이루는가)가 Rust의 안전성 보장 범위에 들어가지 않는다는 점입니다. 안전성은 잘못된 일을 하지 않는 것에 관한 것이지, 결국 올바른 일을 하게 된다는 보장은 아닙니다.
std::sync::Mutex는 재진입 가능하지 않습니다. 같은 스레드에서 두 번 잠그면 데드락이 납니다.은행 계좌 버그는 사실 Mutex의 문제라고 생각할 수도 있습니다. 락을 내려놓고 락 없는 아토믹으로 가면 문제가 사라질 것 같지만, 그렇지 않습니다. 확인 후 행동하는 함정은 락과 무관합니다. 문제는 연산의 조합에 있고, 아토믹도 똑같이 조합에서 문제가 생깁니다.
아토믹은 정의상 동기화되어 있으므로 각 개별 연산은 데이터 레이스가 없습니다. 하지만 “각 연산이 원자적이다”라는 말은 “내 연산열 전체가 원자적이다”라는 뜻이 아니며, 바로 그 틈을 방금 mutex 예제에서 본 것입니다.
여기서는 네 개의 스레드가 각각 100,000번 증가를 수행하지만, 증가는 별도의 load와 store로 나뉘어 있습니다:
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
fn main() {
let counter = AtomicU64::new(0);
thread::scope(|s| {
for _ in 0..4 {
s.spawn(|| {
for _ in 0..100_000 {
// Two independent atomic operations are not atomic together!
let current = counter.load(Ordering::SeqCst);
counter.store(current + 1, Ordering::SeqCst);
}
});
}
});
println!("expected: 400000");
println!("got: {}", counter.into_inner());
}
서로 다른 두 개의 잘못된 답이 나온 실행 예시는 다음과 같습니다:
expected: 400000
got: 305352
expected: 400000
got: 168582
모든 load와 모든 store는 올바르게 동기화된 아토믹 연산이었습니다. 데이터 레이스는 일어나지 않았습니다. 하지만 두 스레드가 같은 값을 둘 다 load하고, 둘 다 1을 더한 뒤, 둘 다 다시 store할 수 있으므로 증가 하나가 사라집니다. 이것은 다시 은행 계좌 문제입니다. 이번에는 틈이 두 개의 락 구간 사이가 아니라 두 개의 아토믹 연산 _사이_에 있을 뿐입니다. 이것은 lost update이며, 다시 말해 레이스 컨디션입니다. 1
여기서 우리는 Rust가 제공하는 가장 강한 메모리 순서인 SeqCst를 사용하고 있다는 점에 주목하세요. 그래도 버그는 발생합니다. 문제는 메모리 순서가 아니라 증가 연산이 두 개의 분리된 연산으로 쪼개져 있다는 데 있기 때문입니다.
해결책은 두 단계를 하나의 쪼갤 수 없는 연산으로 합치는 것입니다. 락에서는 두 단계 모두에 걸쳐 가드를 유지하는 것이었고, 아토믹에서는 읽기-수정-쓰기 단일 연산인 fetch_add를 쓰는 것입니다. 이 연산은 load-add-store를 한 단계로 수행합니다:
counter.fetch_add(1, Ordering::SeqCst);
이 한 가지 변경만으로 프로그램은 매번 400000을 출력합니다.
이것은 은행 계좌 예제와 똑같은 확인 후 행동의 함정이며, 여기에는 락이 전혀 없습니다. 문제는 애초에 Mutex에 있었던 것이 아닙니다.
load 다음 store는 두 개의 연산이며, 그 사이에 다른 스레드가 끼어들 수 있습니다.fetch_add 같은 연산을 사용하세요.안전한 Rust는 설계상 데이터 레이스를 제거합니다. 데이터 레이스가 있는 프로그램은 컴파일되지 않습니다. 이것은 Go의 -race나 C/C++의 ThreadSanitizer 같은 런타임 탐지기보다 더 강한 보장입니다. 그런 도구들은 테스트 실행 중 실제로 실행된 레이스만 잡을 수 있기 때문입니다.
안전한 Rust는 일반적인 의미의 레이스 컨디션은 막지 않습니다. 데드락, 라이브락, lost update, 확인 후 행동 버그는 모두 깔끔하게 컴파일되며 여전히 잘못된 결과를 만들거나 프로그램을 멈추게 할 수 있습니다.2
comparison of common C++ bugs against Rust를 정리한 Geo-ant는 이 차이를 한 줄로 요약합니다:
Rust does prevent data races and on the other hand you can still deadlock all you want.
이 구분이 중요한 이유는, 단지 용어를 엄밀하게 쓰기 위해서만이 아니라, 어디에 주의를 기울여야 하는지를 알려주기 때문입니다. 찢어진 읽기나, 빠뜨린 락 때문에 메모리가 손상되는 일은 이제 걱정하지 않아도 됩니다. 컴파일러가 그 부분은 맡아줍니다. 남는 것은 동시성에서 진짜 어려운 부분입니다. 즉, 여러분의 임계 구역이 불변식을 제대로 덮고 있는지, 락 순서가 일관적인지, 그리고 여러분이 원자적이라고 생각하는 논리 연산이 실제로도 그만큼 원자적인지를 확인하는 일입니다.
Rust는 엄청나게 많은 것을 대신 보장해주지만, 남는 부분은 여러분의 의도 안에 살아 있는 부분이며, 어떤 타입 시스템도 그것을 읽을 수는 없습니다.
이 주제의 동시성 측면을 더 깊이 파고들고 싶다면 Mara Bos의 Rust Atomics and Locks를 읽어보세요. 온라인에서 무료로 읽을 수 있습니다.
이 글에서 다룬 함정을 포함해, 여러분의 Rust 코드베이스에서 동시성을 제대로 다루고 싶으신가요?
재미있는 사실: 이 카운트는 얼마나 많은 증가가 사라졌는지를 나타냅니다. 즉, 스레드들이 인터리빙되면서 오래된 값을 읽고 서로의 진행 상황을 덮어써서 사라진 개별 증가의 총수를 뜻합니다. 따라서 첫 번째 실행에서는 94,648번의 증가가 사라졌고, 두 번째 실행에서는 231,418번이 사라졌습니다. 이는 각각 23.66%와 57.85%에 해당하며, 단지 스레드들이 어떻게 인터리빙되었는지의 타이밍 차이만으로도 엄청난 차이가 생긴다는 뜻입니다. ↩
이 글의 맥락에서는 “data race”와 “race condition”을 서로 분리된 두 개념으로 다루고 있습니다. 이는 유용한 단순화이지만 전체 그림은 아닙니다. 두 개념은 크게 겹칩니다(많은 레이스 컨디션은 데이터 레이스 때문에 발생합니다). 그러나 어느 한쪽이 다른 한쪽을 완전히 포함하지는 않습니다. 데이터 레이스 없이도 레이스 컨디션이 있을 수 있습니다(위의 은행 잔액 예제는 모든 접근을 올바르게 잠그고도 여전히 돈을 잃습니다). 어떤 정의를 따르면, 데이터 레이스는 존재하지만 관찰 가능한 프로그램 동작은 그것에 의존하지 않는 예제도 만들 수 있습니다(아무도 의존하지 않는 “계좌가 건드려졌다” 플래그를 설정하기 위해 두 스레드가 경쟁하는 경우처럼요). John Regehr의 글 Race Condition vs. Data Race를 읽어보길 권합니다. ↩