Devin.KR

Rust · 심화

트레이트와 동시성으로 깊어지는 Rust

스레드와 공유 상태 - Arc·Mutex·채널

std::thread::spawn 과 join, scoped thread, Arc<Mutex<T>>, mpsc 채널, Send·Sync 의 의미, 교착 상태 피하기. 출력 순서를 결정적으로 만든다

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서는 구조체 속 참조의 수명과 'static 에 대한 오해를 정리했다. 이 장에서는 그 지식을 스레드(thread)로 옮긴다. 스레드는 호출한 함수보다 오래 살 수 있어서, 빌림 규칙이 가장 엄격하게 적용되는 곳이다. 컴파일러가 막아 주는 지점을 하나씩 짚으며 창고 재고 처리를 여러 스레드로 나눈다. 실행할 때마다 순서가 달라지는 출력을 어떻게 결정적으로 만드는지도 다룬다.

  • thread::spawn 과 join 으로 스레드를 만들고 결과와 패닉을 받는다.
  • 스코프 스레드(scoped thread)로 Arc 없이 지역 데이터를 빌려 쓴다.
  • Arc<Mutex<T>> 로 재고 같은 공유 상태를 안전하게 갱신한다.
  • mpsc 채널(channel)로 결과를 모으고, 정렬로 출력 순서를 고정한다.
  • Send, Sync 의 의미를 읽고 교착 상태(deadlock)를 피하는 잠금 순서를 정한다.

문제 상황

창고에 주문이 일곱 건 들어왔다. 품목은 볼트, 너트, 와셔 세 가지이고 재고는 각각 50, 30, 10개다. 주문을 한 줄로 처리하면 느리지 않은 규모이지만, 실제로는 주문마다 검수와 로그 기록이 붙어 시간이 걸린다고 가정하자. 그래서 품목마다 작업자 스레드를 하나씩 두고 같은 재고 장부를 갱신하게 하려 한다.

여기서 두 가지 문제가 생긴다. 첫째, 여러 스레드가 한 장부를 동시에 고치면 데이터 경쟁(data race)이 생길 수 있다. 둘째, 스레드가 끝나는 순서는 실행할 때마다 달라서 "주문 3 처리 완료" 같은 로그가 뒤섞인다. 로그가 뒤섞이면 테스트도 디버깅도 어렵다. 이 장의 목표는 병렬로 처리하되 같은 입력에 항상 같은 출력이 나오게 하는 것이다.

스레드 만들기: spawn, join, scope

spawn 은 'static 을 요구한다

thread::spawn 은 클로저를 받아 새 스레드에서 실행하고 JoinHandle 을 돌려준다. 새 스레드는 만든 함수가 끝난 뒤에도 살아 있을 수 있다. 그래서 클로저는 지역 변수를 빌릴 수 없고, 값의 소유권을 move 로 가져가야 한다.

use std::thread;

fn main() {
    let qtys: Vec<u32> = vec![50, 30, 10];
    let h = thread::spawn(|| qtys.iter().sum::<u32>());
    println!("{}", h.join().unwrap());
}

컴파일러는 E0373 으로 거절한다. 요지는 "클로저가 현재 함수보다 오래 살 수 있는데 함수가 소유한 qtys 를 빌린다"는 것이다. 클로저 앞에 move 를 붙이면 qtys 가 스레드로 넘어가고 오류가 사라진다.

join 은 스레드가 끝날 때까지 기다리고 Result 를 돌려준다. 스레드가 패닉했다면 Err 이고, 정상 종료했다면 클로저의 반환값이 Ok 에 담긴다. 예제는 unwrap 으로 패닉을 그대로 전파하지만, 실무에서는 Err 를 받아 해당 작업만 실패로 기록하기도 한다.

스코프 스레드는 지역 데이터를 빌린다

thread::scope 안에서 만든 스레드는 스코프가 끝나기 전에 모두 합류하는 것이 보장된다. 그래서 컴파일러가 'static 을 요구하지 않고, 지역 변수를 참조로 넘길 수 있다. 읽기만 하는 병렬 계산에는 Arc 로 감쌀 필요가 없다. 완성 코드의 품목별 주문 수량 합산이 이 방식이다.

