Rust 동시성 | Thread, Channel, Arc, Mutex
이 글의 핵심
use std::thread; use std::time::Duration;.
시리즈 안내
들어가며
Rust는 데이터 레이스를 컴파일 단계에서 막는 쪽에 가깝습니다. 스레드 간 공유는 Arc·Mutex로, 메시지 전달은 채널로 처리하는 패턴이 흔합니다. 공유 자원의 열쇠를 누가 쥐는지가 여전히 소유권 규칙과 연결됩니다.
Go의 고루틴·채널도 “통신으로 동기화” 쪽에 서고, Kotlin 코루틴은 스레드 풀·디스패처 위에서 협력적으로 돌아갑니다. OS 스레드를 직접 다루는 흐름은 Java Thread·C++ std::thread와 나란히 보면 좋습니다.
스레드 생성과 move 클로저로 데이터 넘기기
기본 스레드
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("스레드: {}", i);
thread::sleep(Duration::from_millis(100));
}
});
for i in 1..5 {
println!("메인: {}", i);
thread::sleep(Duration::from_millis(100));
}
handle.join().unwrap();
}
thread::spawn은 새 OS 스레드를 만들고 JoinHandle을 돌려줍니다. 실행해 보면 “메인”과 “스레드” 출력이 섞여 나오고, 실행할 때마다 순서가 조금씩 다를 수 있습니다. 두 스레드의 실행 순서는 OS 스케줄러가 정하기 때문입니다. handle.join()은 스레드가 끝날 때까지 기다리는데, 이 줄을 빼면 메인 함수가 먼저 끝나는 순간 프로세스 전체가 종료되어 스레드는 9까지 출력하지 못하고 중간에 잘립니다. C++의 std::thread는 join 없이 소멸되면 프로그램을 강제 종료하지만, Rust는 핸들을 버리면 스레드를 분리(detach)할 뿐이라 이런 “조용히 잘리는” 결과가 됩니다.
join()이 Result를 반환하는 이유는 스레드 안에서 panic이 났을 수 있기 때문입니다. 자식 스레드의 panic은 메인 스레드로 전파되지 않고, join()의 Err로 돌아옵니다. 여기서 unwrap()을 하면 자식의 panic을 메인에서 다시 일으키는 셈입니다. 스레드의 계산 결과는 클로저의 반환값으로 돌려받을 수 있어서, let sum = handle.join().unwrap();처럼 쓰면 공유 변수 없이 결과를 모을 수 있습니다(뒤의 청크 합산 예제가 이 방식입니다).
데이터 전달
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("벡터: {:?}", v);
});
handle.join().unwrap();
// v는 더 이상 사용 불가 (소유권 이동)
}
move 키워드가 이 예제의 핵심입니다. move 없이 쓰면 클로저는 v를 빌리려고 하는데, 컴파일러는 새 스레드가 main보다 오래 살 수 있다고 보기 때문에 “closure may outlive the current function, but it borrows v, which is owned by the current function”(E0373) 에러를 냅니다. 바로 아래 줄에서 join()으로 기다린다는 사실은 고려되지 않습니다. thread::spawn의 시그니처가 클로저에 'static 수명을 요구하기 때문으로, 이 제약 덕분에 “스레드가 이미 해제된 지역 변수를 읽는” 버그가 원천적으로 막힙니다. move를 붙이면 v의 소유권이 스레드로 넘어가므로 main에서 v를 쓰면 “borrow of moved value” 에러가 납니다.
소유권을 넘기지 않고 지역 데이터를 스레드에서 빌려 쓰고 싶다면 Rust 1.63부터 들어온 스코프 스레드 thread::scope를 쓰면 됩니다. thread::scope(|s| { s.spawn(|| println!("{:?}", v)); });처럼 쓰면 스코프가 끝나기 전에 모든 스레드가 반드시 합류한다는 것이 보장되므로, 'static이 아닌 참조도 캡처할 수 있습니다. 뒤에서 볼 Arc로 감싸고 복제하는 코드의 상당수가 이것으로 단순해집니다.
mpsc 채널로 메시지 주고받기
기본 채널
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let val = String::from("안녕하세요");
tx.send(val).unwrap();
});
let received = rx.recv().unwrap();
println!("받음: {}", received);
}
mpsc는 “multiple producer, single consumer”의 약자로, 보내는 쪽(tx)은 여럿일 수 있지만 받는 쪽(rx)은 하나입니다. 채널로 값을 보내면 소유권도 함께 넘어가므로, tx.send(val) 뒤에 스레드에서 val을 다시 쓰면 컴파일 에러가 납니다. 이것이 채널이 안전한 이유입니다. 보낸 쪽과 받은 쪽이 같은 데이터를 동시에 만질 방법이 없습니다. recv()는 값이 올 때까지 블로킹하고, 모든 송신자가 사라졌는데 값이 없으면 Err를 돌려줍니다. 기다리지 않고 확인만 하려면 try_recv(), 제한 시간을 두려면 recv_timeout()을 씁니다.
mpsc::channel()은 크기 제한이 없는 채널이라, 받는 쪽이 느리면 보낸 메시지가 메모리에 계속 쌓입니다. 생산자가 소비자보다 빠른 구조라면 mpsc::sync_channel(100)처럼 버퍼 크기를 정해 두세요. 버퍼가 차면 send가 블로킹되어 생산 속도가 자연스럽게 조절됩니다(backpressure).
여러 메시지
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("thread"),
];
for val in vals {
tx.send(val).unwrap();
thread::sleep(Duration::from_millis(100));
}
});
for received in rx {
println!("받음: {}", received);
}
}
여러 송신자
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
let tx2 = tx.clone();
thread::spawn(move || {
tx.send(String::from("스레드 1")).unwrap();
});
thread::spawn(move || {
tx2.send(String::from("스레드 2")).unwrap();
});
for received in rx {
println!("받음: {}", received);
}
}
for received in rx는 채널이 닫힐 때까지 계속 값을 받는 반복문입니다. 채널은 모든 송신자(tx와 그 복제본)가 drop되어야 닫힙니다. 이 예제에서는 tx와 tx2가 모두 스레드로 옮겨졌고 각 스레드가 끝나며 drop되므로, 두 메시지를 받은 뒤 루프가 자연스럽게 끝납니다.
여기서 채널을 처음 쓸 때 가장 흔히 겪는 문제가 생깁니다. 송신자를 반복문으로 여러 개 만들면서 let tx = tx.clone();으로 복제만 하고 원본 tx를 main에 남겨 두면, 모든 스레드가 끝나도 main의 tx가 살아 있어서 채널이 닫히지 않고 for received in rx가 영원히 기다립니다. 에러도 경고도 없이 프로그램이 멈추기 때문에 원인을 찾기 어렵습니다. 스레드를 다 만든 뒤 drop(tx);로 원본을 명시적으로 버려야 합니다. 받은 메시지의 순서도 보장되지 않는다는 점을 기억하세요. 한 송신자 안에서의 순서는 유지되지만, 여러 송신자 사이의 순서는 스케줄링에 따라 매번 달라질 수 있습니다.
Arc와 Mutex로 상태 공유하기
Mutex (상호 배제)
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut num = m.lock().unwrap();
*num = 6;
} // 락 해제
println!("m = {:?}", m);
}
Rust의 Mutex가 C++의 std::mutex와 가장 다른 점은 보호할 데이터를 안에 담는다는 것입니다. Mutex::new(5)의 5는 lock()으로 얻은 가드(MutexGuard)를 통해서만 접근할 수 있으므로, 락을 잡지 않고 데이터를 읽거나 쓰는 코드는 컴파일되지 않습니다. C++에서는 뮤텍스와 데이터가 따로 있어 “이 변수를 만질 때 어떤 락을 잡아야 하는지”를 사람이 기억해야 했던 것과 대조적입니다.
락은 가드가 drop될 때 자동으로 풀립니다. 예제에서 중괄호 블록으로 감싼 이유가 이것으로, 블록이 끝나면 num이 drop되어 락이 해제되고 그 뒤의 println!에서 m을 다시 잠글 수 있습니다. 블록 없이 같은 함수 안에서 m.lock()을 두 번 호출하면 첫 가드가 아직 살아 있어 같은 스레드가 자기 자신을 기다리는 데드락이 됩니다(표준 문서는 이 경우 “반환하지 않는다, 데드락이나 panic이 날 수 있다”고만 명시합니다). Rust의 Mutex는 재진입(reentrant)을 지원하지 않기 때문입니다. 락을 일찍 풀고 싶다면 drop(num);을 명시적으로 호출하면 됩니다.
Arc + Mutex
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!("결과: {}", *counter.lock().unwrap()); // 10
}
Arc가 필요한 이유는 Mutex를 여러 스레드가 함께 소유해야 하기 때문입니다. Mutex 하나를 move로 한 스레드에 넘기면 다른 스레드는 쓸 수 없으니, 참조 카운트로 공동 소유를 표현하는 스마트 포인터가 필요합니다. 단일 스레드용 Rc를 대신 쓰면 “Rc<Mutex<i32>> cannot be sent between threads safely”(E0277) 에러가 납니다. Rc의 참조 카운트는 원자 연산이 아니라서 여러 스레드가 동시에 복제하면 카운트가 깨질 수 있고, 컴파일러가 Send 트레이트로 이를 막는 것입니다. Arc는 “Atomically Reference Counted”로, 카운트 증감에 원자 연산을 씁니다.
Arc::clone(&counter)는 데이터를 복사하는 것이 아니라 참조 카운트만 1 올리고 같은 Mutex를 가리키는 포인터를 하나 더 만듭니다. counter.clone()으로 써도 같지만, Arc::clone 형태가 “깊은 복사가 아니다”라는 의도를 드러내 주어 관례로 쓰입니다. 이 예제처럼 단순한 정수 카운터라면 사실 Mutex 대신 AtomicUsize의 fetch_add가 락 없이 더 가볍게 같은 일을 합니다. Mutex는 여러 필드를 함께 일관되게 바꿔야 할 때 필요합니다.
예제: 병렬 계산
use std::thread;
use std::sync::{Arc, Mutex};
fn parallel_sum(numbers: Vec<i32>) -> i32 {
let chunk_size = numbers.len() / 4;
let numbers = Arc::new(numbers);
let result = Arc::new(Mutex::new(0));
let mut handles = vec![];
for i in 0..4 {
let numbers = Arc::clone(&numbers);
let result = Arc::clone(&result);
let handle = thread::spawn(move || {
let start = i * chunk_size;
let end = if i == 3 { numbers.len() } else { (i + 1) * chunk_size };
let sum: i32 = numbers[start..end].iter().sum();
let mut total = result.lock().unwrap();
*total += sum;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
*result.lock().unwrap()
}
fn main() {
let numbers: Vec<i32> = (1..=1000).collect();
let sum = parallel_sum(numbers);
println!("합계: {}", sum); // 500500
}
이 예제는 앞에서 배운 도구를 모두 조합합니다. 입력 벡터는 읽기만 하므로 Arc로 감싸 네 스레드가 락 없이 공유하고, 결과는 여러 스레드가 써야 하므로 Arc<Mutex<i32>>로 공유합니다. 마지막 스레드(i == 3)가 numbers.len()까지 맡는 이유는 길이가 4로 나누어떨어지지 않을 때 남는 원소를 처리하기 위해서입니다. 이 처리가 없으면 1001개일 때 마지막 원소 하나가 합계에서 조용히 빠집니다.
다만 이 구조는 결과를 모으는 데 Mutex가 꼭 필요하지는 않습니다. 각 스레드가 부분합을 반환값으로 돌려주고 join()으로 모으면 공유 상태 자체가 사라집니다. 아래 “실전 심화 보강”의 예제가 그 방식입니다. i32로 합을 구하는 것도 주의할 점으로, 1부터 10만까지 더하면 약 50억이 되어 i32 범위를 넘습니다. 디버그 빌드에서는 “attempt to add with overflow” panic이 나고, 릴리스 빌드에서는 조용히 값이 감겨(wrap) 틀린 결과가 나옵니다. 큰 합계는 i64로 계산하세요.
데이터를 청크로 나눠 스레드별로 합산하기
std::mpsc::Receiver는 복제할 수 없어 “여러 워커가 한 큐를 나눠 먹기” 패턴을 그대로 구현하기 어렵습니다. 그 대신 데이터를 미리 청크로 나눈 뒤 스레드마다 한 청크만 처리하면 동일한 효과를 얻을 수 있습니다.
use std::thread;
fn parallel_sum(nums: Vec<i32>, workers: usize) -> i32 {
assert!(workers > 0);
let chunk_size = (nums.len() + workers - 1) / workers;
let chunks: Vec<Vec<i32>> = nums
.chunks(chunk_size.max(1))
.map(|c| c.to_vec())
.collect();
let handles: Vec<_> = chunks
.into_iter()
.map(|chunk| {
thread::spawn(move || chunk.iter().copied().sum::<i32>())
})
.collect();
handles.into_iter().map(|h| h.join().unwrap()).sum()
}
fn main() {
let nums: Vec<i32> = (1..=10_000).collect();
let total = parallel_sum(nums, 4);
println!("합계: {}", total);
}
이 버전은 공유 상태가 전혀 없습니다. 데이터를 chunks로 미리 나누고 to_vec()으로 청크마다 소유권을 가진 복사본을 만들어 각 스레드에 move하며, 각 스레드는 부분합을 반환값으로 돌려줍니다. Arc도 Mutex도 필요 없어 코드가 더 단순하고 락 경합도 없습니다. 대신 to_vec()으로 전체 데이터를 한 번 복사하는 비용이 듭니다. 앞에서 소개한 thread::scope를 쓰면 이 복사 없이 nums.chunks(chunk_size)의 슬라이스를 그대로 각 스레드에 빌려줄 수 있어서, 현재 Rust에서는 그쪽이 가장 깔끔한 표준 라이브러리 해법입니다.
솔직히 말하면 1만 개 정수의 합처럼 작은 작업에서는 스레드를 만드는 비용이 계산 자체보다 커서 단일 스레드 iter().sum()이 더 빠릅니다. 스레드 병렬화는 청크 하나의 처리 시간이 스레드 생성 비용(수십 마이크로초 단위)보다 충분히 클 때 이득이 납니다. 실무에서 데이터 병렬 계산이 필요하면 rayon 크레이트의 nums.par_iter().sum() 한 줄이 스레드 풀과 작업 분할을 자동으로 처리해 주므로, 직접 스레드를 나누는 코드는 원리를 이해하는 용도로 보는 편이 좋습니다.
데드락·포이즌·Send/Sync에서 막히는 지점
Mutex락을 잡은 채로 I/O를 하여 다른 스레드를 굶기는 경우.Arc없이 스레드 간 데이터 공유를 시도하는 경우.- 데드락: 두 개 이상의 뮤텍스를 서로 다른 순서로 잠그는 경우.
Mutex의lock()이Err를 돌려주면 포이즌 상태입니다. 락을 잡고 있던 다른 스레드가 panic해서, 보호 중이던 데이터가 중간 상태로 남았을 수 있다는 신호입니다.Send/Sync제약을 이해하지 못해 클로저 캡처에서 막히는 경우가 많습니다.
Send와 Sync는 Rust 동시성 안전의 기반이 되는 두 마커 트레이트입니다. Send는 “이 값의 소유권을 다른 스레드로 넘겨도 안전하다”, Sync는 “이 값의 참조를 여러 스레드가 동시에 가져도 안전하다”는 뜻입니다. 대부분의 타입은 컴파일러가 자동으로 두 트레이트를 구현해 주고, Rc(원자적이지 않은 카운트)나 RefCell(스레드 안전하지 않은 내부 가변성), 원시 포인터처럼 위험한 타입만 빠집니다. thread::spawn이 클로저에 Send를 요구하므로, 이런 타입을 캡처하면 컴파일러가 “cannot be sent between threads safely” 에러와 함께 어떤 필드가 원인인지 추적해서 알려 줍니다. 에러 메시지가 길어도 마지막의 “required because it appears within the type …” 줄들을 따라가면 원인 타입을 찾을 수 있습니다.
컴파일러가 막아 주는 것은 데이터 레이스까지라는 점도 분명히 해 두겠습니다. 위 목록의 데드락, 락을 오래 잡는 성능 문제, 메시지 순서에 의존하는 논리 오류는 Rust에서도 여전히 생깁니다. 제가 Rust 동시성 코드에서 실제로 가장 자주 만난 문제는 데이터 레이스가 아니라 앞에서 설명한 “송신자를 drop하지 않아 수신 루프가 끝나지 않는” 멈춤이었습니다. 컴파일이 된다고 동시성 버그가 없다는 뜻은 아니니, 채널과 락의 수명을 코드 리뷰에서 한 번 더 확인하는 습관이 필요합니다.
rayon·tokio·프로세스 분리 중 무엇을 고를까
- CPU 병렬은 rayon, I/O 동시성은 tokio로 역할을 나눕니다.
- 공유 상태 최소화를 위해 메시지 패싱 우선을 고려합니다.
| 도구 | 용도 |
|---|---|
| OS 스레드 | CPU 바운드, 격리 |
| Tokio task | I/O 대기 많음 |
| 프로세스 분리 | 강한 격리·크래시 내성 |
참고 자료
동시성 도구 요약
- thread::spawn: 스레드 생성
- mpsc::channel: 스레드 간 통신
- Arc: 원자적 참조 카운팅 (여러 스레드 공유)
- Mutex: 상호 배제 (동시 접근 방지)
- join: 스레드 완료 대기
다음 단계
같이 보면 좋은 글
- C++ 메모리 모델
- C++ 멀티스레딩
- C++ vs Rust 완전 비교 | 소유권·메모리 안전성·에러 처리·동시성·성능 실전 가이드
- Java 멀티스레드 | Thread, Runnable, Executor
- C++ Atomic
- Rust 테스팅 | 단위 테스트, 통합 테스트, 벤치마크
- Rust 비동기 프로그래밍 | async/await, Tokio
자주 묻는 질문 (FAQ)
Q. Mutex를 쓰는데 프로그램이 멈추거나 다른 스레드가 느려지는 이유는 무엇인가요?
A. 가장 흔한 원인은 락을 잡은 채로 파일·네트워크 I/O 같은 느린 작업을 해서 다른 스레드가 계속 대기하는 경우입니다. 두 개 이상의 Mutex를 스레드마다 서로 다른 순서로 잠그면 데드락이 생기므로 잠그는 순서를 통일해야 합니다. 또 락을 잡은 스레드가 panic하면 Mutex가 포이즌 상태가 되어 이후 lock()이 Err를 돌려주니, unwrap으로 넘기지 말고 처리 방침을 정해 두는 것이 좋습니다.