공유 상태 동시성

메시지 전달은 동시성을 처리하는 좋은 방법이지만, 유일한 방법은 아니다. 또 다른 방법은 여러 스레드가 동일한 공유 데이터에 접근하는 것이다. Go 언어 문서의 슬로건 일부를 다시 살펴보자: “메모리를 공유함으로써 통신하지 말라.”

메모리를 공유함으로써 통신하는 것은 어떤 모습일까? 또한, 메시지 전달을 선호하는 사람들이 왜 메모리 공유를 사용하지 말라고 조언할까?

어떤 면에서, 프로그래밍 언어의 채널은 단일 소유권과 유사하다. 값을 채널로 전송한 후에는 그 값을 더 이상 사용해서는 안 되기 때문이다. 반면, 공유 메모리 동시성은 다중 소유권과 같다. 여러 스레드가 동시에 동일한 메모리 위치에 접근할 수 있다. 15장에서 스마트 포인터가 다중 소유권을 가능하게 하는 것을 보았듯이, 다중 소유권은 이러한 다양한 소유자를 관리해야 하기 때문에 복잡성을 증가시킬 수 있다. Rust의 타입 시스템과 소유권 규칙은 이러한 관리를 올바르게 수행하는 데 큰 도움을 준다. 예를 들어, 공유 메모리를 위한 가장 일반적인 동시성 기본 요소 중 하나인 뮤텍스를 살펴보자.

뮤텍스를 사용해 한 번에 하나의 스레드만 데이터에 접근하도록 허용하기

_뮤텍스(Mutex)_는 _상호 배제(mutual exclusion)_의 약자로, 뮤텍스를 사용하면 한 번에 하나의 스레드만 특정 데이터에 접근할 수 있다. 뮤텍스 내부의 데이터에 접근하려면 스레드가 먼저 뮤텍스의 _락(lock)_을 획득하도록 요청해야 한다. 락은 뮤텍스의 일부로, 현재 누가 데이터에 배타적으로 접근 중인지 추적하는 데이터 구조다. 따라서 뮤텍스는 락 시스템을 통해 자신이 보유한 데이터를 _보호(guarding)_한다고 표현한다.

뮤텍스는 사용하기 까다롭다는 평판이 있는데, 그 이유는 두 가지 규칙을 반드시 기억해야 하기 때문이다:

  1. 데이터를 사용하기 전에 반드시 락을 획득하려고 시도해야 한다.
  2. 뮤텍스가 보호하는 데이터 사용을 마치면, 다른 스레드가 락을 획득할 수 있도록 데이터를 잠금 해제해야 한다.

뮤텍스를 현실 세계에 비유하자면, 컨퍼런스에서 단 하나의 마이크만 사용할 수 있는 패널 토론을 생각해볼 수 있다. 패널리스트가 발언하기 전에 마이크 사용을 요청하거나 신호를 보내야 한다. 마이크를 받으면 원하는 만큼 발언할 수 있고, 발언이 끝나면 다음 발언을 요청한 패널리스트에게 마이크를 넘겨야 한다. 만약 패널리스트가 발언을 마친 후 마이크를 넘기는 것을 잊어버리면, 다른 누구도 발언할 수 없다. 공유 마이크 관리가 잘못되면 패널 토론은 계획대로 진행되지 않을 것이다!

뮤텍스 관리는 매우 까다로울 수 있어서 많은 사람들이 채널을 선호한다. 하지만 Rust의 타입 시스템과 소유권 규칙 덕분에 락 획득과 해제를 잘못 사용할 일은 없다.

Mutex<T>의 API

Mutex를 사용하는 방법을 설명하기 위해, 먼저 단일 스레드 환경에서 Mutex를 사용하는 예제를 살펴보자. 이는 Listing 16-12에 나와 있다.

Filename: 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:?}");
}
Listing 16-12: 단순함을 위해 단일 스레드 환경에서 Mutex<T>의 API를 탐색

다른 타입들과 마찬가지로, Mutex<T>는 연관 함수 new를 사용해 생성한다. 뮤텍스 내부의 데이터에 접근하기 위해 lock 메서드를 사용해 락을 획득한다. 이 호출은 현재 스레드를 블로킹하여 락을 획득할 때까지 다른 작업을 수행할 수 없게 한다.

만약 락을 가지고 있는 다른 스레드가 패닉 상태에 빠진다면, lock 호출은 실패할 것이다. 이 경우, 아무도 락을 얻을 수 없기 때문에, 우리는 unwrap을 선택해 이 스레드가 패닉 상태에 빠지도록 했다.

락을 획득한 후에는, 반환값(이 예제에서는 num이라는 이름)을 뮤텍스 내부 데이터에 대한 가변 참조로 취급할 수 있다. 타입 시스템은 m 내부의 값을 사용하기 전에 반드시 락을 획득하도록 보장한다. m의 타입은 i32가 아니라 Mutex<i32>이기 때문에, i32 값을 사용하려면 반드시 lock을 호출해야 한다. 이를 잊을 수는 없다. 타입 시스템이 내부의 i32에 접근하지 못하도록 막기 때문이다.