스레드를 만드는 두 방법의 차이
구분thread::spawnthread::scope선택 기준
클로저 조건'static, 보통 move스코프보다 오래 사는 참조면 충분지역 데이터를 쓰면 scope
합류 시점직접 join스코프 종료 시 자동 join누락을 막으려면 scope
스레드 수명함수를 넘어 살 수 있음스코프 안에서만백그라운드 작업이면 spawn

공유 상태: Arc, Mutex, 채널

Arc<Mutex<T>>

스레드 여러 개가 같은 장부를 가지려면 소유자가 여럿이어야 한다. Arc 는 참조 횟수를 원자적으로 세는 공유 소유 포인터다. 앞서 본 단일 스레드용 공유 포인터와 달리 횟수 증감이 스레드에 안전하다. 다만 Arc 는 내용을 불변으로만 보여 준다. 고치려면 Mutex(뮤텍스, 상호 배제 잠금)가 필요하다.

Arc 하나가 가리키는 힙 블록 안에 Mutex 와 HashMap 이 있고, 스레드 네 개가 이를 함께 소유한다

lock() 은 잠금을 얻을 때까지 기다렸다가 MutexGuard 를 돌려준다. 가드가 스코프를 벗어나 drop 되면 잠금이 풀린다. 잠금을 푸는 코드를 따로 쓰지 않으므로 풀기를 잊는 실수가 없다. 반대로 말하면, 가드가 살아 있는 동안은 계속 잠겨 있다. lock() 이 Result 를 돌려주는 이유는 중독(poisoning) 때문이다. 잠금을 쥔 스레드가 패닉하면 그 뮤텍스는 중독 상태가 되고 이후 lock() 이 Err 를 돌려준다. 이 장은 unwrap 으로 처리한다.

잠금 범위는 짧을수록 좋다. 이 장의 process 함수는 재고 확인과 차감을 가드 하나로 끝낸다. 확인과 차감 사이에 잠금을 풀면 그 틈에 다른 스레드가 같은 재고를 가져가는 경쟁이 생긴다. 확인과 갱신은 한 가드 안에서 해야 한다.

채널로 결과 모으기

mpsc::channel 은 다중 생산자 단일 소비자(multi-producer, single-consumer) 채널이다. 송신 쪽 Sender 는 clone 으로 늘리고, 수신 쪽 Receiver 는 하나뿐이다. 작업자는 결과를 보내기만 하고 장부 바깥의 출력 순서에는 관여하지 않는다. 수신 쪽이 모아서 한 번에 정렬해 출력한다.

수신 반복은 모든 Sender 가 drop 되어야 끝난다. 원래 만든 tx 를 남겨 두면 작업자가 모두 끝나도 반복이 끝나지 않는다. 복제본을 나눠 준 뒤 원본은 drop(tx) 로 버려야 한다.

공유 상태와 메시지 전달의 비교
방식데이터의 위치동기화 지점어울리는 경우
Arc<Mutex<T>>모두가 한 곳을 본다lock 호출재고 장부처럼 모두가 갱신
mpsc 채널값이 스레드 사이를 이동한다send, recv작업 결과 수집, 로그
스코프 + 참조호출자가 소유한다스코프 종료읽기 전용 병렬 계산

Send 와 Sync, 그리고 교착 상태

Send 와 Sync 가 뜻하는 것

Send 는 값의 소유권을 다른 스레드로 옮겨도 안전하다는 표지이고, Sync 는 &T 를 여러 스레드가 동시에 가져도 안전하다는 표지다. 정확히는 T: Sync 와 &T: Send 가 같은 말이다. 둘 다 컴파일러가 필드를 보고 자동으로 붙이는 마커 트레이트이고, 직접 구현할 일은 unsafe 를 다루는 뒤 장에서나 생긴다. thread::spawn 은 클로저가 Send 일 것을 요구하므로, 안전하지 않은 공유는 컴파일 단계에서 걸린다.

