소개
**Shared-State Concurrency (공유 상태 동시성)**에 오신 것을 환영합니다. 이 랩은 Rust Book의 일부입니다. LabEx 에서 Rust 기술을 연습할 수 있습니다.
이 랩에서는 공유 메모리 동시성의 개념과 메시지 전달 옹호자들이 왜 이에 대해 경고하는지 살펴봅니다.
Shared-State Concurrency (공유 상태 동시성)
메시지 전달은 동시성을 처리하는 훌륭한 방법이지만, 유일한 방법은 아닙니다. 또 다른 방법은 여러 스레드가 동일한 공유 데이터에 접근하는 것입니다. Go 언어 문서의 슬로건 중 일부를 다시 생각해 봅시다: "메모리 공유를 통해 통신하지 마십시오."
메모리 공유를 통한 통신은 어떤 모습일까요? 또한, 메시지 전달 옹호자들이 메모리 공유를 사용하지 말라고 경고하는 이유는 무엇일까요?
어떤 면에서, 모든 프로그래밍 언어의 채널은 단일 소유권과 유사합니다. 채널을 통해 값을 전송하면 더 이상 해당 값을 사용해서는 안 되기 때문입니다. 공유 메모리 동시성은 다중 소유권과 같습니다: 여러 스레드가 동시에 동일한 메모리 위치에 접근할 수 있습니다. 15 장에서 스마트 포인터가 다중 소유권을 가능하게 했던 것처럼, 다중 소유권은 복잡성을 더할 수 있습니다. 이러한 서로 다른 소유자를 관리해야 하기 때문입니다. Rust 의 타입 시스템과 소유권 규칙은 이러한 관리를 올바르게 수행하는 데 크게 도움이 됩니다. 예를 들어, 공유 메모리를 위한 더 일반적인 동시성 기본 요소 중 하나인 뮤텍스 (mutex) 를 살펴보겠습니다.
Mutex 를 사용하여 한 번에 하나의 스레드에서 데이터에 접근 허용하기
Mutex는 *mutual exclusion (상호 배제)*의 약자입니다. 즉, 뮤텍스는 주어진 시간에 하나의 스레드만 특정 데이터에 접근하도록 허용합니다. 뮤텍스 내의 데이터에 접근하려면, 스레드는 먼저 뮤텍스의 *lock (락)*을 획득하여 접근 권한을 요청해야 합니다. 락은 현재 데이터에 대한 독점적인 접근 권한을 가진 주체를 추적하는 뮤텍스의 일부인 데이터 구조입니다. 따라서 뮤텍스는 락킹 시스템을 통해 자신이 보유한 데이터를 *guarding (보호)*한다고 설명됩니다.
뮤텍스는 사용하기 어렵다는 평판이 있는데, 두 가지 규칙을 기억해야 하기 때문입니다.
- 데이터를 사용하기 전에 락을 획득하려고 시도해야 합니다.
- 뮤텍스가 보호하는 데이터 사용이 완료되면, 다른 스레드가 락을 획득할 수 있도록 데이터를 언락해야 합니다.
뮤텍스에 대한 현실 세계의 비유를 위해, 마이크가 하나뿐인 컨퍼런스 패널 토론을 상상해 보세요. 패널리스트가 말하기 전에, 마이크를 사용하고 싶다는 신호를 보내거나 요청해야 합니다. 마이크를 받으면 원하는 만큼 말할 수 있으며, 그런 다음 말하기를 요청하는 다음 패널리스트에게 마이크를 넘겨줍니다. 패널리스트가 마이크 사용을 마치고 넘겨주는 것을 잊어버리면, 다른 사람은 아무도 말할 수 없습니다. 공유 마이크 관리가 잘못되면, 패널은 계획대로 진행되지 않을 것입니다!
뮤텍스 관리는 올바르게 수행하기가 매우 까다로울 수 있으며, 많은 사람들이 채널에 열광하는 이유입니다. 그러나 Rust 의 타입 시스템과 소유권 규칙 덕분에 락킹과 언락킹을 잘못할 수 없습니다.
Mutex<T>의 API
뮤텍스를 사용하는 방법의 예로, Listing 16-12 에 표시된 것처럼 단일 스레드 컨텍스트에서 뮤텍스를 사용하는 것부터 시작해 보겠습니다.
파일 이름: src/main.rs
use std::sync::Mutex;
fn main() {
1 let m = Mutex::new(5);
{
2 let mut num = m.lock().unwrap();
3 *num = 6;
4 }
5 println!("m = {:?}", m);
}
Listing 16-12: 단순화를 위해 단일 스레드 컨텍스트에서 Mutex<T>의 API 탐색
많은 타입과 마찬가지로, 연관 함수 new를 사용하여 Mutex<T>를 생성합니다 [1]. 뮤텍스 내부의 데이터에 접근하기 위해, lock 메서드를 사용하여 락을 획득합니다 [2]. 이 호출은 현재 스레드가 락을 가질 차례가 될 때까지 어떤 작업도 수행할 수 없도록 차단합니다.
락을 가지고 있는 다른 스레드가 패닉 (panic) 상태가 되면 lock 호출이 실패할 수 있습니다. 이 경우, 아무도 락을 얻을 수 없으므로, 우리는 unwrap을 선택하여 해당 상황에서 이 스레드가 패닉하도록 했습니다.
락을 획득한 후, 이 경우 num이라고 명명된 반환 값을 내부 데이터에 대한 가변 참조로 취급할 수 있습니다. 타입 시스템은 m의 값을 사용하기 전에 락을 획득하도록 보장합니다. m의 타입은 i32가 아닌 Mutex<i32>이므로, i32 값을 사용하려면 반드시 lock을 호출해야 합니다. 잊을 수 없습니다; 그렇지 않으면 타입 시스템이 내부 i32에 접근하는 것을 허용하지 않습니다.
예상할 수 있듯이, Mutex<T>는 스마트 포인터입니다. 더 정확하게 말하면, lock 호출은 unwrap 호출로 처리한 LockResult로 래핑된 MutexGuard라는 스마트 포인터를 반환합니다. MutexGuard 스마트 포인터는 내부 데이터를 가리키도록 Deref를 구현합니다. 또한 스마트 포인터는 MutexGuard가 스코프를 벗어날 때 자동으로 락을 해제하는 Drop 구현을 가지고 있으며, 이는 내부 스코프의 끝에서 발생합니다 [4]. 결과적으로, 락 해제가 자동으로 발생하므로 락을 해제하는 것을 잊어 다른 스레드가 뮤텍스를 사용하지 못하도록 차단할 위험이 없습니다.
락을 해제한 후, 뮤텍스 값을 출력하여 내부 i32를 6으로 변경할 수 있었음을 확인할 수 있습니다 [5].
여러 스레드 간의 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() {
1 let counter = Mutex::new(0);
let mut handles = vec![];
2 for _ in 0..10 {
3 let handle = thread::spawn(move || {
4 let mut num = counter.lock().unwrap();
5 *num += 1;
});
6 handles.push(handle);
}
for handle in handles {
7 handle.join().unwrap();
}
8 println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-13: Mutex<T>로 보호되는 카운터를 각각 증가시키는 10 개의 스레드
Listing 16-12 에서 했던 것처럼, Mutex<T> 내부에 i32를 저장하기 위해 counter 변수를 생성합니다 [1]. 다음으로, 숫자 범위를 반복하여 10 개의 스레드를 생성합니다 [2]. thread::spawn을 사용하고 모든 스레드에 동일한 클로저를 제공합니다. 이 클로저는 카운터를 스레드로 이동시키고 [3], lock 메서드를 호출하여 Mutex<T>에 대한 락을 획득한 다음 [4], 뮤텍스 내의 값에 1 을 더합니다 [5]. 스레드가 클로저 실행을 마치면 num이 스코프를 벗어나 락을 해제하여 다른 스레드가 락을 획득할 수 있습니다.
메인 스레드에서 모든 join handle 을 수집합니다 [6]. 그런 다음, Listing 16-2 에서 했던 것처럼, 모든 스레드가 완료되도록 각 handle 에 대해 join을 호출합니다 [7]. 그 시점에서 메인 스레드는 락을 획득하고 이 프로그램의 결과를 출력합니다 [8].
이 예제가 컴파일되지 않을 것이라고 암시했습니다. 이제 그 이유를 알아봅시다!
error[E0382]: use of moved value: `counter`
--> src/main.rs:9:36
|
5 | let counter = Mutex::new(0);
| ------- move occurs because `counter` has type `Mutex<i32>`, which
does not implement the `Copy` trait
...
9 | let handle = thread::spawn(move || {
| ^^^^^^^ value moved into closure here,
in previous iteration of loop
10 | let mut num = counter.lock().unwrap();
| ------- use occurs due to use in closure
오류 메시지는 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());
}
Listing 16-14: 여러 스레드가 Mutex<T>를 소유하도록 허용하기 위해 Rc<T>를 사용하려는 시도
다시 한 번, 컴파일하면... 다른 오류가 발생합니다! 컴파일러는 우리에게 많은 것을 가르쳐주고 있습니다.
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely 1
--> src/main.rs:11:22
|
11 | let handle = thread::spawn(move || {
| ______________________^^^^^^^^^^^^^_-
| | |
| | `Rc<Mutex<i32>>` cannot be sent between threads
safely
12 | | let mut num = counter.lock().unwrap();
13 | |
14 | | *num += 1;
15 | | });
| |_________- within this `[closure@src/main.rs:11:36: 15:10]`
|
= help: within `[closure@src/main.rs:11:36: 15:10]`, the trait `Send` is not
implemented for `Rc<Mutex<i32>>` 2
= note: required because it appears within the type
`[closure@src/main.rs:11:36: 15:10]`
note: required by a bound in `spawn`
와, 오류 메시지가 매우 장황합니다! 여기서 집중해야 할 중요한 부분은 ``Rc<Mutex는 스레드 간에 안전하게 전송될 수 없습니다[1]입니다. 컴파일러는 그 이유도 알려줍니다:트레이트 Send가 Rc<Mutex에 대해 구현되지 않았습니다[2]. 다음 섹션에서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 호출을 변경하여 프로그램을 수정합니다. 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());
}
Listing 16-15: 여러 스레드 간에 소유권을 공유할 수 있도록 Mutex<T>를 래핑하기 위해 Arc<T> 사용
이 코드는 다음을 출력합니다.
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 패밀리가 하는 것처럼 내부 가변성을 제공한다는 것을 의미합니다. 15 장에서 Rc<T> 내부의 내용을 변경할 수 있도록 RefCell<T>를 사용했던 방식과 마찬가지로, Arc<T> 내부의 내용을 변경하기 위해 Mutex<T>를 사용합니다.
또 다른 주목할 세부 사항은 Mutex<T>를 사용할 때 Rust 가 모든 종류의 논리적 오류로부터 보호할 수 없다는 것입니다. 15 장에서 Rc<T>를 사용하면 두 개의 Rc<T> 값이 서로를 참조하여 메모리 누수를 일으키는 참조 사이클을 생성할 위험이 있다는 것을 기억하십시오. 마찬가지로, Mutex<T>는 deadlock(교착 상태) 을 생성할 위험이 있습니다. 이는 연산이 두 개의 리소스를 잠가야 하고 두 스레드가 각각 하나의 잠금을 획득하여 서로 영원히 기다리게 될 때 발생합니다. 교착 상태에 관심이 있다면, 교착 상태가 있는 Rust 프로그램을 만들고, 모든 언어에서 뮤텍스에 대한 교착 상태 완화 전략을 연구한 다음, Rust 에서 구현해 보십시오. Mutex<T> 및 MutexGuard에 대한 표준 라이브러리 API 문서는 유용한 정보를 제공합니다.
이 장을 마무리하면서 Send 및 Sync 트레이트와 사용자 정의 타입과 함께 사용하는 방법에 대해 이야기하겠습니다.
요약
축하합니다! 공유 상태 동시성 랩을 완료했습니다. LabEx 에서 더 많은 랩을 연습하여 기술을 향상시킬 수 있습니다.