본문 바로가기

공유 상태 동시성

Mutex로 공유 데이터 접근을 직렬화하고 Arc의 원자적 참조 카운팅을 결합해 여러 스레드에서 상태를 안전하게 갱신합니다.

메시지 패싱은 동시성을 다루는 좋은 방법이지만, 유일한 수단은 아닙니다.

또 다른 방법은 여러 스레드가 동일한 공유 데이터에 접근하는 것입니다.

고(Go) 언어 문서로부터 나온 슬로건의 일부를 다시 한번 생각해 보세요: ‘메모리를 공유하여 통신하지 마세요.’

메모리를 공유하는 통신은 어떻게 생겼을까요?

더불어서 메시지 패싱 애호가들은 왜 메모리 공유를 쓰지 말라고 경고할까요?

이 절에서 쓰는 러스트 채널은 값을 send에 넘길 때 소유권을 전달합니다. 앞 예제의 String은 Copy가 아니므로 보낸 뒤 원래 변수를 다시 사용할 수 없습니다. Copy 값은 복사해서 보낼 수 있으며, 다른 언어의 채널도 같은 소유권 규칙을 따른다고 일반화할 수는 없습니다.

공유 메모리 동시성은 복수 소유권과 유사합니다.

여러 스레드들이 동시에 동일한 메모리 위치에 접근할 수 있지요.

스마트 포인터가 복수 소유권을 가능하게 하는 내용을 담은 14장에서 보셨듯이, 복수 소유권은 서로 다른 소유자들에 대한 관리가 필요하기 때문에 더 복잡할 수 있습니다.

러스트의 타입 시스템과 소유권 규칙은 이러한 관리가 올바르도록 훌륭히 도와줍니다.

예를 들면, 공유 메모리를 위한 더 일반적인 동시성 기초 재료 중 하나인 뮤텍스(mutex)를 살펴봅시다.


뮤텍스를 사용하여 한 번에 한 스레드에서의 데이터 접근을 허용하기

뮤텍스는 *상호 배제(mutual exclusion)*의 줄임말로, 뮤텍스에서는 한 번에 하나의 스레드만 데이터에 접근하도록 허용합니다.

뮤텍스 내부의 데이터에 접근하려면 스레드는 먼저 뮤텍스의 *락(lock)*을 얻는 요청을 해서 접근을 희망하는 신호를 보내야 합니다.

락은 누가 현재 배타적으로 데이터에 접근하는지 추적하는 뮤텍스의 일부에 해당하는 데이터 구조입니다.

그러므로, 뮤텍스는 잠금 시스템을 통해 가지고 있는 데이터를 *보호하는 것(guard)*으로 묘사됩니다.

뮤텍스는 사용하기 어렵다는 평판이 있는데 이는 다음 두 가지 규칙을 기억해야 하기 때문입니다.

  • 데이터를 사용하기 전에는 반드시 락을 얻는 시도를 해야 합니다.
  • 만일 뮤텍스가 보호하는 데이터의 사용이 끝났다면, 반드시 언락을 해야 다른 스레드들이 락을 얻을 수 있습니다.

뮤텍스는 마이크가 하나뿐인 컨퍼런스 패널 토의에 비유할 수 있습니다.

패널 참가자가 말하려면 먼저 마이크 사용을 요청하거나 신호를 줘야 합니다.

마이크를 얻었을 때는 원하는 만큼 길게 말한 다음, 말하기를 원하는 다음 패널 참가자에게 마이크를 건네줍니다.

만일 패널 참여자가 마이크 사용을 끝냈을 때 이를 건네주는 것을 잊어먹는다면, 그 외 아무도 말할 수 없게 됩니다.

공유하는 마이크의 관리가 잘못되면, 패널 토의는 계획대로 진행되지 않을 겁니다!

뮤텍스의 관리를 올바르게 하려면 믿을 수 없을 만큼 까다로울 수 있는데, 이것이 바로 많은 사람들이 채널 애호가가 되는 이유입니다.

러스트의 타입 시스템은 보호된 데이터에 접근할 때 guard를 요구하고, 소유권 규칙은 guard가 drop될 때 락을 해제하도록 돕습니다. 다만 데드락이나 지나치게 긴 락 보유까지 막지는 않습니다.

Mutex<T>의 API

뮤텍스 사용 방법에 대한 예제로, 예제 15-12처럼 싱글스레드 컨텍스트에서 뮤텍스를 사용하는 것으로 시작해봅시다.

예제 15-12: 간소화를 위해 싱글스레드 컨텍스트에서 Mutex<T>의 API 탐색하기
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 메서드를 사용하여 락을 얻습니다.

다른 스레드가 이미 락을 보유하고 있다면 이 호출은 현재 스레드가 락을 얻을 때까지 기다립니다. 현재 스레드는 lock 다음 줄로 진행하지 못하지만, 락을 보유한 스레드나 다른 스레드까지 멈추는 것은 아닙니다. 락을 즉시 얻을 수 있다면 기다리지 않고 guard를 받습니다.

락을 보유한 스레드의 panic이 감지되면 뮤텍스는 poisoned 상태가 될 수 있고, 이후 lock은 PoisonError를 반환합니다.

이 예제는 unwrap으로 오류를 전파하여 현재 스레드도 panic하게 합니다. 영구적으로 잠긴다는 뜻은 아닙니다. 표준 라이브러리 문서처럼 PoisonError::into_inner로 guard를 얻어 데이터를 점검하고 복구할 수도 있습니다.