use std::rc::Rc;
use std::thread;

fn main() {
    let book = Rc::new(5);
    thread::spawn(move || println!("{book}"));
}

오류의 요지는 "Rc<i32> 는 스레드 사이에서 안전하게 보낼 수 없다. Send 트레이트가 구현되지 않았다"이다. Rc 의 참조 횟수가 원자적이지 않아서 두 스레드가 동시에 증감하면 횟수가 틀어지기 때문이다. Arc 로 바꾸면 해결된다.

자주 만나는 타입의 Send, Sync 여부
타입SendSync이유
Rc<T>아니오아니오참조 횟수가 원자적이지 않다
Arc<T>T 가 Send 이면서 Sync같음횟수는 안전하지만 T 는 여러 곳에서 보인다
RefCell<T>T 가 Send 이면 예아니오빌림 검사가 스레드에 안전하지 않다
Mutex<T>T 가 Send 이면 예T 가 Send 이면 예잠금이 접근을 직렬화한다

같은 이유로 Arc<RefCell<i32>> 도 스레드로 보낼 수 없다. 오류의 요지는 "RefCell<i32> 는 스레드 사이에서 안전하게 공유할 수 없다. Sync 가 구현되지 않았다"이다. 내부 가변성이 필요하면 RefCell 대신 Mutex 를 쓴다.

교착 상태 피하기

잠금이 둘 이상이면 교착 상태가 가능하다. 스레드 1은 선반 A 를 쥐고 B 를 기다리고, 스레드 2는 B 를 쥐고 A 를 기다리면 둘 다 영영 진행하지 못한다. 컴파일러는 이를 잡지 못한다. 규칙으로 막아야 한다.

잠금 순서가 서로 반대면 대기가 순환해 교착이 생기고, 모두 A 다음 B 순서로 잠그면 순환이 생기지 않는다

가장 단순한 규칙은 모든 스레드가 같은 순서로 잠그는 것이다. 완성 코드의 move_units 는 두 선반을 이름 순으로 잠근다. 옮기는 방향이 A 에서 B 든 B 에서 A 든 잠그는 순서는 항상 A, B 다. 같은 선반을 출발지와 도착지로 동시에 받으면 한 스레드가 자기 자신을 기다리므로 먼저 거른다. 그 밖의 원칙도 있다. 잠금을 쥔 채 join 이나 recv 로 다른 스레드를 기다리지 않는다. 잠금을 쥔 채 오래 걸리는 일을 하지 않는다.

출력 순서를 결정적으로 만드는 방법

스레드가 도는 순서는 운영체제가 정한다. 그래서 스레드 안에서 곧바로 println! 하면 순서가 보장되지 않는다. 이 장은 세 가지를 쓴다. 첫째, 스레드 안에서는 출력하지 않고 값을 돌려주거나 채널로 보낸다. 둘째, 모은 결과를 주문 번호 같은 안정적인 키로 정렬한 뒤 한 곳에서 출력한다. 셋째, 결과가 처리 순서에 좌우되는 작업은 키별로 한 스레드에 몰아 순서를 고정한다. 같은 품목의 주문을 한 작업자가 번호순으로 처리하면, 품목 사이의 실행 순서가 어떻든 재고 결과는 같다.

완성 코드

use std::collections::HashMap;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread;

const INITIAL: [(&str, u32); 3] = [("bolt", 50), ("nut", 30), ("washer", 10)];

struct Order {
    id: u32,
    sku: &'static str,
    qty: u32,
}

