이번 글에서 다룰 내용
Rust의 동시성 모델은 "데이터 경쟁을 컴파일 단계에서 막자"는 목표를 따릅니다. 이번 편에서는 표준 라이브러리에서 바로 쓸 수 있는 스레드와 채널, 그리고 [[send-sync|Send와 Sync]] 트레이트가 의미하는 바를 직관적으로 정리합니다.
스레드 생성과 조인
std::thread 모듈은 OS 스레드를 직접 생성합니다. spawn은 클로저를 받아 새 스레드를 실행하고, join은 실행이 끝날 때까지 기다립니다.
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=5 {
println!("worker {i}");
thread::sleep(Duration::from_millis(20));
}
});
println!("main thread continues");
handle.join().expect("thread panicked");
}
스레드에서 panic!이 발생하면 join 시점에 Err로 전달되므로, expect나 match로 에러를 처리합니다.
move 클로저와 소유권
새 스레드에 데이터를 넘길 때는 move 클로저를 사용해 명시적으로 소유권을 이동시켜야 합니다.
fn main() {
let numbers = vec![1, 2, 3];
let handle = std::thread::spawn(move || {
println!("sum = {}", numbers.iter().sum::<i32>());
});
handle.join().unwrap();
}
move를 붙이지 않으면 스레드가 끝나기 전에 numbers를 다시 사용하려 할 수 있으므로 컴파일 오류가 발생합니다. 이 규칙 덕분에 데이터 경쟁이 줄어듭니다.
즉, thread를 만들 때 move가 자주 붙는 이유는 "새 스레드가 사용할 데이터를 명확히 넘겨준다"는 뜻이라고 이해하면 쉽습니다.
채널로 메시지 전달하기
스레드 간 데이터를 공유하는 대신, 소유권을 메시지로 전달하는 것이 안전합니다. std::sync::mpsc 채널을 사용하면 송신자와 수신자를 나눌 수 있습니다.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let msgs = vec!["prepare", "compile", "ship"];
for msg in msgs {
tx.send(msg.to_string()).unwrap();
}
});
for received in rx {
println!("got: {received}");
}
}
수신자 rx는 이터레이터처럼 동작해 송신자가 닫힐 때까지 값을 받습니다. 송신 측이 복수 필요하면 tx.clone()을 사용하면 됩니다.
Send와 Sync 직관
Send: 값을 다른 스레드로 이동(move)해도 안전하다는 뜻입니다. 대부분의 기본 타입과Vec,String은 자동으로Send입니다.Sync:&T참조를 여러 스레드가 동시에 읽어도 안전하다는 뜻입니다.Sync타입의 참조는Send로 간주됩니다.
컴파일러는 스레드를 생성하거나 채널로 값을 보낼 때 해당 타입이 Send인지 확인합니다. 예를 들어 [[rc|Rc<T>]]는 참조 카운트를 원자적으로 관리하지 않기 때문에 Send가 아니므로 [[arc|Arc<T>]]를 사용해야 합니다. 마찬가지로 내부 가변성을 스레드 간 공유하려면 [[mutex|Mutex<T>]]와 같은 동기화 도구가 필요합니다.
가장 자주 보는 공유 패턴은 아래 조합입니다.
use std::sync::{Arc, Mutex};
fn main() {
let count = Arc::new(Mutex::new(0));
let worker = Arc::clone(&count);
std::thread::spawn(move || {
*worker.lock().unwrap() += 1;
}).join().unwrap();
println!("count = {}", *count.lock().unwrap());
}
여기서 [[arc|Arc]]는 여러 스레드가 소유권을 공유하게 하고, [[mutex|Mutex]]는 한 번에 한 스레드만 값을 바꾸게 합니다.
스레드 + 채널 예제: 병렬 텍스트 처리
간단한 로그 처리 작업을 병렬로 나누어 보겠습니다.
use std::sync::mpsc;
use std::thread;
fn main() {
let lines = vec![
"INFO - start", "WARN - retry", "INFO - done",
];
let (tx, rx) = mpsc::channel();
let producer = thread::spawn(move || {
for line in lines {
tx.send(line.to_string()).unwrap();
}
});
let consumer = thread::spawn(move || {
let mut warn_count = 0;
for msg in rx {
if msg.contains("WARN") {
warn_count += 1;
}
}
println!("warn count = {warn_count}");
});
producer.join().unwrap();
consumer.join().unwrap();
}
메시지를 통해 데이터 소유권을 넘겨서, 공유 뮤텍스 없이도 작업을 안전하게 분리할 수 있습니다.
CodeSandbox로 이어서 실습하기
아래 샌드박스는 CodeSandbox의 Rust starter입니다. 이번 글의 핵심 코드를 src/main.rs에 옮기고, cargo check와 cargo run 결과를 나란히 보면서 컴파일 메시지와 실행 출력을 비교해 보세요.
💬 댓글
이 글에 대한 의견을 남겨주세요