락을 얻고 난 후에는 그 반환 값, 지금의 경우 num이라는 이름의 값을 내부 데이터에 대한 가변 참조자로 취급할 수 있습니다.

타입 시스템은 m 내부의 값을 사용하기 전에 락을 얻도록 보장합니다.

Mutex<i32>는 i32가 아닙니다. 이 예처럼 공유 참조에서 보호된 i32를 변경하려면 lock으로 guard를 얻습니다. 뮤텍스 자체를 유일하게 가변 대여할 수 있는 별도 상황에는 get_mut 경로도 있으므로 모든 API가 락 획득을 요구한다고 일반화하지 마세요.

이 공유 접근 경로에서 타입 시스템은 guard 없이 내부의 i32를 직접 변경하는 것을 허용하지 않습니다.

짐작하셨을지 모르겠지만 Mutex<T>의 API에는 스마트 포인터가 사용됩니다.

더 정확하게는 lock의 호출이 MutexGuard라는 스마트 포인터를 반환하는데, unwrap 호출을 통해 처리되는 LockResult로 감싸져 있습니다.

MutexGuard 스마트 포인터는 내부 데이터를 가리키도록 Deref가 구현되어 있습니다; 또한 MutexGuard 스마트 포인터에는 Drop 구현체가 있는데, 이것으로 내부 스코프의 끝에서 스코프 밖으로 벗어났을 때 자동으로 락을 해제하는 일이 벌어집니다.

이 예제에서는 내부 스코프가 끝날 때 guard가 drop되어 락이 자동으로 해제됩니다. guard를 오래 보유하거나 여러 락을 잘못된 순서로 획득하면 대기나 데드락이 발생할 수 있습니다.

락이 버려진 후에는 뮤텍스 값을 출력하여 내부의 i32를 6으로 바꿀 수 있음을 확인할 수 있습니다.

예제 15-12에서 락을 얻고, 값을 바꾸고, 스코프 끝에서 락이 풀리는 흐름은 MutexGuard의 수명과 함께 읽으면 다음처럼 정리됩니다.

단계별 보기

Arc·Mutex 공유 소유권과 락 수명

Arc 소유 이동→lock→guard 임계 구역 증가→drop unlock→join→최종 10 관측의 자원/카운터 상태이다. panic poison 가능/복구와 데드락 경계를 보존한다.

Arc 공유

스레드별 Arc가 Mutex 카운터를 공유한다.

Arc·Mutex 공유 소유권과 락 수명 — Arc 공유

상자 안의 대상과 화살표의 조건을 함께 읽으세요. 좁은 화면에서는 같은 관계를 세로로 재배치한 도식을 보여 줍니다.

Arc·Mutex 공유 소유권과 락 수명 — Arc 공유스레드별 Arc가 Mutex 카운터를 공유한다.현재 상태·값: 카운터: 공유 하나 소유 관계: Arc 복제현재 상태·값카운터: 공유 하나소유 관계: Arc 복제전환 근거: Arc는 수명을 공유하고, 값 갱신 보호는 Mutex가 담당한다.전환 근거Arc는 수명을 공유하고, 값 갱신 보호는 Mutex가 담당한다.
Arc·Mutex 공유 소유권과 락 수명 — Arc 공유스레드별 Arc가 Mutex 카운터를 공유한다.현재 상태·값: 카운터: 공유 하나 소유 관계: Arc 복제현재 상태·값카운터: 공유 하나소유 관계: Arc 복제전환 근거: Arc는 수명을 공유하고, 값 갱신 보호는 Mutex가 담당한다.전환 근거Arc는 수명을공유하고, 값 갱신보호는 Mutex가담당한다.

스레드별 Arc가 Mutex 카운터를 공유한다.

카운터
공유 하나
소유 관계
Arc 복제

이번 단계에서 바뀐 점

  • Arc는 수명을 공유하고, 값 갱신 보호는 Mutex가 담당한다.

전체보기

SEQUENCE · OWNERSHIP PLUS EXCLUSION

Arc는 소유권을 공유하고 MutexGuard는 배타 접근을 유지한다

Arc<T>는 원자적 공유 소유권을, Mutex<T>는 MutexGuard를 통한 배타 변경을 제공한다. guard가 drop되면 잠금이 풀리고 join 뒤 최종 값을 관찰할 수 있다.

