Rust 기초: Mutex와 RwLock으로 스레드 간 데이터 수정하기

Arc로 데이터를 여러 스레드에 나눠 주는 것까지는 잘됩니다. 그런데 그 데이터를 고치려고 하면 막히죠. Arc가 불변 참조만 주기 때문인데요. 단일 스레드에서라면 RefCell로 풀던 문제라, 똑같이 감싸 보면 어떨까 싶어집니다.
use std::cell::RefCell;
use std::sync::Arc;
use std::thread;
fn main() {
let counter = Arc::new(RefCell::new(0));
thread::scope(|s| {
for _ in 0..5 {
s.spawn(|| *counter.borrow_mut() += 1);
}
});
}
error[E0277]: `RefCell<i32>` cannot be shared between threads safely
--> src/main.rs:10:21
|
10 | s.spawn(|| *counter.borrow_mut() += 1);
| ----- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `RefCell<i32>` cannot be shared between threads safely
| |
| required by a bound introduced by this call
|
= help: the trait `Sync` is not implemented for `RefCell<i32>`
RefCell이 빌림을 세는 카운터에는 아무런 동기화가 없어서, 여러 스레드가 동시에 건드리면 숫자가 꼬입니다. 그래서 Sync를 구현하지 않고, 컴파일러가 미리 막아 주는 거예요.
여기서 필요한 게 Mutex입니다. 이름은 상호 배제(mutual exclusion)에서 왔는데, 말 그대로 한 번에 하나만 들여보내는 잠금 장치입니다.
Mutex는 한 번에 하나만 들여보냅니다
RefCell 자리에 Mutex를 넣으면 그대로 동작합니다.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..5 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
let mut n = counter.lock().unwrap();
*n += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("최종 값: {}", counter.lock().unwrap());
}
최종 값: 5000
다섯 스레드가 천 번씩 올려 정확히 5000이 나왔습니다. 잠금이 없었다면 읽기와 쓰기가 겹치면서 값이 새어 나갔을 텐데요.
lock()이 돌려주는 건 값이 아니라 잠금 가드(guard)입니다. 이 가드가 살아 있는 동안 잠금이 유지되고, 스코프를 벗어나 버려질 때 자동으로 풀립니다. 잠금을 푸는 코드를 따로 쓰지 않아도 되고, 중간에 패닉이 나도 새지 않아요. Drop이 정리를 책임지는 Rust다운 설계입니다.
Arc와 Mutex의 역할이 헷갈리기 쉬운데, 둘은 다른 일을 합니다. Arc는 여러 스레드가 같은 값을 가리키게 해주고, Mutex는 그 값에 한 번에 하나만 접근하도록 통제합니다. 그래서 Arc<Mutex<T>>가 한 짝으로 붙어 다닙니다.
잠금은 필요한 만큼만 붙드세요
가드가 자동으로 풀린다는 건 편하지만, 언제 풀리는지 모르면 성능 문제가 됩니다. 가드가 살아 있는 내내 다른 스레드는 전부 기다리거든요.
use std::sync::Mutex;
fn main() {
let data = Mutex::new(vec![1, 2, 3]);
// 잠금이 문장 끝에서 바로 풀립니다
let total: i32 = data.lock().unwrap().iter().sum();
println!("합계: {total}");
// 값을 복사해 오고 잠금을 먼저 풉니다
let snapshot = {
let guard = data.lock().unwrap();
guard.clone()
};
println!("복사본으로 계산: {}", snapshot.iter().sum::<i32>());
}
합계: 6
복사본으로 계산: 6
두 번째 방식이 요령입니다. 블록 안에서 값을 복사해 온 뒤 잠금을 풀고, 무거운 계산은 복사본으로 바깥에서 합니다. 계산이 오래 걸릴수록 차이가 커져요.
반대로 피해야 할 건 가드를 들고 있는 채 시간이 걸리는 일을 하는 겁니다. 네트워크 요청을 보내거나 파일을 읽는 코드를 잠금 안에 두면, 그동안 다른 스레드가 전부 멈춰 섭니다.
lock이 Result를 돌려주는 이유
예제마다 lock().unwrap()을 썼는데, 왜 Result일까요? 잠금을 잡은 스레드가 패닉하면 보호하던 데이터가 어중간한 상태로 남을 수 있기 때문입니다. Rust는 이런 잠금을 오염(poison)됐다고 표시하고, 다음 사용자에게 알려 줍니다.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let data = Arc::new(Mutex::new(vec![1, 2, 3]));
let cloned = Arc::clone(&data);
let handle = thread::spawn(move || {
let _guard = cloned.lock().unwrap();
panic!("작업 도중 실패");
});
let _ = handle.join();
match data.lock() {
Ok(v) => println!("정상 획득: {v:?}"),
Err(poisoned) => {
println!("잠금이 오염됐습니다");
println!("그래도 값은 꺼낼 수 있습니다: {:?}", poisoned.into_inner());
}
}
}
잠금이 오염됐습니다
그래도 값은 꺼낼 수 있습니다: [1, 2, 3]
오염됐다고 값을 못 쓰는 건 아닙니다. into_inner()로 꺼내 쓸 수 있어요. 다만 그 값이 온전한지는 직접 판단해야 합니다. 이 예제에서는 패닉이 데이터를 건드리기 전에 났으니 멀쩡하지만, 절반쯤 수정하다 죽었다면 이야기가 다르겠죠.
대부분의 코드에서는 unwrap()으로 넘어가도 괜찮습니다. 잠금이 오염됐다는 건 이미 다른 곳에서 심각한 문제가 났다는 뜻이라, 같이 죽는 편이 나은 경우가 많거든요.
읽기가 많다면 RwLock
Mutex는 읽기든 쓰기든 가리지 않고 하나만 들여보냅니다. 그런데 읽기끼리는 서로 방해하지 않으니 굳이 줄을 세울 이유가 없어요. 설정값처럼 어쩌다 한 번 바뀌고 계속 읽히기만 하는 데이터라면 낭비가 큽니다.
RwLock은 읽기 여럿과 쓰기 하나를 구분합니다. read()는 여러 스레드가 동시에 얻을 수 있고, write()는 혼자만 얻습니다.
use std::sync::RwLock;
use std::thread;
use std::time::{Duration, Instant};
fn main() {
let config = RwLock::new(vec!["a".to_string(), "b".to_string()]);
let start = Instant::now();
thread::scope(|s| {
let config = &config;
for id in 0..3 {
s.spawn(move || {
let guard = config.read().unwrap();
thread::sleep(Duration::from_millis(100));
println!("읽기 {id} 완료: {}ms, {} 항목",
start.elapsed().as_millis(), guard.len());
});
}
});
config.write().unwrap().push("c".to_string());
println!("최종: {:?}", config.read().unwrap());
}
읽기 2 완료: 105ms, 2 항목
읽기 0 완료: 105ms, 2 항목
읽기 1 완료: 105ms, 2 항목
최종: ["a", "b", "c"]
셋 다 105밀리초에 끝났습니다. 각자 100밀리초씩 붙들고 있었는데도 총 시간이 100밀리초 남짓이니, 동시에 돌았다는 뜻이죠.
같은 코드에서 RwLock을 Mutex로, read()를 lock()으로만 바꿔 보면 차이가 분명해집니다.
읽기 0 완료: 105ms, 2 항목
읽기 1 완료: 205ms, 2 항목
읽기 2 완료: 310ms, 2 항목
105, 205, 310밀리초로 줄을 섰습니다. 세 배 넘게 걸린 거예요. 읽기가 압도적으로 많은 데이터라면 RwLock을 고를 이유가 여기에 있습니다.
그렇다고 항상 RwLock이 낫지는 않습니다. 읽기와 쓰기를 구분해 세느라 Mutex보다 한 번의 잠금 비용이 비싸거든요. 읽기가 짧고 쓰기가 잦다면 오히려 손해입니다. 판단이 어려우면 Mutex로 시작하고, 읽기 경합이 실제로 병목이 될 때 바꾸는 순서를 권합니다.
기다리고 싶지 않다면 try_lock
lock()은 잠금을 얻을 때까지 스레드를 멈춰 세웁니다. 기다리는 대신 바로 다른 일을 하고 싶다면 try_lock()을 쓰면 됩니다.
use std::sync::Mutex;
fn main() {
let data = Mutex::new(String::from("설정"));
let guard = data.lock().unwrap();
match data.try_lock() {
Ok(_) => println!("두 번째 잠금 성공"),
Err(e) => println!("두 번째 잠금 실패: {e}"),
}
drop(guard);
match data.try_lock() {
Ok(v) => println!("잠금 성공: {v}"),
Err(e) => println!("잠금 실패: {e}"),
}
}
두 번째 잠금 실패: try_lock failed because the operation would block
잠금 성공: 설정
잠금이 잡혀 있으면 기다리지 않고 즉시 Err를 돌려줍니다. 주기적으로 상태를 확인하는 작업처럼 “지금 안 되면 다음에 하지” 하고 넘어가도 되는 자리에 어울립니다.
한 가지 주의할 점이 있는데요. 위 예제에서 같은 스레드가 이미 잡은 잠금을 다시 잡으려 했죠. try_lock이라 실패로 끝났지만, lock()이었다면 자기가 푼 잠금을 자기가 기다리는 교착 상태(deadlock)에 빠졌을 겁니다. Rust의 Mutex는 재진입을 허용하지 않으니, 같은 데이터를 두 번 잠그는 코드가 없는지 살펴야 합니다.
마치며
여러 스레드가 같은 데이터를 고쳐야 한다면 Arc<Mutex<T>>가 출발점입니다. Arc가 공유를, Mutex가 통제를 맡는 조합이죠. 읽기가 훨씬 많은 데이터라면 RwLock으로 바꿔 읽기끼리 동시에 통과시키면 되고요.
다만 잠금이 언제나 정답은 아닙니다. 정수 하나를 세는 정도라면 원자적 타입이 훨씬 가볍고, 한 번 정해지면 바뀌지 않는 전역 값이라면 LazyLock과 OnceLock이 더 알맞습니다. 잠금은 여러 필드를 묶어 일관성을 지켜야 할 때 빛을 봅니다.
비동기 코드에서는 또 이야기가 달라집니다. std::sync::Mutex는 잠금을 기다리며 스레드를 멈추기 때문에 async 함수 안에서 쓰면 런타임 전체가 막힐 수 있는데요. tokio::sync의 채널과 동기화 도구에서 다루는 tokio::sync::Mutex처럼 기다리는 동안 다른 작업에 자리를 내주는 잠금을 써야 하니, 비동기로 넘어가실 때 꼭 확인해 보세요.
더 자세한 내용은 std::sync::Mutex - Rust 공식 문서를 참고하세요.
This work is licensed under CC BY 4.0