enum Outcome {
    Shipped { id: u32, sku: &'static str, qty: u32 },
    Rejected { id: u32, sku: &'static str, missing: u32 },
}

impl Outcome {
    fn id(&self) -> u32 {
        match self {
            Outcome::Shipped { id, .. } | Outcome::Rejected { id, .. } => *id,
        }
    }
}

type Stock = Arc<Mutex<HashMap<&'static str, u32>>>;

fn process(stock: &Stock, order: &Order) -> Outcome {
    let mut map = stock.lock().unwrap();
    let have = map.entry(order.sku).or_insert(0);
    if *have >= order.qty {
        *have -= order.qty;
        Outcome::Shipped { id: order.id, sku: order.sku, qty: order.qty }
    } else {
        Outcome::Rejected { id: order.id, sku: order.sku, missing: order.qty - *have }
    }
}

struct Shelf {
    name: &'static str,
    qty: Mutex<u32>,
}

fn move_units(from: &Shelf, to: &Shelf, n: u32) -> bool {
    if from.name == to.name {
        return false;
    }
    let from_first = from.name < to.name;
    let (first, second) = if from_first { (from, to) } else { (to, from) };
    let mut g1 = first.qty.lock().unwrap();
    let mut g2 = second.qty.lock().unwrap();
    let (src, dst) = if from_first {
        (&mut *g1, &mut *g2)
    } else {
        (&mut *g2, &mut *g1)
    };
    if *src < n {
        return false;
    }
    *src -= n;
    *dst += n;
    true
}

fn shuttle(from: &Shelf, to: &Shelf, times: u32) -> u32 {
    let mut done = 0;
    for _ in 0..times {
        if move_units(from, to, 1) {
            done += 1;
        }
    }
    done
}

fn main() {
    // 1. spawn 과 join
    let qtys: Vec<u32> = INITIAL.iter().map(|&(_, q)| q).collect();
    let summer = thread::spawn(move || qtys.iter().sum::<u32>());
    let total = summer.join().unwrap();
    println!("초기 재고 합계: {total}");

    let orders = vec![
        Order { id: 1, sku: "bolt", qty: 20 },
        Order { id: 2, sku: "nut", qty: 25 },
        Order { id: 3, sku: "bolt", qty: 20 },
        Order { id: 4, sku: "washer", qty: 15 },
        Order { id: 5, sku: "nut", qty: 10 },
        Order { id: 6, sku: "bolt", qty: 20 },
        Order { id: 7, sku: "washer", qty: 5 },
    ];
    let skus = ["bolt", "nut", "washer"];

    // 2. 스코프 스레드로 품목별 주문 수량 합산
    let demand: Vec<(&str, u32)> = thread::scope(|s| {
        let orders_ref = &orders;
        let handles: Vec<_> = skus
            .iter()
            .map(move |&sku| {
                s.spawn(move || {
                    let sum = orders_ref
                        .iter()
                        .filter(|o| o.sku == sku)
                        .map(|o| o.qty)
                        .sum::<u32>();
                    (sku, sum)
                })
            })
            .collect();
        handles.into_iter().map(|h| h.join().unwrap()).collect()
    });
    let line: Vec<String> = demand.iter().map(|(s, n)| format!("{s}={n}")).collect();
    println!("품목별 주문 수량: {}", line.join(" "));

    // 3. Arc<Mutex> 장부 + 채널로 결과 수집
    let stock: Stock = Arc::new(Mutex::new(INITIAL.iter().copied().collect()));
    let orders = Arc::new(orders);
    let (tx, rx) = mpsc::channel::<Outcome>();
    let mut workers = Vec::new();
    for sku in skus {
        let tx = tx.clone();
        let stock = Arc::clone(&stock);
        let orders = Arc::clone(&orders);
        workers.push(thread::spawn(move || {
            for order in orders.iter().filter(|o| o.sku == sku) {
                tx.send(process(&stock, order)).unwrap();
            }
        }));
    }
    drop(tx);

    let mut outcomes: Vec<Outcome> = rx.iter().collect();
    for w in workers {
        w.join().unwrap();
    }
    outcomes.sort_by_key(|o| o.id());
    for o in &outcomes {
        match o {
            Outcome::Shipped { id, sku, qty } => println!("주문 {id}: {sku} {qty}개 출고"),
            Outcome::Rejected { id, sku, missing } => {
                println!("주문 {id}: {sku} {missing}개 부족, 거절")
            }
        }
    }
    println!("Arc 참조 수: {}", Arc::strong_count(&stock));
    {
        let guard = stock.lock().unwrap();
        let mut rows: Vec<_> = guard.iter().collect();
        rows.sort();
        for (sku, qty) in rows {
            println!("{sku}: {qty}");
        }
    }

    // 4. 잠금 순서를 맞춘 선반 이동
    let a = Shelf { name: "A", qty: Mutex::new(100) };
    let b = Shelf { name: "B", qty: Mutex::new(100) };
    let (ab, ba) = thread::scope(|s| {
        let t1 = s.spawn(|| shuttle(&a, &b, 100));
        let t2 = s.spawn(|| shuttle(&b, &a, 100));
        (t1.join().unwrap(), t2.join().unwrap())
    });
    println!("이동 성공: A→B {ab}회, B→A {ba}회");
    println!("선반 A={} B={}", a.qty.lock().unwrap(), b.qty.lock().unwrap());
}

줄별 해설

상수와 타입. INITIAL 은 초기 재고 상수다. Order 의 sku 가 &'static str 인 이유는 문자열 리터럴만 담기 때문이며, 이 덕분에 작업자 스레드로 넘겨도 수명 문제가 없다. Outcome 은 출고와 거절을 구분하는 열거형이고, id 메서드는 정렬 키를 꺼낸다. 두 변형의 id 를 한 패턴으로 묶는 or 패턴이 쓰였다.

process. lock().unwrap() 으로 가드를 얻고, entry(...).or_insert(0) 로 품목의 수량 참조를 얻는다. 재고가 충분하면 차감하고 Shipped, 아니면 부족분을 계산해 Rejected 를 돌려준다. 확인과 차감이 같은 가드 안에 있어서 중간에 다른 스레드가 끼어들지 못한다. 가드는 함수가 끝날 때 drop 되며 잠금이 풀린다.

move_units. 이름을 비교해 항상 사전순으로 앞선 선반을 먼저 잠근다. from_first 로 가드가 출발지인지 도착지인지 다시 맞춰 src, dst 가변 참조를 만든다. 재고가 모자라면 일찍 반환하고, 그 경우에도 가드는 자동으로 풀린다. shuttle 은 이동을 반복하고 성공 횟수를 센다.

1단계: spawn 과 join. qtys 를 move 클로저로 넘기고, join().unwrap() 으로 합계를 받는다. 값은 스레드의 반환값으로 돌아오므로 공유 상태가 필요 없다.

2단계: 스코프 스레드. orders_ref 는 orders 에 대한 참조다. 참조는 복사되므로 move 클로저 안팎에서 여러 번 쓸 수 있다. 품목마다 스레드 하나가 filter 로 자기 품목의 수량을 합한다. 스코프가 orders 보다 먼저 끝나므로 Arc 가 필요 없다. 핸들을 모아 skus 순서대로 join 하기 때문에 결과 순서는 항상 볼트, 너트, 와셔다.

3단계: 장부와 채널. 스코프가 끝나 orders 의 빌림이 모두 풀렸으므로 Arc::new(orders) 로 소유권을 옮긴다. 반복마다 tx, stock, orders 를 복제해 이름을 가린(shadowing) 뒤 move 로 스레드에 준다. 한 작업자는 한 품목의 주문만 번호순으로 처리하고 결과를 보낸다. 반복이 끝나면 drop(tx) 로 원본 송신자를 버려 수신 반복이 끝나게 한다. rx.iter().collect() 는 모든 결과가 올 때까지 기다린다. 이어서 작업자를 join 하고, sort_by_key 로 주문 번호순 정렬을 한 뒤 출력한다. 작업자들이 모두 합류했으므로 Arc::strong_count 는 1 이다. 마지막 블록에서는 장부를 잠그고 항목을 정렬해 출력한다. HashMap 순회 순서는 정해져 있지 않으므로 정렬이 필수다.

4단계: 선반 이동. 두 스레드가 서로 반대 방향으로 100번씩 옮긴다. 각 방향의 출발 선반은 100개로 시작하므로 100번 모두 성공하고 최종 수량은 처음과 같다. 잠금 순서가 같으므로 어떤 교차 실행에서도 교착이 생기지 않는다. 마지막 출력은 두 가드를 한 문장 안에서 얻지만 서로 다른 뮤텍스라 문제가 없다.

실행 결과

$ cargo run
초기 재고 합계: 90
품목별 주문 수량: bolt=60 nut=35 washer=20
주문 1: bolt 20개 출고
주문 2: nut 25개 출고
주문 3: bolt 20개 출고
주문 4: washer 5개 부족, 거절
주문 5: nut 5개 부족, 거절
주문 6: bolt 10개 부족, 거절
주문 7: washer 5개 출고
Arc 참조 수: 1
bolt: 10
nut: 5
washer: 5
이동 성공: A→B 100회, B→A 100회
선반 A=100 B=100

몇 번을 실행해도 같은 출력이 나온다. 스레드의 실행 순서는 바뀌지만, 결과를 정렬해서 출력하고 같은 품목은 한 스레드가 순서대로 처리하기 때문이다.

실무에서 자주 틀리는 것

1. move 없이 지역 변수를 spawn 에 넘긴다

틀린 코드다. 오류 요지는 "클로저가 현재 함수보다 오래 살 수 있지만 names 를 빌린다"이다.

let names = vec![String::from("bolt")];
let h = thread::spawn(|| println!("{}", names.len()));
h.join().unwrap();

고친 코드는 두 가지다. 소유권을 넘기거나, 이후에도 names 를 써야 하면 스코프 스레드로 빌린다.

let names = vec![String::from("bolt")];
thread::scope(|s| {
    s.spawn(|| println!("{}", names.len()));
});
println!("{}", names.len());

2. 스레드 안에서 바로 출력해 순서가 흔들린다

틀린 코드는 실행마다 줄 순서가 달라질 수 있다.

for order in orders.iter() {
    let stock = Arc::clone(&stock);
    thread::spawn(move || println!("{}", process_and_format(&stock)));
}

고친 방식은 스레드가 문자열이나 결과 값을 채널로 보내고, 메인 스레드가 키로 정렬해 한 번에 출력하는 것이다. 위 완성 코드의 outcomes.sort_by_key 가 그 예다. 합류하지 않은 스레드는 메인이 끝나면 함께 종료되므로, 핸들을 모아 join 하는 것도 잊지 않아야 한다.

3. 가드를 쥔 채 다른 스레드를 기다린다

틀린 코드다. 메인이 잠금을 쥔 채 join 하고, 스레드는 같은 잠금을 기다리므로 영원히 끝나지 않는다.

let guard = stock.lock().unwrap();
let s2 = Arc::clone(&stock);
let h = thread::spawn(move || s2.lock().unwrap().len());
h.join().unwrap();
println!("{}", guard.len());

고친 코드는 잠금 범위를 블록으로 줄이거나, 잠금이 필요 없어진 뒤에 join 한다.

let h = {
    let s2 = Arc::clone(&stock);
    thread::spawn(move || s2.lock().unwrap().len())
};
let n = h.join().unwrap();
println!("{}", n);

4. 원본 Sender 를 drop 하지 않아 수신이 끝나지 않는다

틀린 코드는 컴파일은 되지만 멈춘 채로 있다.

let (tx, rx) = mpsc::channel::<u32>();
for i in 0..3 {
    let tx = tx.clone();
    thread::spawn(move || tx.send(i).unwrap());
}
let all: Vec<u32> = rx.iter().collect(); // tx 원본이 살아 있어 끝나지 않는다

고친 코드는 복제본을 나눠 준 뒤 원본을 버리고, 결과를 정렬해 순서를 고정한다.

let (tx, rx) = mpsc::channel::<u32>();
for i in 0..3 {
    let tx = tx.clone();
    thread::spawn(move || tx.send(i).unwrap());
}
drop(tx);
let mut all: Vec<u32> = rx.iter().collect();
all.sort();

한눈에 보기

이 장의 도구와 쓰임새 요약
도구역할주의할 점이 장의 예
thread::spawn + join독립 스레드와 결과 회수move 필요, join 은 Result초기 재고 합계
thread::scope지역 데이터를 빌려 병렬 실행스코프 안에서만 유효품목별 수량 합산
Arc<Mutex<T>>공유 가변 상태잠금 범위는 짧게재고 장부
mpsc 채널결과 전달원본 Sender drop출고 결과 수집
Send, Sync스레드 안전성 표지Rc, RefCell 은 제외컴파일 오류로 확인
잠금 순서 고정교착 방지자기 자신 잠금 주의선반 이동
출력 순서를 고정하는 방법과 한계
방법어떻게비용한계
스레드 안에서 출력 금지값을 반환하거나 전송없음호출자가 출력해야 한다
정렬 후 출력안정적인 키로 sort정렬 시간모든 결과를 모아야 한다
키별 한 스레드같은 품목은 순서대로병렬도 감소키 사이 의존이 없을 때만

연습 문제

  1. 다음 코드는 컴파일되지 않는다. 원인을 말하고, move 를 쓰는 방법과 스코프 스레드를 쓰는 방법으로 각각 고쳐라. let skus = vec!["bolt", "nut"]; let h = thread::spawn(|| skus.len()); println!("{:?}", skus);
  2. Arc<RefCell<u32>> 를 스레드 네 개가 1씩 더하는 용도로 쓰려 한다. 컴파일 오류의 원인을 설명하고, 고친 타입을 쓰라.
  3. 스레드 1은 a.lock() 다음 b.lock(), 스레드 2는 b.lock() 다음 a.lock() 을 호출한다. 교착이 생기는 조건을 설명하고 한 줄 규칙으로 해결하라.
  4. 스레드 세 개가 각각 (i, i * i) 를 채널로 보낸다(i 는 1, 2, 3). 매번 같은 순서로 한 줄씩 출력하는 코드를 써라.

정답과 해설

  1. 원인은 spawn 의 클로저가 skus 를 빌리는데, 스레드가 함수보다 오래 살 수 있기 때문이다. 게다가 뒤에서 skus 를 다시 쓴다. move 로 고치려면 소유권을 넘기므로 뒤의 출력은 쓸 수 없다. 이후에 필요하면 let copy = skus.clone(); 을 만들어 move 로 넘긴다. 스코프 스레드로 고치면 thread::scope(|s| { s.spawn(|| skus.len()); }); 이후에도 skus 를 그대로 쓸 수 있다.
  2. RefCell 은 Sync 가 아니어서 Arc<RefCell<u32>> 는 Send 가 되지 못하고 spawn 이 거절한다. 빌림 검사가 스레드에 안전하지 않기 때문이다. Arc<Mutex<u32>> 로 바꾸고 *counter.lock().unwrap() += 1; 로 더한다.
  3. 스레드 1이 a 를, 스레드 2가 b 를 쥔 상태에서 서로 상대의 잠금을 기다리면 대기가 순환해 둘 다 진행하지 못한다. 규칙은 "모든 스레드가 항상 a 다음 b 순서로 잠근다"이다. 순서가 같으면 먼저 a 를 얻은 쪽만 b 로 넘어가므로 순환이 생기지 않는다.
  4. use std::sync::mpsc;
    use std::thread;
    
    fn main() {
        let (tx, rx) = mpsc::channel::<(u32, u32)>();
        let mut hs = Vec::new();
        for i in 1..=3 {
            let tx = tx.clone();
            hs.push(thread::spawn(move || tx.send((i, i * i)).unwrap()));
        }
        drop(tx);
        let mut rows: Vec<(u32, u32)> = rx.iter().collect();
        for h in hs {
            h.join().unwrap();
        }
        rows.sort();
        for (i, sq) in rows {
            println!("{i}: {sq}");
        }
    }
    도착 순서는 달라질 수 있지만 sort 후 출력하므로 항상 1: 1, 2: 4, 3: 9 가 나온다. drop(tx) 가 없으면 collect 가 끝나지 않는다.

동기화 도구의 표준 설명은 std::sync 문서에서 확인할 수 있다. 다음에는 연관 타입과 newtype 으로 제네릭을 더 정밀하게 다루는 방법을 살펴본다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.