Arc·Mutex 공유 소유권과 락 수명 Arc clone과 스레드 이동, Mutex lock, MutexGuard 임계 구역, guard drop 해제, join, poisoning 회복 경계를 책임별로 나눈 시퀀스 ARC::CLONE Arc 소유자 이동 LOCK() MUTEXGUARD MUTATE T ALT [normal] guard drop → unlock [panic while held] panic: unlock · poison 가능 PoisonError로 guard 회수 MAIN 메인 스레드 OWNER Arc<Mutex<T>> LOCK Mutex<T> WORKER 작업 스레드 최종 읽기 전에 합류 · poison은 복구 가능 · RAII도 데드락은 막지 못함
공유 소유권과 잠금 수명의 구별원 desktop4참여자의clone/move/lock/guard반환/변경5메시지와ALT정상해제/보유중panic의선택을보존한다. 모바일첫5쌍은같은참여자를반복표현하며새10개스레드가아니다. 아래두해제쌍은서로대안이고둘다순차실행하라는뜻이아니다. 정상guarddrop과unwind중guard정리가unlock하며poison은가능한advisory신호다. abort정리를보장하지않는다. 원desktop의정상unlock간선1과panicALT설명속암묵적해제관계를각각표현하여모바일은총7화살표이다. join은최종읽기의별도조건이고Arc자체는payload동기화가아니다.1 · 메시지메인 스레드Arc 소유자Arc::clone2 · 메시지Arc 소유자작업 스레드move3 · 메시지작업 스레드Mutexlock4 · 메시지Mutex작업 스레드guard 반환5 · 메시지작업 스레드Mutex의 내부 Tguard로 변경대안 A · 정상 종료작업의 guardMutexdropunlock대안 B · unwind작업의 guardMutexunwind 중 정리unlockunwind: poison 가능 신호회복은 데이터 점검 뒤 선택최종 읽기 전 모든 작업 join
  1. 공유 소유권

    Arc::clone은 원자적 참조 카운트 소유자를 만들어 작업 스레드로 보냅니다.

  2. 배타 접근

    Mutex::lock은 MutexGuard를 돌려주고 guard가 살아 있는 동안만 T를 변경합니다.

  3. 해제와 관찰

    guard drop이 unlock하고 JoinHandle 합류 뒤 최종 값을 읽습니다.

  4. Poison

    panic 중 guard가 풀리면 poisoning 신호가 남지만 LockResult를 통해 회복을 선택할 수 있습니다.

  5. 경계

    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이나 과도한 임계 구역을 막지 않는다.