예상할 수 있듯이, Mutex<T>는 스마트 포인터다. 더 정확히 말하면, lock 호출은 MutexGuard라는 스마트 포인터를 반환하며, 이는 unwrap 호출로 처리된 LockResult로 감싸져 있다. MutexGuard 스마트 포인터는 내부 데이터를 가리키기 위해 Deref를 구현한다. 또한 이 스마트 포인터는 Drop 구현을 가지고 있어, MutexGuard가 스코프를 벗어날 때(내부 스코프의 끝에서) 자동으로 락을 해제한다. 결과적으로, 락을 해제하는 것을 잊어버려 다른 스레드가 뮤텍스를 사용하지 못하게 되는 위험은 없다. 락 해제가 자동으로 이루어지기 때문이다.

락을 해제한 후에는 뮤텍스 값을 출력해 볼 수 있으며, 내부의 i32 값을 6으로 변경할 수 있었다는 것을 확인할 수 있다.

여러 스레드 간 Mutex<T> 공유하기

이제 Mutex<T>를 사용해 여러 스레드 간에 값을 공유해 보자. 10개의 스레드를 생성하고 각 스레드가 카운터 값을 1씩 증가시켜, 카운터가 0에서 10으로 증가하도록 할 것이다. 리스트 16-13의 예제는 컴파일 오류가 발생하며, 이 오류를 통해 Mutex<T>의 사용법과 Rust가 어떻게 올바른 사용을 도와주는지 더 깊이 이해할 수 있다.

Filename: 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-13: Mutex<T>로 보호된 카운터를 각각 증가시키는 10개의 스레드

리스트 16-12와 마찬가지로 Mutex<T> 내부에 i32 값을 저장할 counter 변수를 생성한다. 다음으로, 숫자 범위를 순회하며 10개의 스레드를 생성한다. thread::spawn을 사용하고 모든 스레드에 동일한 클로저를 전달한다. 이 클로저는 counter를 스레드로 이동시키고, lock 메서드를 호출해 Mutex<T>의 락을 획득한 후, 뮤텍스 내부의 값을 1 증가시킨다. 스레드가 클로저 실행을 마치면 num이 스코프를 벗어나 락이 해제되어 다른 스레드가 락을 획득할 수 있다.

메인 스레드에서는 모든 조인 핸들을 수집한다. 그리고 리스트 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 `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>를 사용해 참조 카운트가 적용된 값을 생성함으로써 다중 소유권을 구현했다. 이번에도 같은 방식을 적용해 보자. Mutex<T>Rc<T>로 감싸고, 스레드로 소유권을 이동하기 전에 Rc<T>를 복제해 보겠다.

<리스트 번호=“16-14” 파일명=“src/main.rs” 캡션=“Rc<T>를 사용해 다중 스레드가 Mutex<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());
}

</리스트>

다시 컴파일을 해 보면… 또 다른 에러가 발생한다! 컴파일러가 우리에게 많은 것을 가르쳐 주고 있다.

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<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<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<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/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/std/src/thread/mod.rs:728: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>>`는 스레드 간에 안전하게 전송될 수 없다는 것이다. 컴파일러는 또한 그 이유를 알려주고 있다: `Rc<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][atomic] 문서를 참고하면 된다. 여기서는 원자적 연산이 기본 타입처럼 동작하지만, 스레드 간에 안전하게 공유할 수 있다는 점만 이해하면 충분하다.

그렇다면 모든 기본 타입이 원자적이지 않은 이유와 표준 라이브러리 타입이 기본적으로 Arc<T>를 사용하지 않는 이유가 궁금할 것이다. 그 이유는 스레드 안전성을 보장하려면 성능상의 비용이 발생하기 때문이다. 이 비용은 정말 필요할 때만 지불하는 것이 좋다. 단일 스레드 내에서만 값을 다룬다면, 원자적 연산이 제공하는 보장을 강제할 필요가 없으므로 코드가 더 빠르게 실행될 수 있다.

예제로 돌아가 보자. Arc<T>Rc<T>는 동일한 API를 제공하므로, use 문과 new 호출, 그리고 clone 호출만 변경하면 프로그램을 수정할 수 있다. 리스트 16-15의 코드는 드디어 컴파일되고 실행된다.

Filename: 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 모듈][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>는 데드락을 발생시킬 위험이 있다. 데드락은 두 리소스를 잠그는 작업이 필요할 때, 두 스레드가 각각 하나의 잠금을 획득하여 서로를 영원히 기다리게 되는 상황에서 발생한다. 데드락에 관심이 있다면, 데드락이 발생하는 Rust 프로그램을 만들어보고, 다양한 언어에서 뮤텍스의 데드락 완화 전략을 연구한 후 Rust에서 이를 구현해보는 것도 좋은 방법이다. Mutex<T>MutexGuard의 표준 라이브러리 API 문서는 유용한 정보를 제공한다.

이 장의 마지막으로, SendSync 트레이트에 대해 이야기하고 커스텀 타입에서 이를 어떻게 사용할 수 있는지 알아볼 것이다.