공유 상태 동시성
메시지 전달은 동시성을 다루는 훌륭한 방법이지만, 유일한 방법은 아닙니다. 또 다른 방식은 여러 스레드가 같은 공유 데이터에 접근하는 것입니다. Go 언어 문서의 구호 일부를 다시 떠올려 보세요. “공유 메모리로 통신하지 말라.”
그렇다면 공유 메모리로 통신하는 것은 실제로 어떤 모습일까요? 그리고 메시지 전달을 선호하는 사람들은 왜 메모리 공유를 피하라고 할까요?
어떤 의미에서, 어떤 언어의 채널이든 단일 소유권과 비슷합니다. 값을 채널로 보낸 뒤에는 그 값을 더 이상 사용하지 않아야 하기 때문입니다. 반면 공유 메모리 동시성은 다중 소유권과 비슷합니다. 여러 스레드가 같은 메모리 위치에 동시에 접근할 수 있기 때문입니다. 15장에서 스마트 포인터로 다중 소유권을 가능하게 만들었을 때처럼, 여러 소유자는 관리해야 할 복잡성을 늘립니다. 러스트의 타입 시스템과 소유권 규칙은 이 관리를 올바르게 하는 데 큰 도움을 줍니다. 이제 공유 메모리에서 자주 쓰이는 동시성 원시 도구 중 하나인 뮤텍스를 예로 들어 보겠습니다.
뮤텍스로 접근 제어하기
Mutex 는 mutual exclusion 의 줄임말로, 어느 한 시점에는 오직 하나의 스레드만 특정 데이터에 접근하도록 허용한다는 뜻입니다. 뮤텍스 안 데이터에 접근하려면, 스레드는 먼저 접근하겠다고 신호를 보내 락을 획득해야 합니다. 락(lock) 은 현재 누가 데이터에 대한 배타적 접근권을 갖고 있는지를 추적하는, 뮤텍스 내부의 자료구조입니다. 그래서 뮤텍스는 자신이 담고 있는 데이터를 락으로 보호(guard) 한다고 말합니다.
뮤텍스는 올바르게 사용하기가 어렵다는 평판이 있습니다. 두 가지 규칙을 반드시 기억해야 하기 때문입니다.
- 데이터를 사용하기 전에 반드시 락을 획득해야 한다.
- 뮤텍스가 보호하는 데이터를 다 사용한 뒤에는, 다른 스레드가 락을 획득할 수 있도록 반드시 잠금을 해제해야 한다.
실생활 비유로는, 하나의 마이크만 있는 컨퍼런스 패널 토론을 떠올려 볼 수 있습니다. 패널 한 명이 말하려면 먼저 마이크를 쓰고 싶다고 요청해야 하고, 마이크를 받으면 원하는 만큼 말한 뒤 다음 사람에게 넘겨줘야 합니다. 만약 누군가가 다 말한 뒤에도 마이크를 넘기지 않으면, 아무도 더 이상 발언할 수 없습니다. 공유 마이크 관리가 틀어지면 토론이 제대로 진행되지 않는 것과 같습니다.
뮤텍스 관리는 정말 까다로울 수 있기 때문에, 많은 사람이 채널을 더 선호합니다. 하지만 러스트에서는 타입 시스템과 소유권 규칙 덕분에 락을 잘못 다루는 실수를 훨씬 줄일 수 있습니다.
Mutex<T> API
뮤텍스를 어떻게 사용하는지 보기 위해, 먼저 단일 스레드 상황에서 단순하게 살펴봅시다. 목록 16-12를 보세요.
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
}
println!("m = {m:?}");
}
Mutex<T> API 살펴보기다른 많은 타입과 마찬가지로, Mutex<T> 는 연관 함수 new 로 만듭니다. 뮤텍스 안
데이터에 접근하려면 lock 메서드로 락을 획득합니다. 이 호출은 현재 스레드를 블록하여,
우리 차례가 올 때까지 아무 일도 하지 못하게 합니다.
락을 쥐고 있던 다른 스레드가 패닉하면 lock 호출은 실패할 수 있습니다. 그런 경우에는
아무도 락을 다시 얻을 수 없을 수 있으므로, 여기서는 unwrap 을 사용해 그런 상황이면
현재 스레드도 패닉하게 하기로 했습니다.
락을 획득한 뒤에는, 여기서 num 이라는 이름의 반환값을 뮤텍스 안 데이터에 대한
가변 참조처럼 다룰 수 있습니다. 타입 시스템은 락을 획득한 뒤에만 m 안 값을 사용할 수
있도록 보장합니다. m 의 타입은 Mutex<i32> 이고 i32 가 아니므로, 안의 i32
값에 접근하려면 반드시 lock 을 호출해야 합니다.
lock 호출은 LockResult 로 감싼 MutexGuard 타입을 반환하며, 우리는 unwrap
으로 이를 꺼냈습니다. MutexGuard 는 Deref 를 구현해 안쪽 데이터를 가리키고,
또 Drop 구현도 있어서 MutexGuard 가 스코프를 벗어나면 자동으로 락이 해제됩니다.
덕분에 락을 놓치는 실수로 다른 스레드가 영영 기다리게 될 위험이 줄어듭니다.
락이 해제된 뒤에는 뮤텍스 값을 출력할 수 있고, 그 안의 i32 를 6 으로 바꿀 수
있었음을 확인할 수 있습니다.
여러 스레드와 Mutex<T> 공유하기
이제 Mutex<T> 를 여러 스레드 사이에서 공유해 봅시다. 스레드 열 개를 만들고,
각각이 카운터 값을 1씩 늘리게 해서, 카운터가 0에서 10까지 올라가도록 하겠습니다.
목록 16-13의 예제는 처음에는 컴파일 오류가 날 것이고, 이 오류를 통해 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>우리는 목록 16-12에서 했던 것처럼 i32 값을 담은 Mutex<T> 를 counter 변수에
만듭니다. 그 다음 숫자 범위를 순회하며 스레드 열 개를 생성합니다. 우리는
thread::spawn 에 같은 클로저를 넘깁니다. 이 클로저는 카운터를 스레드 안으로
이동시키고, lock 메서드로 Mutex<T> 의 락을 획득한 뒤, 뮤텍스 안 값에 1을
더합니다. 스레드가 클로저 실행을 마치면, num 이 스코프를 벗어나며 락이 해제되어
다른 스레드가 락을 얻을 수 있게 됩니다.
메인 스레드에서는 모든 JoinHandle 을 모읍니다. 그리고 목록 16-2에서 했던 것처럼,
각 핸들에 대해 join 을 호출해 모든 스레드가 끝날 때까지 기다립니다. 그 다음
메인 스레드는 락을 얻어 이 프로그램의 결과를 출력합니다.
우리는 이 예제가 컴파일되지 않을 것이라고 미리 말했습니다. 왜 그런지 확인해 봅시다.
$ cargo run
Compiling shared-state v0.1.0 (file:///projects/shared-state)
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
|
help: consider moving the expression out of the loop so it is only moved once
|
8 ~ let mut value = counter.lock();
9 ~ for _ in 0..10 {
10 | let handle = thread::spawn(move || {
11 ~ let mut num = value.unwrap();
|
For more information about this error, try `rustc --explain E0382`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error
오류 메시지는 counter 값이 이전 반복에서 이미 이동되었다고 말합니다. 다시 말해
러스트는 같은 락 counter 의 소유권을 여러 스레드로 여러 번 이동시키는 것을
허용하지 않는다고 알려 주는 것입니다. 이제 15장에서 다룬 다중 소유권 방식으로 이
컴파일 오류를 해결해 보겠습니다.
여러 스레드에서 다중 소유권 사용하기
15장에서는 스마트 포인터 Rc<T> 를 사용해 하나의 값을 여러 소유자가 가지게 할 수
있다는 것을 보았습니다. 여기서도 같은 접근을 해 보고 어떤 일이 벌어지는지 확인해
봅시다. 목록 16-14에서는 Mutex<T> 를 Rc<T> 로 감싸고, 스레드로 소유권을 넘기기
전에 Rc<T> 를 clone 합니다.
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());
}
Mutex<T> 를 소유하게 하려고 Rc<T> 사용 시도하기다시 컴파일하면… 이번에는 다른 오류를 얻게 됩니다. 컴파일러가 계속 많이 가르쳐 주고 있죠.
$ cargo run
Compiling shared-state v0.1.0 (file:///projects/shared-state)
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`
--> /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/std/src/thread/mod.rs:723:1
For more information about this error, try `rustc --explain E0277`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error
오류 메시지의 핵심은 이것입니다. `Rc<Mutex<i32>>` cannot be sent between threads safely. 그리고 그 이유로 the trait `Send` is not implemented for `Rc<Mutex<i32>>` 라고 알려 줍니다. 다음 절에서 Send 에 대해 이야기하겠지만,
지금은 스레드와 함께 쓰기에 적합한 타입인지 보장하는 트레이트 중 하나라고만 알면
됩니다.
안타깝게도 Rc<T> 는 스레드 사이에서 공유해도 안전하지 않습니다. Rc<T> 는 각
clone 호출 때 참조 수를 늘리고, 각 clone 이 drop 될 때 그 수를 줄입니다. 하지만
이 참조 수 변경이 다른 스레드에 의해 중간에 끼어들지 않도록 보장하는 동시성 원시
도구를 사용하지 않습니다. 그 결과 참조 수가 잘못 계산될 수 있고, 이는 메모리 누수나
아직 사용 중인 값이 너무 일찍 drop 되는 문제로 이어질 수 있습니다. 우리가 필요한 것은
Rc<T> 와 똑같이 생겼지만, 참조 수 변경을 스레드 안전하게 처리하는 타입입니다.
Arc<T> 로 원자적 참조 카운팅하기
다행히 동시성 상황에서 안전하게 쓸 수 있는 Rc<T> 의 대안으로 Arc<T> 가 있습니다.
여기서 a 는 atomic 의 약자로, 즉 원자적 참조 카운팅(atomically reference-counted)
타입이라는 뜻입니다. 원자 연산은 또 다른 동시성 원시 도구이지만, 여기서는 자세히
다루지 않습니다. 더 궁금하면 표준 라이브러리의 std::sync::atomic
문서를 참고하세요. 지금은, 원자 타입이 기본 타입과 비슷하게 동작하면서도 스레드 간
공유가 안전하다는 점만 알면 충분합니다.
그렇다면 왜 모든 기본 타입이 자동으로 atomic 이 아니고, 표준 라이브러리 타입도
기본적으로 Arc<T> 기반으로 구현되지 않는지 궁금할 수 있습니다. 이유는 스레드
안전성이 성능 비용을 동반하기 때문입니다. 실제로 필요할 때만 그 비용을 지불하는 것이
좋습니다. 값이 단일 스레드 안에서만 사용된다면, 원자 연산이 제공하는 보장을 강제하지
않는 편이 더 빠릅니다.
이제 예제로 돌아와 봅시다. Arc<T> 와 Rc<T> 는 API가 같으므로, use 줄과
new 호출, clone 호출만 바꾸면 됩니다. 목록 16-15는 드디어 컴파일되고 원하는
대로 실행됩니다.
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());
}
Mutex<T> 를 Arc<T> 로 감싸기이 코드는 다음과 같은 출력을 합니다.
Result: 10
해냈습니다! 0에서 10까지 세는 것 자체는 대단해 보이지 않을 수 있지만, Mutex<T>
와 스레드 안전성에 대해 많은 것을 배웠습니다. 이 프로그램 구조는 단순히 카운터를
증가시키는 것보다 훨씬 복잡한 작업에도 사용할 수 있습니다. 계산을 여러 독립적인
부분으로 나누고, 각각을 여러 스레드로 분리한 뒤, Mutex<T> 를 사용해 각 스레드가
최종 결과를 조금씩 갱신하게 만드는 식입니다.
참고로 단순한 수치 연산만 한다면, 표준 라이브러리의 [std::sync::atomic 모듈]
atomic 에는 Mutex<T> 보다 더 단순한 타입들도 있습니다.
이 타입들은 기본 타입에 대해 스레드 안전하고 원자적인 접근을 제공합니다. 여기서는
Mutex<T> 가 어떻게 동작하는지에 집중하기 위해 기본 타입과 함께 Mutex<T> 를
사용했습니다.
RefCell<T>/Rc<T> 와 Mutex<T>/Arc<T> 비교하기
혹시 counter 자체는 불변인데도 그 안의 값에는 가변 참조를 얻을 수 있었다는 점을
눈치챘을지도 모릅니다. 이는 Mutex<T> 역시 Cell 계열과 마찬가지로 내부 가변성을
제공한다는 뜻입니다. 15장에서 Rc<T> 안의 값을 바꾸기 위해 RefCell<T> 를 썼던
것처럼, 여기서는 Arc<T> 안의 값을 바꾸기 위해 Mutex<T> 를 사용합니다.
또 한 가지 기억할 점은, Mutex<T> 를 사용한다고 해서 러스트가 모든 논리 오류까지
막아 주는 것은 아니라는 점입니다. 15장에서 Rc<T> 를 쓸 때 참조 순환으로 메모리
누수를 만들 수 있었던 것을 떠올려 보세요. 비슷하게 Mutex<T> 에는
교착 상태(deadlocks) 를 만들 위험이 있습니다. 이는 어떤 연산이 두 자원 모두의 락을
필요로 하는데, 두 스레드가 각각 하나의 락씩을 이미 쥐고 있어서 서로를 영원히
기다리게 되는 상황입니다. 교착 상태에 관심이 있다면, 일부러 교착 상태가 일어나는
러스트 프로그램을 하나 만들어 보고, 어떤 언어에서든 뮤텍스 교착 상태를 줄이는 전략을
조사한 뒤 러스트에 적용해 보는 것도 좋은 연습입니다. 표준 라이브러리의 Mutex<T>
와 MutexGuard 문서는 이와 관련된 유용한 정보를 제공합니다.
이제 이 장을 마무리하면서 Send 와 Sync 트레이트, 그리고 이들을 사용자 정의
타입과 함께 어떻게 사용하는지 이야기해 보겠습니다.