이 예제의 가정

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
원 도표의 제목·노드·관계·조건·주석
범위원문
본문·주석SEQUENCE · OWNERSHIP PLUS EXCLUSION
본문·주석Arc는 소유권을 공유하고 MutexGuard는 배타 접근을 유지한다
본문·주석Arc<T>는 원자적 공유 소유권을, Mutex<T>는 MutexGuard를 통한 배타 변경을 제공한다. guard가 drop되면 잠금이 풀리고 join 뒤 최종 값을 관찰할 수 있다.
도표 제목Arc·Mutex 공유 소유권과 락 수명
관계·조건 설명Arc clone과 스레드 이동, Mutex lock, MutexGuard 임계 구역, guard drop 해제, join, poisoning 회복 경계를 책임별로 나눈 시퀀스
노드·연결선 라벨ARC::CLONE
노드·연결선 라벨Arc 소유자 이동
노드·연결선 라벨LOCK()
노드·연결선 라벨MUTEXGUARD
노드·연결선 라벨MUTATE T
노드·연결선 라벨ALT
노드·연결선 라벨[normal]
노드·연결선 라벨guard drop → unlock
노드·연결선 라벨[panic while held]
노드·연결선 라벨panic: unlock · poison 가능
노드·연결선 라벨PoisonError로 guard 회수
노드·연결선 라벨MAIN
노드·연결선 라벨메인 스레드
노드·연결선 라벨OWNER
노드·연결선 라벨Arc<Mutex<T>>
노드·연결선 라벨LOCK
노드·연결선 라벨Mutex<T>
노드·연결선 라벨WORKER
노드·연결선 라벨작업 스레드
노드·연결선 라벨최종 읽기 전에 합류 · poison은 복구 가능 · RAII도 데드락은 막지 못함
도표 제목공유 소유권과 잠금 수명의 구별
관계·조건 설명원래 가로 도표의 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 동기화가 아니다.
노드·연결선 라벨1 · 메시지
노드·연결선 라벨Arc 소유자
노드·연결선 라벨Arc::clone
노드·연결선 라벨2 · 메시지
노드·연결선 라벨move
노드·연결선 라벨3 · 메시지
노드·연결선 라벨Mutex
노드·연결선 라벨lock
노드·연결선 라벨4 · 메시지
노드·연결선 라벨guard 반환
노드·연결선 라벨5 · 메시지
노드·연결선 라벨Mutex의 내부 T
노드·연결선 라벨guard로 변경
노드·연결선 라벨대안 A · 정상 종료
노드·연결선 라벨작업의 guard
노드·연결선 라벨drop
노드·연결선 라벨unlock
노드·연결선 라벨대안 B · unwind
노드·연결선 라벨unwind 중 정리
노드·연결선 라벨unwind: poison 가능 신호
노드·연결선 라벨회복은 데이터 점검 뒤 선택
노드·연결선 라벨최종 읽기 전 모든 작업 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이나 과도한 임계 구역을 막지 않는다.
  1. 1 / 3단계

    Arc 공유

    스레드별 Arc가 Mutex 카운터를 공유한다.

    Arc·Mutex 공유 소유권과 락 수명 — Arc 공유

    상자 안의 대상과 화살표의 조건을 함께 읽으세요. 좁은 화면에서는 같은 관계를 세로로 재배치한 도식을 보여 줍니다.

    Arc·Mutex 공유 소유권과 락 수명 — Arc 공유스레드별 Arc가 Mutex 카운터를 공유한다.현재 상태·값: 카운터: 공유 하나 소유 관계: Arc 복제현재 상태·값카운터: 공유 하나소유 관계: Arc 복제전환 근거: Arc는 수명을 공유하고, 값 갱신 보호는 Mutex가 담당한다.전환 근거Arc는 수명을 공유하고, 값 갱신 보호는 Mutex가 담당한다.
    Arc·Mutex 공유 소유권과 락 수명 — Arc 공유스레드별 Arc가 Mutex 카운터를 공유한다.현재 상태·값: 카운터: 공유 하나 소유 관계: Arc 복제현재 상태·값카운터: 공유 하나소유 관계: Arc 복제전환 근거: Arc는 수명을 공유하고, 값 갱신 보호는 Mutex가 담당한다.전환 근거Arc는 수명을공유하고, 값 갱신보호는 Mutex가담당한다.

    스레드별 Arc가 Mutex 카운터를 공유한다.

    카운터
    공유 하나
    소유 관계
    Arc 복제

    이번 단계에서 바뀐 점

    • Arc는 수명을 공유하고, 값 갱신 보호는 Mutex가 담당한다.
  2. 2 / 3단계

    guard 갱신

    lock guard로 카운터를 증가시키고 drop한다.

    Arc·Mutex 공유 소유권과 락 수명 — guard 갱신

    상자 안의 대상과 화살표의 조건을 함께 읽으세요. 좁은 화면에서는 같은 관계를 세로로 재배치한 도식을 보여 줍니다.

    Arc·Mutex 공유 소유권과 락 수명 — guard 갱신lock guard로 카운터를 증가시키고 drop한다.현재 상태·값: 임계 구역: guard 보유 중 guarddrop: unlock현재 상태·값임계 구역: guard 보유 중guarddrop: unlock전환 근거: 증가 연산과 정리 경계를 같은 자원 수명으로 추적한다.전환 근거증가 연산과 정리 경계를 같은 자원 수명으로 추적한다.
    Arc·Mutex 공유 소유권과 락 수명 — guard 갱신lock guard로 카운터를 증가시키고 drop한다.현재 상태·값: 임계 구역: guard 보유 중 guarddrop: unlock현재 상태·값임계 구역: guard보유 중guarddrop:unlock전환 근거: 증가 연산과 정리 경계를 같은 자원 수명으로 추적한다.전환 근거증가 연산과 정리경계를 같은 자원수명으로 추적한다.

    lock guard로 카운터를 증가시키고 drop한다.

    임계 구역
    guard 보유 중
    guarddrop
    unlock

    이번 단계에서 바뀐 점

    • 증가 연산과 정리 경계를 같은 자원 수명으로 추적한다.
  3. 3 / 3단계

    join 후 관측

    정상적으로 10개 스레드가 완료된 뒤 결과를 읽는다.

    Arc·Mutex 공유 소유권과 락 수명 — join 후 관측

    상자 안의 대상과 화살표의 조건을 함께 읽으세요. 좁은 화면에서는 같은 관계를 세로로 재배치한 도식을 보여 줍니다.

    Arc·Mutex 공유 소유권과 락 수명 — join 후 관측정상적으로 10개 스레드가 완료된 뒤 결과를 읽는다.현재 상태·값: 최종 카운터: 10 완료 조건: 모든 join 성공현재 상태·값최종 카운터: 10완료 조건: 모든 join 성공전환 근거: poison·panic·deadlock이 없는 정상 경로의 결과이며, 모든 상황을 보장하지 않는다.전환 근거poison·panic·deadlock이 없는 정상 경로의 결과이며,모든 상황을 보장하지 않는다.
    Arc·Mutex 공유 소유권과 락 수명 — join 후 관측정상적으로 10개 스레드가 완료된 뒤 결과를 읽는다.현재 상태·값: 최종 카운터: 10 완료 조건: 모든 join 성공현재 상태·값최종 카운터: 10완료 조건: 모든 join성공전환 근거: poison·panic·deadlock이 없는 정상 경로의 결과이며, 모든 상황을 보장하지 않는다.전환 근거poison·panic·deadlock이 없는 정상경로의 결과이며,모든 상황을 보장하지않는다.

    정상적으로 10개 스레드가 완료된 뒤 결과를 읽는다.

    최종 카운터
    10
    완료 조건
    모든 join 성공

    이번 단계에서 바뀐 점

    • poison·panic·deadlock이 없는 정상 경로의 결과이며, 모든 상황을 보장하지 않는다.

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·Mutex 공유 소유권과 락 수명
관계·조건 설명Arc clone과 스레드 이동, Mutex lock, MutexGuard 임계 구역, guard drop 해제, join, poisoning 회복 경계를 책임별로 나눈 시퀀스
노드·연결선 라벨ARC::CLONE
노드·연결선 라벨Arc 소유자 이동
노드·연결선 라벨LOCK()
노드·연결선 라벨MUTEXGUARD
노드·연결선 라벨MUTATE T
노드·연결선 라벨ALT
노드·연결선 라벨[normal]
노드·연결선 라벨guard drop → unlock
노드·연결선 라벨[panic while held]
노드·연결선 라벨panic: unlock · poison 가능
노드·연결선 라벨PoisonError로 guard 회수
노드·연결선 라벨MAIN
노드·연결선 라벨메인 스레드
노드·연결선 라벨OWNER
노드·연결선 라벨Arc<Mutex<T>>
노드·연결선 라벨LOCK
노드·연결선 라벨Mutex<T>
노드·연결선 라벨WORKER
노드·연결선 라벨작업 스레드
노드·연결선 라벨최종 읽기 전에 합류 · poison은 복구 가능 · RAII도 데드락은 막지 못함
도표 제목공유 소유권과 잠금 수명의 구별
관계·조건 설명원래 가로 도표의 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 동기화가 아니다.
노드·연결선 라벨1 · 메시지
노드·연결선 라벨Arc 소유자
노드·연결선 라벨Arc::clone
노드·연결선 라벨2 · 메시지
노드·연결선 라벨move
노드·연결선 라벨3 · 메시지
노드·연결선 라벨Mutex
노드·연결선 라벨lock
노드·연결선 라벨4 · 메시지
노드·연결선 라벨guard 반환
노드·연결선 라벨5 · 메시지
노드·연결선 라벨Mutex의 내부 T
노드·연결선 라벨guard로 변경
노드·연결선 라벨대안 A · 정상 종료
노드·연결선 라벨작업의 guard
노드·연결선 라벨drop
노드·연결선 라벨unlock
노드·연결선 라벨대안 B · unwind
노드·연결선 라벨unwind 중 정리
노드·연결선 라벨unwind: poison 가능 신호
노드·연결선 라벨회복은 데이터 점검 뒤 선택
노드·연결선 라벨최종 읽기 전 모든 작업 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이나 과도한 임계 구역을 막지 않는다.
  1. 1 / 3단계

    Arc 공유

    스레드별 Arc가 Mutex 카운터를 공유한다.

    Arc·Mutex 공유 소유권과 락 수명 — Arc 공유

    상자 안의 대상과 화살표의 조건을 함께 읽으세요. 좁은 화면에서는 같은 관계를 세로로 재배치한 도식을 보여 줍니다.

    Arc·Mutex 공유 소유권과 락 수명 — Arc 공유스레드별 Arc가 Mutex 카운터를 공유한다.현재 상태·값: 카운터: 공유 하나 소유 관계: Arc 복제현재 상태·값카운터: 공유 하나소유 관계: Arc 복제전환 근거: Arc는 수명을 공유하고, 값 갱신 보호는 Mutex가 담당한다.전환 근거Arc는 수명을 공유하고, 값 갱신 보호는 Mutex가 담당한다.
    Arc·Mutex 공유 소유권과 락 수명 — Arc 공유스레드별 Arc가 Mutex 카운터를 공유한다.현재 상태·값: 카운터: 공유 하나 소유 관계: Arc 복제현재 상태·값카운터: 공유 하나소유 관계: Arc 복제전환 근거: Arc는 수명을 공유하고, 값 갱신 보호는 Mutex가 담당한다.전환 근거Arc는 수명을공유하고, 값 갱신보호는 Mutex가담당한다.

    스레드별 Arc가 Mutex 카운터를 공유한다.

    카운터
    공유 하나
    소유 관계
    Arc 복제

    이번 단계에서 바뀐 점

    • Arc는 수명을 공유하고, 값 갱신 보호는 Mutex가 담당한다.
  2. 2 / 3단계

    guard 갱신

    lock guard로 카운터를 증가시키고 drop한다.

    Arc·Mutex 공유 소유권과 락 수명 — guard 갱신

    상자 안의 대상과 화살표의 조건을 함께 읽으세요. 좁은 화면에서는 같은 관계를 세로로 재배치한 도식을 보여 줍니다.

    Arc·Mutex 공유 소유권과 락 수명 — guard 갱신lock guard로 카운터를 증가시키고 drop한다.현재 상태·값: 임계 구역: guard 보유 중 guarddrop: unlock현재 상태·값임계 구역: guard 보유 중guarddrop: unlock전환 근거: 증가 연산과 정리 경계를 같은 자원 수명으로 추적한다.전환 근거증가 연산과 정리 경계를 같은 자원 수명으로 추적한다.
    Arc·Mutex 공유 소유권과 락 수명 — guard 갱신lock guard로 카운터를 증가시키고 drop한다.현재 상태·값: 임계 구역: guard 보유 중 guarddrop: unlock현재 상태·값임계 구역: guard보유 중guarddrop:unlock전환 근거: 증가 연산과 정리 경계를 같은 자원 수명으로 추적한다.전환 근거증가 연산과 정리경계를 같은 자원수명으로 추적한다.

    lock guard로 카운터를 증가시키고 drop한다.

    임계 구역
    guard 보유 중
    guarddrop
    unlock

    이번 단계에서 바뀐 점

    • 증가 연산과 정리 경계를 같은 자원 수명으로 추적한다.
  3. 3 / 3단계

    join 후 관측

    정상적으로 10개 스레드가 완료된 뒤 결과를 읽는다.

    Arc·Mutex 공유 소유권과 락 수명 — join 후 관측

    상자 안의 대상과 화살표의 조건을 함께 읽으세요. 좁은 화면에서는 같은 관계를 세로로 재배치한 도식을 보여 줍니다.

    Arc·Mutex 공유 소유권과 락 수명 — join 후 관측정상적으로 10개 스레드가 완료된 뒤 결과를 읽는다.현재 상태·값: 최종 카운터: 10 완료 조건: 모든 join 성공현재 상태·값최종 카운터: 10완료 조건: 모든 join 성공전환 근거: poison·panic·deadlock이 없는 정상 경로의 결과이며, 모든 상황을 보장하지 않는다.전환 근거poison·panic·deadlock이 없는 정상 경로의 결과이며,모든 상황을 보장하지 않는다.
    Arc·Mutex 공유 소유권과 락 수명 — join 후 관측정상적으로 10개 스레드가 완료된 뒤 결과를 읽는다.현재 상태·값: 최종 카운터: 10 완료 조건: 모든 join 성공현재 상태·값최종 카운터: 10완료 조건: 모든 join성공전환 근거: poison·panic·deadlock이 없는 정상 경로의 결과이며, 모든 상황을 보장하지 않는다.전환 근거poison·panic·deadlock이 없는 정상경로의 결과이며,모든 상황을 보장하지않는다.

    정상적으로 10개 스레드가 완료된 뒤 결과를 읽는다.

    최종 카운터
    10
    완료 조건
    모든 join 성공

    이번 단계에서 바뀐 점

    • poison·panic·deadlock이 없는 정상 경로의 결과이며, 모든 상황을 보장하지 않는다.

