공유 상태 동시성
Mutex로 공유 데이터 접근을 직렬화하고 Arc의 원자적 참조 카운팅을 결합해 여러 스레드에서 상태를 안전하게 갱신합니다.
메시지 전달은 동시성을 다루는 좋은 방법이지만 유일한 방법은 아닙니다. 다른 방법은 여러 스레드가 같은 공유 데이터에 접근하는 것입니다. Go 언어 문서의 구호 가운데 메모리를 공유해서 소통하지 말라는 부분을 다시 생각해 봅니다.
메모리를 공유해서 소통하는 것은 어떤 모습일까요? 그리고 메시지 전달을 좋아하는 사람들은 왜 메모리 공유를 쓰지 말라고 조심시킬까요?
어떤 면에서 모든 프로그래밍 언어의 채널은 단일 소유권과 비슷합니다. 값을 채널로 보내고 나면 그 값을 더는 쓰지 않아야 하기 때문입니다. 공유 메모리 동시성은 다중 소유권과 비슷합니다. 여러 스레드가 같은 메모리 위치에 동시에 접근할 수 있습니다. 스마트 포인터가 다중 소유권을 가능하게 하는 것을 보았듯, 다중 소유권은 서로 다른 소유자를 관리해야 하므로 복잡성을 더할 수 있습니다. 러스트의 타입 시스템과 소유권 규칙은 이 관리를 올바르게 하는 데 크게 도움이 됩니다. 예로 공유 메모리에 흔히 쓰이는 동시성 기본 도구인 뮤텍스(mutex)를 봅니다.
뮤텍스를 사용하여 한 번에 한 스레드에서의 데이터 접근을 허용하기
뮤텍스는 상호 배제(mutual exclusion)의 줄임말로, 어느 시점에든 한 스레드만 어떤 데이터에 접근할 수 있게 합니다. 뮤텍스 안의 데이터에 접근하려면, 스레드는 먼저 뮤텍스의 락(lock)을 얻겠다고 요청해 접근하고 싶다는 신호를 보내야 합니다. 락은 뮤텍스의 일부인 자료 구조로, 지금 누가 데이터에 배타적으로 접근하고 있는지 추적합니다. 그래서 뮤텍스는 락 시스템으로 자신이 가진 데이터를 보호한다고 말합니다.
뮤텍스는 두 규칙을 기억해야 하므로 쓰기 어렵다는 평이 있습니다.
| 규칙 |
|---|
| 데이터를 쓰기 전에 락을 얻으려고 시도해야 합니다. |
| 뮤텍스가 보호하는 데이터를 다 쓰고 나면, 다른 스레드가 락을 얻을 수 있도록 데이터의 락을 풀어야 합니다. |
뮤텍스를 현실에 비유하자면, 마이크가 하나뿐인 학회의 패널 토론을 떠올려 보세요. 패널이 말하려면 먼저 마이크를 쓰고 싶다고 요청하거나 신호를 보내야 합니다. 마이크를 받으면 원하는 만큼 말한 뒤, 말하고 싶은 다음 패널에게 마이크를 넘깁니다. 패널이 말을 마치고 마이크를 넘기는 것을 잊으면, 다른 누구도 말할 수 없습니다. 공유 마이크의 관리가 잘못되면 패널 토론은 계획대로 진행되지 않습니다.
뮤텍스를 제대로 관리하는 것은 믿기 어려울 만큼 까다로울 수 있으며, 그래서 많은 사람이 채널을 좋아합니다. 하지만 러스트의 타입 시스템과 소유권 규칙 덕분에 락을 얻고 푸는 것을 틀릴 수 없습니다.
Mutex<T>의 API
뮤텍스를 어떻게 쓰는지 보는 예로, 다음 코드처럼 단일 스레드 상황에서 뮤텍스를 써 봅니다. 여러 스레드에 나누기 전에 API부터 익히려는 것이며, 아래 코드는 안쪽 스코프에서 락을 얻어 값을 바꾼 뒤 스코프 밖에서 m을 출력합니다.
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
}
println!("m = {m:?}");
}많은 타입처럼 Mutex<T>는 연관 함수 new로 만듭니다. 뮤텍스 안의 데이터에 접근하려면 lock 메서드로 락을 얻습니다. 이 호출은 현재 스레드를 막으므로, 우리 차례가 되어 락을 얻기 전까지 아무 일도 할 수 없습니다.
락을 쥔 다른 스레드가 패닉을 냈다면 lock 호출은 락을 얻은 뒤에도 Err를 돌려주어, 데이터가 일관되지 않을 수 있다고 알립니다. 여기서는 그런 상황을 따로 다루지 않고 unwrap을 써서 이 스레드도 패닉을 내도록 했습니다.
락을 얻은 뒤에는, 이 경우 num이라는 이름의 반환값을 안쪽 데이터의 가변 참조처럼 다룰 수 있습니다. 타입 시스템은 m 안의 값을 쓰기 전에 락을 얻도록 보장합니다. m의 타입은 i32가 아니라 Mutex<i32>이므로, i32 값을 쓰려면 lock을 불러야 합니다. 잊을 수가 없습니다. 그렇지 않으면 타입 시스템이 안쪽의 i32에 접근하게 해 주지 않습니다.
lock 호출은 MutexGuard라는 타입을 돌려주며, unwrap으로 다루는 LockResult에 감싸여 있습니다. MutexGuard 타입은 안쪽 데이터를 가리키도록 Deref를 구현합니다. 또 MutexGuard가 스코프를 벗어나면 락을 자동으로 푸는 Drop 구현이 있으며, 이 예에서는 안쪽 스코프가 끝날 때 그렇게 됩니다. 그래서 락을 풀지 않아 다른 스레드가 뮤텍스를 쓰지 못하게 막는 실수를 할 위험이 없습니다. 락이 자동으로 풀리기 때문입니다.
락이 풀린 뒤에는 뮤텍스의 값을 출력해, 안쪽의 i32를 6으로 바꿀 수 있었다는 것을 확인할 수 있습니다.
m = Mutex { data: 6, poisoned: false, .. }아래 그림은 여러 스레드가 Arc로 같은 Mutex를 함께 가지며 락을 얻고 푸는 과정을 보여 줍니다. 그림의 예제 15-12는 방금 본 단일 스레드 예제이고, 예제 15-15는 이 글의 마지막에 볼 공유 카운터 예제입니다.
Arc·Mutex 공유 소유권과 락 수명
Arc 소유 이동→lock→guard 임계 구역 증가→drop unlock→join→최종 10 관측의 자원/카운터 상태이다. panic poison 가능/복구와 데드락 경계를 보존한다.
SEQUENCE · OWNERSHIP PLUS EXCLUSION
Arc는 소유권을 공유하고 MutexGuard는 배타 접근을 유지한다
Arc<T>는 원자적 공유 소유권을, Mutex<T>는 MutexGuard를 통한 배타 변경을 제공한다. guard가 drop되면 잠금이 풀리고 join 뒤 최종 값을 관찰할 수 있다.
- 공유 소유권
Arc::clone은 원자적 참조 카운트 소유자를 만들어 작업 스레드로 보냅니다.
- 배타 접근
Mutex::lock은 MutexGuard를 돌려주고 guard가 살아 있는 동안만 T를 변경합니다.
- 해제와 관찰
guard drop이 unlock하고 JoinHandle 합류 뒤 최종 값을 읽습니다.
- Poison
panic 중 guard가 풀리면 poisoning 신호가 남지만 LockResult를 통해 회복을 선택할 수 있습니다.
- 경계
Arc는 payload 동기화가 아니며 RAII도 deadlock을 막지 않습니다.
본문 예제 15-12 · 한 스레드의 RAII
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
} // num drop → unlock내부 scope가 끝나면 guard가 사라지고 잠금이 해제됩니다. 이 예에서 보호된 값은 6입니다. Mutex가 import된 코드의 핵심입니다.
본문 예제 15-15 · 공유 카운터
Arc::clone으로 10개의 작업 스레드가 같은 Mutex<i32>를 소유합니다. 각 스레드는 lock → 1 증가 → guard drop을 수행합니다. 모두 정상 종료하여 join한 뒤 읽으면 Result: 10입니다.
Arc가 payload를 자동으로 thread-safe하게 만들지는 않으며 구현 경계는 T의 Send·Sync 조건을 따른다. poisoning은 LockResult로 회복 가능한 advisory 신호이고 RAII도 deadlock이나 과도한 임계 구역을 막지 않는다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 원래 가로 도표의 4참여자에 대한 clone/move/lock/guard 반환/변경 5메시지와 ALT 정상 해제/보유 중 panic의 선택을 보존한다. 세로 도표의 첫 5쌍은 같은 참여자를 반복 표현하며 새 10개 스레드가 아니다. 아래 두 해제 쌍은 서로 대안이고 둘 다 순차 실행하라는 뜻이 아니다. 정상 guard drop과 unwind 중 guard 정리가 unlock하며 poison은 가능한 advisory 신호다. abort 시 정리를 보장하지 않는다. 원래 가로 도표의 정상 unlock 간선 1개와 panic ALT 설명 속 암묵적 해제 관계를 각각 표현하여 세로 도표에는 총 7화살표가 있다. join은 최종 읽기의 별도 조건이고 Arc 자체는 payload 동기화가 아니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| Arc 공유 스레드별 Arc가 Mutex 카운터를 공유한다. |
|
|
| guard 갱신 lock guard로 카운터를 증가시키고 drop한다. |
|
|
| join 후 관측 정상적으로 10개 스레드가 완료된 뒤 결과를 읽는다. |
|
|
Arc·Mutex 공유 소유권과 락 수명
Arc 소유 이동→lock→guard 임계 구역 증가→drop unlock→join→최종 10 관측의 자원/카운터 상태이다. panic poison 가능/복구와 데드락 경계를 보존한다.
SEQUENCE · OWNERSHIP PLUS EXCLUSION
Arc는 소유권을 공유하고 MutexGuard는 배타 접근을 유지한다
Arc<T>는 원자적 공유 소유권을, Mutex<T>는 MutexGuard를 통한 배타 변경을 제공한다. guard가 drop되면 잠금이 풀리고 join 뒤 최종 값을 관찰할 수 있다.
- 공유 소유권
Arc::clone은 원자적 참조 카운트 소유자를 만들어 작업 스레드로 보냅니다.
- 배타 접근
Mutex::lock은 MutexGuard를 돌려주고 guard가 살아 있는 동안만 T를 변경합니다.
- 해제와 관찰
guard drop이 unlock하고 JoinHandle 합류 뒤 최종 값을 읽습니다.
- Poison
panic 중 guard가 풀리면 poisoning 신호가 남지만 LockResult를 통해 회복을 선택할 수 있습니다.
- 경계
Arc는 payload 동기화가 아니며 RAII도 deadlock을 막지 않습니다.
본문 예제 15-12 · 한 스레드의 RAII
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
} // num drop → unlock내부 scope가 끝나면 guard가 사라지고 잠금이 해제됩니다. 이 예에서 보호된 값은 6입니다. Mutex가 import된 코드의 핵심입니다.
본문 예제 15-15 · 공유 카운터
Arc::clone으로 10개의 작업 스레드가 같은 Mutex<i32>를 소유합니다. 각 스레드는 lock → 1 증가 → guard drop을 수행합니다. 모두 정상 종료하여 join한 뒤 읽으면 Result: 10입니다.
Arc가 payload를 자동으로 thread-safe하게 만들지는 않으며 구현 경계는 T의 Send·Sync 조건을 따른다. poisoning은 LockResult로 회복 가능한 advisory 신호이고 RAII도 deadlock이나 과도한 임계 구역을 막지 않는다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 원래 가로 도표의 4참여자에 대한 clone/move/lock/guard 반환/변경 5메시지와 ALT 정상 해제/보유 중 panic의 선택을 보존한다. 세로 도표의 첫 5쌍은 같은 참여자를 반복 표현하며 새 10개 스레드가 아니다. 아래 두 해제 쌍은 서로 대안이고 둘 다 순차 실행하라는 뜻이 아니다. 정상 guard drop과 unwind 중 guard 정리가 unlock하며 poison은 가능한 advisory 신호다. abort 시 정리를 보장하지 않는다. 원래 가로 도표의 정상 unlock 간선 1개와 panic ALT 설명 속 암묵적 해제 관계를 각각 표현하여 세로 도표에는 총 7화살표가 있다. join은 최종 읽기의 별도 조건이고 Arc 자체는 payload 동기화가 아니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| Arc 공유 스레드별 Arc가 Mutex 카운터를 공유한다. |
|
|
| guard 갱신 lock guard로 카운터를 증가시키고 drop한다. |
|
|
| join 후 관측 정상적으로 10개 스레드가 완료된 뒤 결과를 읽는다. |
|
|
Arc<T>는 원자적인 공유 소유권을, Mutex<T>는 MutexGuard를 통한 배타적인 변경을 제공합니다. 메인 스레드가 Arc::clone으로 소유자를 만들어 작업 스레드로 옮기면, 작업 스레드는 lock으로 MutexGuard를 받아 그것이 살아 있는 동안만 안쪽 T를 바꿉니다. 정상적으로 끝나면 가드가 버려지며 락이 풀립니다. 락을 쥔 채 패닉이 나면 되감기(unwind) 과정에서 가드가 정리되며 락이 풀리지만, 뮤텍스에 오염(poisoning) 표시가 남을 수 있습니다. 오염은 데이터가 일관되지 않을 수 있다는 알림일 뿐이며, LockResult의 PoisonError에서 가드를 되찾아 데이터를 점검한 뒤 계속 쓸 수도 있습니다. 최종 값은 모든 작업 스레드에 join한 뒤 읽습니다. Arc 자체가 안쪽 데이터를 동기화해 주지는 않으며, 가드가 락을 자동으로 풀어 준다고 교착 상태나 지나치게 긴 임계 구역까지 막아 주지는 않습니다.
여러 스레드 사이에서 Mutex<T> 공유하기
이제 Mutex<T>로 여러 스레드 사이에서 값을 공유해 봅니다. 스레드 10개를 만들어 각 스레드가 카운터 값을 1씩 늘리게 해, 카운터가 0에서 10이 되게 합니다. 다음 코드는 컴파일 오류가 나며, 그 오류로 Mutex<T>를 쓰는 방법과 러스트가 그것을 올바르게 쓰도록 어떻게 돕는지 더 배웁니다.
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);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}앞의 코드처럼 Mutex<T> 안에 i32를 담을 counter 변수를 만듭니다. 그다음 숫자 범위를 돌며 스레드 10개를 만듭니다. thread::spawn을 쓰고 모든 스레드에 같은 클로저를 줍니다. 이 클로저는 카운터를 스레드 안으로 옮기고, lock 메서드를 불러 Mutex<T>의 락을 얻은 뒤, 뮤텍스 안의 값에 1을 더합니다. 스레드가 클로저 실행을 마치면 num이 스코프를 벗어나며 락을 풀어, 다른 스레드가 락을 얻을 수 있게 합니다.
메인 스레드에서는 모든 join 핸들을 모읍니다. 그다음 스레드 동시 실행에서 했듯 각 핸들에 join을 불러 모든 스레드가 끝나게 합니다. 그 시점에 메인 스레드가 락을 얻어 이 프로그램의 결과를 출력합니다. 이 예제는 컴파일되지 않는다고 했습니다. 왜 그런지 봅니다.
error[E0382]: borrow of moved value: `counter`
--> src/main.rs:21:29
|
5 | let counter = Mutex::new(0);
| ------- move occurs because `counter` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
8 | for _ in 0..10 {
| -------------- inside of this loop
9 | let handle = thread::spawn(move || {
| ------- value moved into closure here, in previous iteration of loop
...
21 | println!("Result: {}", *counter.lock().unwrap());
| ^^^^^^^ value borrowed here after move
For more information about this error, try `rustc --explain E0382`.오류 메시지는 counter 값이 루프의 이전 반복에서 클로저 안으로 옮겨졌다고 말합니다. 러스트는 락의 소유권인 counter를 여러 스레드로 옮길 수 없다고 알려 줍니다. Rc<T>, 참조 카운트 스마트 포인터에서 이야기한 다중 소유권 방법으로 이 컴파일 오류를 고쳐 봅니다.
복수 스레드와 함께하는 복수 소유권
Rc<T>, 참조 카운트 스마트 포인터에서는 스마트 포인터 Rc<T>로 참조 카운트 값을 만들어 값에 여러 소유자를 두었습니다. 여기서도 같은 일을 해 보고 무슨 일이 일어나는지 봅니다. 다음 코드에서는 Mutex<T>를 Rc<T>로 감싸고, 소유권을 스레드로 옮기기 전에 Rc<T>를 클론합니다.
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Rc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Rc::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!("Result: {}", *counter.lock().unwrap());
}다시 컴파일하면, 이번에는 다른 오류가 납니다. 컴파일러가 많은 것을 가르쳐 줍니다.
error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
--> src/main.rs:11:36
|
11 | let handle = thread::spawn(move || {
| ------------- ^------
| | |
| ______________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
| | |
| | required by a bound introduced by this call
12 | | let mut num = counter.lock().unwrap();
13 | |
14 | | *num += 1;
15 | | });
| |_________^ `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely
|
= help: within `{closure@src/main.rs:11:36: 11:43}`, the trait `Send` is not implemented for `Rc<std::sync::Mutex<i32>>`
note: required because it's used within this closure
--> src/main.rs:11:36
|
11 | let handle = thread::spawn(move || {
| ^^^^^^^
note: required by a bound in `spawn`
--생략--마지막 note가 가리키는 표준 라이브러리의 경로는 컴파일러 버전마다 달라 생략했습니다. 이 오류 메시지는 길지만 중요한 부분은 `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely입니다. 컴파일러는 그 까닭도 알려 줍니다. the trait `Send` is not implemented for `Rc<std::sync::Mutex<i32>>`입니다. Send는 다음 글에서 이야기합니다. Send는 스레드에서 쓰는 타입이 동시성 상황에서 쓰이기에 알맞다는 것을 보장하는 트레이트 가운데 하나입니다.
아쉽게도 Rc<T>는 스레드 사이에서 함께 쓰기에 안전하지 않습니다. Rc<T>가 참조 카운트를 관리할 때, clone을 부를 때마다 카운트를 더하고 클론이 버려질 때마다 카운트를 뺍니다. 하지만 카운트를 바꾸는 일이 다른 스레드에게 방해받지 않도록 보장하는 동시성 기본 도구를 쓰지 않습니다. 그러면 카운트가 틀려질 수 있고, 그 결과 메모리 누수가 생기거나 값을 다 쓰기 전에 버리는 미묘한 버그가 생길 수 있습니다. 필요한 것은 Rc<T>와 똑같지만, 참조 카운트를 스레드 안전하게 바꾸는 타입입니다.
Arc<T>를 이용한 아토믹 참조 카운팅
다행히 Arc<T>가 바로 Rc<T>와 같은 타입이면서 동시성 상황에서 쓰기에 안전합니다. a는 원자적(atomic)이라는 뜻이므로, 원자적으로 참조를 세는(atomically reference-counted) 타입이라는 뜻입니다. 원자적인 타입은 여기서 자세히 다루지 않을 또 다른 종류의 동시성 기본 도구입니다. 더 자세한 내용은 std::sync::atomic의 표준 라이브러리 문서를 보세요. 지금은 원자적인 타입이 기본 타입처럼 동작하지만 스레드 사이에서 함께 쓰기에 안전하다는 것만 알면 됩니다.
그렇다면 왜 모든 기본 타입이 원자적이지 않은지, 왜 표준 라이브러리의 타입이 기본으로 Arc<T>를 쓰도록 구현되지 않았는지 궁금할 수 있습니다. 까닭은 스레드 안전성에 성능 비용이 따르기 때문이며, 정말 필요할 때만 그 비용을 치르고 싶을 것입니다. 단일 스레드 안에서 값에 연산을 할 뿐이라면, 원자적인 타입이 제공하는 보장을 지키지 않아도 되므로 코드가 더 빨리 실행될 수 있습니다.
예제로 돌아갑니다. Arc<T>와 Rc<T>는 API가 같으므로, use 줄과 new 호출, clone 호출을 바꿔 프로그램을 고칩니다. 다음 코드는 마침내 컴파일되고 실행됩니다.
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!("Result: {}", *counter.lock().unwrap());
}Result: 10해냈습니다. 0부터 10까지 셌습니다. 그리 대단해 보이지 않을 수 있지만, Mutex<T>와 스레드 안전성에 대해 많은 것을 배웠습니다. 이 프로그램의 구조로 카운터를 늘리는 것보다 더 복잡한 연산도 할 수 있습니다. 이 전략으로 계산을 독립적인 부분으로 나누고, 그 부분들을 스레드에 나눠 맡기고, Mutex<T>로 각 스레드가 자기 부분으로 최종 결과를 갱신하게 할 수 있습니다.
간단한 수치 연산을 한다면, 표준 라이브러리의 std::sync::atomic 모듈이 제공하는 Mutex<T>보다 단순한 타입도 있습니다. 이 타입들은 기본 타입에 대해 안전하고 동시적인 원자적 접근을 제공합니다. 이 예제에서는 Mutex<T>가 어떻게 동작하는지에 집중하려고 기본 타입에 Mutex<T>를 썼습니다.
RefCell<T>/Rc<T>와 Mutex<T>/Arc<T> 간의 유사성
counter가 불변인데도 그 안의 값의 가변 참조를 얻을 수 있었다는 점을 눈여겨보았을 것입니다. 곧 Mutex<T>는 Cell 계열처럼 내부 가변성을 제공합니다. RefCell<T>와 내부 가변성 패턴에서 RefCell<T>로 Rc<T> 안의 내용을 바꿀 수 있게 했듯, Mutex<T>로 Arc<T> 안의 내용을 바꿉니다.
또 하나 눈여겨볼 점은, Mutex<T>를 쓸 때 러스트가 모든 종류의 논리 오류로부터 지켜 주지는 못한다는 것입니다. Rc<T>, 참조 카운트 스마트 포인터에서 Rc<T>를 쓰면 두 Rc<T> 값이 서로를 가리키는 순환 참조를 만들어 메모리 누수를 일으킬 위험이 있었습니다. 마찬가지로 Mutex<T>에는 교착 상태(deadlock)를 만들 위험이 있습니다. 교착 상태는 어떤 연산에 자원 두 개의 락이 필요한데 두 스레드가 각각 락 하나씩을 얻어, 서로를 영원히 기다리게 될 때 생깁니다. 교착 상태에 관심이 있다면 교착 상태가 생기는 러스트 프로그램을 만들어 보고, 운영체제 교재의 데드락 예방과 회피에서 다루는 잠금 순서 정하기 같은 전략을 러스트로 구현해 보세요. Mutex<T>와 MutexGuard의 표준 라이브러리 API 문서에 쓸모 있는 정보가 있습니다.
이번 장의 마지막으로 Send와 Sync 트레이트와, 그것들을 직접 만든 타입에 쓰는 방법을 이야기합니다.
더 깊이: 뮤텍스 오염과 표준 Mutex의 구현
Mutex 문서는 락을 쥔 스레드가 패닉을 냈다는 것을 알아챈 뮤텍스를 오염(poisoned)되었다고 부릅니다. 오염된 뒤에는 lock과 try_lock이 Err를 돌려주어, 불변 조건이 깨졌을지 모르는 데이터를 다른 스레드가 모르는 채 쓰지 않게 합니다. 대부분의 코드가 lock().unwrap()으로 쓰는 것은 이 오류를 패닉으로 바꿔 스레드 사이로 퍼뜨리는 셈입니다. 다만 오염은 권고일 뿐이어서 PoisonError의 into_inner로 원래 받았을 가드를 꺼내 데이터에 접근할 수 있고, 데이터를 올바른 값으로 되돌렸다면 clear_poison으로 오염 표시를 지울 수 있습니다.
같은 스레드가 이미 쥔 락을 다시 lock하는 것은 막아 주지 않습니다. 문서는 이때의 동작을 정하지 않고 두 번째 호출이 돌아오지 않는다는 것만 밝히며, 패닉이나 교착 상태를 그 예로 듭니다. 그래서 가드를 쥔 채 같은 뮤텍스를 다시 잠그는 함수를 부르지 않도록, 가드가 살아 있는 범위를 짧게 유지합니다.
구현은 플랫폼마다 다르며 버전에 따라 바뀌었습니다. Rust 1.62 발표에 따르면 그 전까지 Linux의 Mutex, Condvar, RwLock은 pthreads 라이브러리를 썼는데, pthreads 뮤텍스는 40바이트이고 옮길 수 없어서 뮤텍스마다 Box로 따로 할당해야 했습니다. 1.62부터 표준 라이브러리는 Linux에서 futex를 바탕으로 한 구현을 직접 갖추었고, 발표 당시 Linux에서 Mutex의 내부 상태는 5바이트였습니다.
정리
Mutex<T>는 한 번에 한 스레드만 안쪽 데이터에 접근하게 하며,lock()으로 락을 얻어 받은MutexGuard를 가변 참조처럼 씁니다.MutexGuard가 스코프를 벗어나면Drop구현이 락을 자동으로 풀어 주므로, 락을 푸는 것을 잊을 수 없습니다.Mutex<T>를 여러 스레드로 옮기려면 다중 소유권이 필요하지만,Rc<T>는Send가 아니어서 E0277 오류가 납니다.Arc<T>는 참조 카운트를 원자적으로 바꾸는Rc<T>이며,Arc<Mutex<T>>로 여러 스레드가 같은 값을 안전하게 바꿀 수 있습니다.- 락을 쥔 스레드가 패닉을 내면 뮤텍스가 오염될 수 있고,
Mutex<T>를 써도 교착 상태 같은 논리 오류는 막아 주지 않습니다.
연습 문제
1. 락을 얼마나 오래 쥘까
다음 작업 스레드는 락을 쥔 채 1초 동안 잡니다. 스레드 10개로 실행하면 대략 얼마나 걸리고, 어떻게 고치면 빨라지나요?
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
thread::sleep(Duration::from_secs(1));
*num += 1;
});해설 보기
num이 클로저가 끝날 때까지 살아 있으므로, 각 스레드가 락을 쥔 채 1초를 자고, 나머지 스레드는 그동안 기다립니다. 그래서 스레드 10개가 차례로 실행되어 대략 10초가 걸립니다. 락이 필요 없는 일은 락 바깥에서 하고, 값을 바꾸는 부분만 { let mut num = counter.lock().unwrap(); *num += 1; }처럼 짧은 블록에 넣으면 잠자는 일이 동시에 일어나 1초 남짓이면 끝납니다.
2. 왜 Arc만으로는 안 될까
let counter = Arc::new(0);으로 만들고 각 스레드에서 *counter += 1;을 하려 하면 컴파일되지 않습니다. 까닭은 무엇이고, Mutex 말고 어떤 방법이 있나요?
해설 보기
Arc<T>는 Rc<T>처럼 공유된 값에 대한 불변 참조만 주므로, 안쪽 값을 바꿀 수 없습니다. 공유 소유권과 변경은 별개의 문제이므로, 바꾸려면 Mutex<T> 같은 내부 가변성을 함께 써야 합니다. 정수 하나를 늘리는 정도라면 Arc<AtomicUsize>처럼 std::sync::atomic의 원자적 타입을 쓰고 fetch_add(1, Ordering::SeqCst)로 늘릴 수도 있습니다.
다음 글 Sync와 Send 트레이트를 이용한 확장 가능한 동시성에서는 어떤 타입을 스레드 사이에서 옮기거나 함께 쓸 수 있는지 정하는 Send와 Sync 트레이트를 살펴봅니다.