공유 상태 동시성
공유 상태 동시성 (Shared-State Concurrency)
메시지 전달은 동시성을 다루는 훌륭한 방법이지만, 그것만이 전부는 아니에요. 다른 방법으로 여러 스레드가 같은 공유 데이터에 접근하게 하는 방법이 있죠. Go 언어 문서의 슬로건 "메모리를 공유하면서 소통하지 말라(Do not communicate by sharing memory)"를 다시 떠올려 볼까요? 메모리를 공유하며 소통한다는 게 정확히 무슨 뜻인지, 또 왜 메시지 전달을 선호하는 사람들이 메모리 공유를 경계하는지 살펴봅시다.
Mutex로 접근 통제하기
어떤 언어에서든 채널은 단일 소유권과 비슷한 구석이 있어요. 채널로 값을 넘기고 나면 그 값을 더 이상 쓰면 안 되니까요. 반면 공유 메모리 동시성은 다중 소유권과 비슷합니다. 여러 스레드가 동시에 같은 메모리 위치에 접근할 수 있으니까요. 15장에서 스마트 포인터가 다중 소유권을 가능하게 했듯이, 소유자가 여러 개가 되면 그 소유자들을 관리해야 하는 만큼 복잡도가 올라갑니다. Rust의 타입 시스템과 소유권 규칙은 이 관리를 올바르게 하도록 크게 도와줘요. 예를 들어 공유 메모리를 위한 가장 흔한 동시성 원시 타입(concurrency primitive) 중 하나인 뮤텍스(mutex)를 살펴봅시다.
뮤텍스(Mutex)는 상호 배제(mutual exclusion) 의 줄임말로, 뮤텍스는 어떤 순간에는 단 하나의 스레드만 특정 데이터에 접근하도록 허용해요. 뮤텍스 안의 데이터에 접근하려면, 스레드는 먼저 뮤텍스의 잠금(lock)을 획득하겠다고 요청해 접근 의사를 밝혀야 합니다. 잠금은 뮤텍스의 일부인 데이터 구조로, 현재 누가 데이터에 대한 배타적 접근 권한을 갖고 있는지 추적해요. 그래서 뮤텍스는 잠금 체계를 통해 자신이 가진 데이터를 지키고(guard) 있다고 표현합니다.
뮤텍스는 쓰기 어렵기로 유명한데, 두 가지 규칙을 기억해야 하기 때문이에요:
- 데이터를 사용하기 전에 반드시 잠금을 획득해야 한다.
- 뮤텍스가 지키는 데이터 사용을 끝냈으면, 다른 스레드가 잠금을 획득할 수 있도록 데이터의 잠금을 해제해야 한다.
뮤텍스의 실생활 비유로는 콘퍼런스 패널 토론에 마이크가 하나뿐인 상황을 떠올려 보세요. 패널리스트가 말하려면 먼저 마이크를 쓰고 싶다고 요청하거나 신호를 보내야 합니다. 마이크를 받으면 원하는 만큼 말하고, 다음으로 말을 요청한 패널리스트에게 마이크를 넘겨주죠. 만약 패널리스트가 다 쓰고도 마이크를 넘겨주는 걸 잊어버리면, 아무도 말을 할 수 없어요. 공유 마이크 관리가 잘못되면 패널은 계획대로 진행될 수 없겠죠!
뮤텍스 관리는 제대로 하기 굉장히 까다로워서, 많은 사람이 채널에 열광하는 이유기도 합니다. 하지만 다행히도 Rust의 타입 시스템과 소유권 규칙 덕분에 잠그고 푸는 작업을 틀리게 할 수가 없어요.
Mutex<T>의 API
뮤텍스를 어떻게 쓰는지 예시를 들어볼게요. 먼저 단일 스레드 환경에서 뮤텍스를 사용하는 모습부터 보죠(Listing 16-12).
파일명: src/main.rs
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 메서드로 잠금을 획득합니다. 이 호출은 우리 차례가 되어 잠금을 얻기 전까지 현재 스레드를 차단(block)해서 다른 작업을 하지 못하게 하죠.
만약 잠금을 들고 있던 다른 스레드가 패닉(panic)하면 lock 호출은 실패할 거예요. 그런 상황에서는 아무도 잠금을 얻지 못하게 되니까, 우리는 unwrap을 써서 그 상황이 되면 이 스레드가 패닉하도록 선택한 거예요.
잠금을 획득하고 나면 반환값(여기서는 num)을 뮤텍스 안 데이터에 대한 가변 참조자처럼 다룰 수 있습니다. 타입 시스템이 m의 값을 쓰기 전에 잠금을 획득하도록 보장해줘요. m의 타입은 i32가 아니라 Mutex<i32>이므로, i32 값을 쓰려면 반드시 lock을 호출해야 합니다. 잊을 수도 없어요. 타입 시스템이 그렇지 않으면 내부 i32에 접근을 못 하게 만들 테니까요.
lock 호출은 MutexGuard라는 타입을 반환하는데, 이게 우리가 unwrap으로 처리한 LockResult에 감싸져 있어요. MutexGuard 타입은 내부 데이터를 가리키도록 Deref를 구현하고, MutexGuard가 스코프를 벗어나면 잠금을 자동으로 해제하는 Drop 구현도 갖고 있습니다(내부 스코프의 끝에서 벗어나죠). 결과적으로 잠금 해제를 잊어버려서 다른 스레드가 뮤텍스를 못 쓰게 막는 일이 없어요. 해제가 자동으로 일어나니까요.
잠금을 놓은 뒤 뮤텍스 값을 출력해 보면, 내부 i32를 6으로 바꿀 수 있었다는 걸 확인할 수 있어요.
Mutex<T> 공유 접근하기
이제 여러 스레드가 Mutex<T>로 값을 공유해 볼게요. 스레드 10개를 띄워 각각 카운터 값을 1씩 증가시켜서 0에서 10까지 만들어 봅시다. Listing 16-13의 예시는 컴파일 오류가 날 텐데, 그 오류를 이용해 Mutex<T> 사용법과 Rust가 올바른 사용을 어떻게 돕는지 더 배워 볼 거예요.
파일명: src/main.rs
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());
}
Listing 16-12에서처럼 Mutex<T> 안에 i32를 담는 counter 변수를 만들었어요. 다음으로 숫자 범위를 순회하면서 스레드 10개를 만듭니다. thread::spawn을 쓰고 모든 스레드에 같은 클로저를 넘겨줬어요. 그 클로저는 카운터를 스레드로 옮기고(move), lock 메서드를 호출해 Mutex<T>에 대한 잠금을 획득한 뒤, 뮤텍스 안의 값을 1 더합니다. 스레드가 클로저 실행을 끝내면 num이 스코프를 벗어나면서 잠금을 해제해 주므로 다른 스레드가 그 잠금을 획득할 수 있어요.
메인 스레드에서는 모든 조인 핸들을 모아둡니다. 그리고 Listing 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 값이 반복문의 이전 회차에서 이동되었다고 알려줘요. Rust는 잠금 counter의 소유권을 여러 스레드로 옮길 수 없다고 말해 주는 거예요. 15장에서 다뤘던 다중 소유권 방법으로 이 컴파일 오류를 해결해 봅시다.
여러 스레드에서의 다중 소유권
15장에서 우리는 스마트 포인터 Rc<T>를 써서 참조 카운트되는 값을 만들어 값에 여러 소유자를 주었어요. 여기서도 똑같이 해 보고 무슨 일이 일어나는지 볼게요. Listing 16-14에서는 Mutex<T>를 Rc<T>로 감싸고, 소유권을 스레드로 옮기기 전에 Rc<T>를 클론합니다.
파일명: src/main.rs
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());
}
다시 컴파일해 보면… 또 다른 오류가 나요! 컴파일러가 우리에게 많은 것을 가르쳐 주고 있습니다.
$ 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>>은 스레드 사이에서 안전하게 보낼 수 없어요. 컴파일러가 그 이유도 알려줘요: the trait Send is not implemented for Rc<Mutex<i32>>``. Send`에 대해서는 다음 절에서 이야기할 거예요. 이 트레이트는 스레드와 함께 쓰는 타입이 동시성 상황에서 쓰이도록 만들어진 타입인지 보장하는 트레이트 중 하나입니다.
안타깝게도 Rc<T>는 스레드 간 공유에 안전하지 않아요. Rc<T>는 참조 카운트를 관리할 때 clone이 호출될 때마다 카운트를 늘리고, 각 클론이 드롭될 때 카운트를 줄입니다. 하지만 카운트의 변경이 다른 스레드에 의해 중단되지 못하게 만들 동시성 원시 타입을 사용하지 않아요. 이러면 카운트가 잘못될 수 있는데, 그건 결국 메모리 누수나 사용이 끝나기도 전에 값이 드롭되는 등 미묘한 버그로 이어질 수 있죠. 우리에게 필요한 건 Rc<T>와 똑같지만 참조 카운트의 변경을 스레드 안전하게 하는 타입입니다.
Arc<T>로 원자적 참조 카운팅
다행히 Arc<T>는 동시성 상황에서 쓰기에 안전한, Rc<T>와 같은 타입이에요. A는 원자적(atomic) 을 뜻해서, 원자적으로 참조 카운트되는 타입이라는 의미죠. 원자 타입(atomic)은 또 다른 종류의 동시성 원시 타입인데, 여기서 자세히 다루지는 않을 거예요. 자세한 내용은 표준 라이브러리 문서의 std::sync::atomic을 참고하세요. 지금은 원자 타입이 기본 타입처럼 동작하지만 스레드 간 공유에 안전하다는 것만 알면 돼요.
그럼 왜 모든 기본 타입이 원자 타입이 아니고, 표준 라이브러리 타입들이 기본적으로 Arc<T>를 쓰지 않을까 궁금할 수 있겠네요. 이유는 스레드 안전성에는 성능 비용이 따르고, 그 비용은 정말 필요할 때만 내고 싶기 때문이에요. 단일 스레드 안에서 값에 대한 연산만 수행한다면, 원자 타입이 제공하는 보장을 강제할 필요가 없으니 코드가 더 빨리 돌 수 있어요.
우리 예시로 돌아가 볼게요. Arc<T>와 Rc<T>는 같은 API를 가지므로 use 줄, new 호출, clone 호출만 바꾸면 프로그램을 고칠 수 있어요. Listing 16-15의 코드는 드디어 컴파일되고 실행됩니다.
파일명: src/main.rs
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 계열처럼 내부 가변성(interior mutability)을 제공한다는 뜻입니다. 15장에서 Rc<T> 안의 내용을 변경할 수 있게 RefCell<T>를 사용했던 것과 같은 방식으로, 우리는 Arc<T> 안의 내용을 변경할 수 있게 Mutex<T>를 사용합니다.
또 하나 짚고 갈 점은, Rust도 Mutex<T>를 쓸 때 모든 종류의 논리 오류로부터 보호해 주진 못한다는 거예요. 15장에서 Rc<T>를 쓰면 참조 순환(reference cycle)을 만들어 두 Rc<T> 값이 서로를 가리키게 되고 메모리 누수가 일어날 위험이 있었다는 걸 떠올려 보세요. 비슷하게 Mutex<T>도 교착 상태(deadlock) 를 만들 위험이 있어요. 교착 상태는 어떤 연산이 두 자원을 잠가야 하는데 두 스레드가 각각 잠금 중 하나씩 획득한 상태에서 서로 영원히 기다리게 될 때 발생합니다. 교착 상태에 관심이 있다면, 교착 상태가 있는 Rust 프로그램을 만들어 보고, 어떤 언어로든 뮤텍스의 교착 상태 완화 전략을 조사한 뒤 Rust로 구현해 보세요. Mutex<T>와 MutexGuard의 표준 라이브러리 API 문서에 유용한 정보가 있습니다.
이번 장은 Send와 Sync 트레이트, 그리고 그것들을 커스텀 타입과 함께 어떻게 쓸 수 있는지 이야기하면서 마무리할게요.