여러 스레드 사이에서 Mutex<T> 공유하기

이제 Mutex<T>를 사용하여 여러 스레드 사이에서 값을 공유하는 시도를 해봅시다.

10개의 스레드를 생성하고 각자 카운터 값을 1씩 증가시켜서 카운터가 0에서 10으로 가도록 할 것입니다.

다음 예제 15-13은 컴파일 에러가 날 것이고, 이 에러를 이용하여 Mutex<T>를 사용하는 방법과 러스트가 이를 고치는 것을 돕는 방법에 대해 학습하겠습니다.

예제 15-13: Mutex<T>에 의해 보호되는 카운터를 각자 증가시키는 10개의 스레드
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());
}

예제 15-12에서 했던 것처럼 Mutex<T> 내부에 i32를 담고 있는 counter 변수를 만듭니다.

다음으로 숫자 범위만큼 반복하여 10개의 스레드를 만듭니다.

thread::spawn을 사용하고 모든 스레드에게 동일한 클로저를 주었습니다.

이 클로저는 카운터를 스레드로 이동시키고, lock 메서드를 호출하여 Mutex<T>의 락을 얻은 다음, 뮤텍스 내의 값을 1만큼 증가시킵니다.

스레드가 자신의 클로저 실행을 끝냈을 때, num은 스코프 밖으로 벗어나고 락이 해제되어 다른 스레드가 이를 얻을 수 있습니다.

메인 스레드에서는 조인 핸들을 전부 모읍니다.

그러고 나서 예제 15-2에서처럼 각 핸들에 join을 호출하여 모든 스레드가 종료되는 것을 확실히 합니다.

그 시점에서 메인 스레드는 락을 얻고 이 프로그램의 결과를 출력합니다.

이 예제가 컴파일되지 않을 것이라고 암시했었죠.

이제 왜 그런지 알아봅시다!

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
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

For more information about this error, try `rustc --explain E0382`.
error: could not compile `shared-state` due to previous error

이 에러 메시지는 counter 값이 루프의 이전 반복에서 이동되었다고 설명합니다.

러스트는 락 counter의 소유권을 여러 스레드로 옮길 수 없음을 말하고 있습니다.

14장에서 설명했던 복수 소유자 메서드를 가지고 이 컴파일 에러를 고쳐봅시다.

복수 스레드와 함께하는 복수 소유권

14장에서는 스마트 포인터 Rc<T>를 사용하여 참조 카운팅 값을 만들어 값에 여러 소유자를 부여했습니다.

여기서도 똑같이 해서 어떻게 되는지 봅시다.

예제 15-14의 Mutex<T>를 Rc<T>로 감싸서 스레드로 소유권을 넘기기 전에 그 Rc<T>를 복제하겠습니다.

예제 15-14: Rc<T>를 사용하여 여러 스레드가 Mutex<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<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/d5a82bbd26e1ad8b7401f6a718a9c57c96905483/library/std/src/thread/mod.rs:704:8
   |
   = note: required by this bound in `spawn`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `shared-state` due to previous error

와우, 이 에러는 정말 장황하네요!

여기 집중할 중요한 부분이 있습니다.

`Rc<Mutex<i32>>` cannot be sent between threads safely(`Rc<Mutex<i32>>`는 스레드간에 안전하게 보낼 수 없습니다).

또한 컴파일러는 그 이유를 말해주고 있습니다.

the trait `Send` is not implemented for `Rc<Mutex<i32>>`(트레이트 `Send`가 `Rc<Mutex<i32>>` 에 대해 구현되지 않았습니다).

Send에 대해서는 다음 절에서 이야기하겠습니다.

이것은 스레드와 함께 사용하는 타입들이 동시적 상황에서 쓰이기 위한 것임을 보장하는 트레이트 중 하나입니다.

안타깝게도, Rc<T>는 스레드를 교차하면서 공유하기에는 안전하지 않습니다.

Rc<T>가 참조 카운트를 관리할 때, 각 clone 호출마다 카운트에 더하고 각 클론이 버려질 때 카운트에서 제합니다.

하지만 Rc<T>는 여러 스레드가 카운트를 갱신할 때 서로 간섭하지 않도록 보장하는 동기화 수단을 사용하지 않습니다.

이는 잘못된 카운트를 야기할 수 있습니다. 결과적으로 메모리 누수를 발생시키거나 아직 다 쓰기 전에 값이 버려질 수 있는 미세한 버그를 낳겠죠.

우리가 원하는 것은 정확히 Rc<T>와 비슷하지만 스레드-안전한 방식으로 참조 카운트를 바꾸는 녀석입니다.

Arc<T>를 이용한 아토믹 참조 카운팅

Arc<T>는 참조 카운트를 원자적으로 갱신하여 여러 스레드가 같은 값의 소유권을 공유하도록 도와줍니다. 다만 Arc<T>의 Send·Sync 구현에는 T: Send + Sync가 필요하며, 내부 데이터를 함께 변경할 때는 이 예제처럼 Mutex<T>로 접근을 보호해야 합니다.

a는 *아토믹(atomic)*을 의미하는데, 즉 이것이 원자적으로 참조자를 세는 (atomically reference counted) 타입임을 뜻합니다.

아토믹은 추가적인 종류의 동시성 기초 재료로서, 여기서는 자세히 다루지 않을 겁니다.

더 자세히 알고 싶으면 std::sync::atomic에 대한 표준 라이브러리 문서를 보세요.

이 시점에서는 아토믹이 기초 타입처럼 동작하지만 스레드를 교차하며 공유해도 안전하다는 것만 알면 됩니다.

그렇다면 여러분은 왜 모든 기초 타입이 아토믹하지 않은지, 그리고 표준 라이브러리 타입은 왜 기본적으로 Arc<T>를 구현에 이용하지 않는지를 궁금해할지도 모르겠습니다.

그 이유는 스레드 안전성이란 것이 정말로 필요할 때만 감내하고 싶을 성능 저하를 일으키기 때문입니다.

싱글스레드 내에서만 값을 연산하는 경우, 아토믹이 제공하는 보장을 강제할 필요 없이 코드는 더 빠르게 실행될 수 있습니다.

예제로 다시 돌아갑시다.

이 예제에서 사용하는 new와 clone은 Arc<T>와 Rc<T> 모두 제공합니다. 따라서 여기서는 use 라인과 그 호출의 타입 이름을 바꾸어 수정합니다. 두 타입의 모든 API와 스레드 경계가 같다는 뜻은 아닙니다.

예제 15-15의 코드는 마침내 컴파일 및 실행이 될 것입니다.

예제 15-15: Arc<T>를 사용하여 Mutex<T>를 감싸서 여러 스레드 사이에서 소유권을 공유할 수 있도록 하기
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>를 사용하여 각 스레드가 해당 부분의 최종 결과를 업데이트하도록 할 수 있습니다.

Arc<Mutex<T>> 조합은 컴파일 에러를 지나 최종 구조에 도달하는 흐름으로 보면 더 선명합니다.

단순한 정수 갱신에는 표준 라이브러리의 std::sync::atomic 모듈의 정수 원자 타입도 선택할 수 있습니다. 이들은 Mutex<T>와 별도 타입이며 Mutex<T> 자체는 std::sync에 있습니다. 여러 값 사이의 불변식을 함께 지키는 연산은 단일 원자 정수만으로 해결되는지 따로 판단해야 합니다.

이들 타입은 기초 타입에 대한 안전하고, 동시적이며, 원자적인 접근을 제공합니다.

이 예제에서는 기초 타입에 대해 Mutex<T>를 사용하여 Mutex<T>가 동작하는 방식에 집중하였습니다.


RefCell<T>/Rc<T>와 Mutex<T>/Arc<T> 간의 유사성

counter가 불변이지만 내부 값에 대한 가변 참조자를 가져올 수 있었음을 알아채셨을 수도 있습니다; 이는 Cell 가족이 그러하듯 Mutex<T>가 내부 가변성을 제공한다는 의미입니다.

14장에서 Rc<T>의 내용물을 변경할 수 있도록 하기 위해 RefCell<T>를 사용한 것과 같은 방식으로, Arc<T> 내부의 값을 변경하기 위해 Mutex<T>를 이용합니다.

주목할 만한 또 다른 세부 사항은 Mutex<T>를 사용할 때 러스트가 모든 종류의 논리 에러로부터 보호해줄 수 없다는 것입니다.

14장에서 Rc<T>를 사용하는 것은 두 Rc<T> 값들이 서로를 참조하여 메모리 누수를 야기하는 순환 참조자를 만들 위험성이 따라오는 것이었음을 상기해봅시다.

이와 유사하게, Mutex<T>에는 *데드락(deadlock)*을 생성할 위험성이 따라옵니다.

이것은 어떤 연산이 두 개의 리소스에 대한 락을 얻을 필요가 있고 두 개의 스레드가 락을 하나씩 얻는다면, 서로가 서로를 영원히 기다리는 형태로 발생됩니다.

데드락을 분석하려면 먼저 종이에 두 작업의 락 보유·대기를 표시해 보세요. A가 X를 보유하고 Y를 기다리며, B가 Y를 보유하고 X를 기다리는 상태에서는 어느 작업도 다음 락을 얻지 못합니다. 두 작업이 같은 X→Y 순서로 획득하도록 바꾸면 이 역순 획득의 순환 대기 원인을 제거할 수 있습니다.

Mutex<T>와 MutexGuard에 대한 표준 라이브러리 API 문서가 유용한 정보를 제공합니다.

예시

Arc의 소유권 공유, 짧은 임계 구역, 완료 뒤 읽기를 별개의 줄로 확인하는 전체 프로그램입니다.

src/main.rs
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let total = Arc::new(Mutex::new(0));
    let mut handles = Vec::new();
    for amount in [2, 4, 6] {
        let shared = Arc::clone(&total);
        handles.push(thread::spawn(move || {
            let mut guard = shared.lock().expect("poisoned");
            *guard += amount;
        }));
    }
    for handle in handles {
        handle.join().expect("worker panicked");
    }
    println!("total={}", *total.lock().expect("poisoned"));
}

모든 작업이 정상 종료하면 total=12입니다. Arc::clone은 같은 Mutex 할당의 소유자를 늘리고, guard를 통한 더하기는 한 작업씩 직렬화합니다. 어느 작업이 먼저 락을 얻는지는 정하지 않아도 각 덧셈을 정확히 한 번 완료한 뒤의 합은 같습니다.

어디까지 함께 잠가야 할까요? 여기서 *guard += amount는 현재 값을 읽고 더한 값을 쓰기까지 같은 guard를 유지합니다. 읽기와 쓰기를 각각 잠가도, 그 사이에 guard를 버리면 다른 작업이 값을 바꿀 수 있습니다. 그러면 오래된 복사본으로 나중에 덮어쓰는 갱신 손실이 생깁니다. 데이터에 대한 배타 접근과 여러 단계로 된 업무 연산의 정확성은 함께 검토해야 합니다.

스스로 잠금과 완료 구별하기

별도 정상 실행에서 초기 값을 5, 세 amount를 1·2·3으로 바꾸면 모든 join 뒤의 값은 무엇인가요? 세 join을 생략하고 main이 락만 얻어 읽는다면 그 값도 고정되나요? 마지막으로 main이 먼저 같은 Mutex의 guard를 얻어 살아 있게 둔 채, 그 락이 필요한 작업의 join을 기다린다고 합시다. 작업과 main이 각각 무엇을 기다리는지 설명하세요.

합과 대기 조건 확인

모든 증가가 완료된 뒤에는 5+1+2+3=11입니다. join을 생략한 읽기도 배타 접근이라 데이터 경합은 피하지만 아직 완료되지 않은 덧셈이 있을 수 있어 최종 11을 보장하지 않습니다. 출력 시점에 끝난 작업의 부분 합을 읽습니다. main이 guard를 유지하며 join하면 작업은 main의 unlock을, main은 작업의 완료를 기다립니다. guard의 해제를 join 뒤에 두면 서로 진행할 수 없습니다. RAII가 있다는 이유로 대기 순환까지 사라지지는 않습니다.

공유 접근과 유일 접근의 차이는 Mutex::lock 및 get_mut, payload의 조건은 Arc의 스레드 안전성을 읽어 확인하세요.

이제 Send와 Sync 트레이트와 이를 커스텀 타입과 함께 사용하는 방법을 이야기하는 것으로 이 장을 마무리하겠습니다.


공유 상태 동시성 학습 판단 기준표

이 다이어그램은 공유 상태 동시성 내용을 예제 코드에 적용할 때 먼저 볼 조건과 나중에 검증할 조건을 나눕니다.

각 접근은 안전한데 합이 틀리는 경우

다음 전체 프로그램의 두 작업은 각각 2와 4를 더하려 합니다. Barrier::new(2)는 두 작업이 모두 wait()에 도착해야 그다음 줄로 진행하게 하는 만남 지점입니다. 같은 시각에 CPU에서 실행된다는 보장은 아닙니다. 이 프로그램에서는 두 작업이 이전 값을 읽은 뒤에만 쓰기를 시작하도록 사용합니다. 두 스레드 생성과 join은 성공하고, 패닉·외부 종료는 없다고 가정하세요.

src/main.rs
use std::sync::{Arc, Barrier, Mutex};
use std::thread;

fn main() {
    let total = Arc::new(Mutex::new(0_i32));
    let gate = Arc::new(Barrier::new(2));
    let mut handles = Vec::new();
    for amount in [2, 4] {
        let shared = Arc::clone(&total);
        let gate = Arc::clone(&gate);
        handles.push(thread::spawn(move || {
            let before = {
                let guard = shared.lock().unwrap();
                *guard
            };
            gate.wait();
            let mut guard = shared.lock().unwrap();
            *guard = before + amount;
        }));
    }
    for handle in handles {
        handle.join().unwrap();
    }
    println!("total={}", *total.lock().unwrap());
}

가능한 최종 출력 값을 모두 찾고 total=6이 가능한지 설명하세요. 모든 읽기·쓰기에 락이 있는데도 왜 덧셈이 손실되는지, 클로저 내부를 어떻게 고쳐야 하는지도 답하세요. 마지막으로 읽기부터 쓰기까지 같은 guard를 유지한 채 gate.wait()를 호출하는 수정은 안전한지 대기 관계로 판단하세요.

보호할 연산과 대기 위치로 풀이하기

가능한 출력은 total=2 또는 total=4입니다. 두 작업 모두 초기 값 0을 복사한 뒤 내부 블록을 벗어나 guard를 버립니다. 두 번째 작업이 만남 지점에 도착하기 전에는 어느 작업도 쓰기를 시작할 수 없으므로 두 before는 모두 0입니다. 이후 각 작업은 자기 계산 결과 2 또는 4를 차례로 쓰며, 마지막 쓰기가 앞선 쓰기를 덮습니다. 따라서 이 조건에서는 6이 나올 수 없습니다. 개별 메모리 접근은 배타적이어도 전체 읽기·수정·쓰기의 논리가 나뉘어 있습니다.

클로저 내부를 다음처럼 바꾸면 두 작업 모두 만남 지점을 지난 뒤, 한 guard 안에서 현재 값을 읽고 더한 값을 씁니다. 두 작업의 순서는 정해지지 않아도 두 join 뒤 최종 출력은 total=6입니다.

gate.wait();
let mut guard = shared.lock().unwrap();
*guard += amount;

반대로 한 작업이 읽기 guard를 유지한 채 gate.wait()에서 두 번째 작업을 기다리면, 두 번째 작업은 읽기를 위한 같은 락을 얻지 못해 만남 지점에 도착할 수 없습니다. 첫 작업은 두 번째 작업의 도착을, 두 번째 작업은 첫 작업의 unlock을 기다립니다. 따라서 guard를 유지한 채 그 만남 지점을 기다리는 수정은 데드락을 만듭니다. 오래된 값으로 덮어쓰지 않는 것과 대기 중 필요한 락을 붙잡지 않는 것을 함께 지켜야 합니다.

만남 지점의 조건은 Barrier::wait, 스코프를 벗어날 때의 해제는 MutexGuard의 설명과 연결됩니다.