thread::spawn은 단순히 함수를 다른 곳에서 실행하는 호출이 아닙니다. 클로저와 반환값이 스레드 경계를 건너도 안전한지, 빌린 값이 작업 스레드보다 오래 유효한지를 컴파일러가 검사하는 경계입니다. Rust는 이 조건을 소유권과 수명, Send와 Sync로 표현합니다.
이 글은 Rust 2024 edition과 rustc·Cargo 1.98.1을 기준으로 합니다. 고정된 엔드포인트 세 개를 OS 스레드로 옮겨 차단 검사를 수행하고 모든 JoinHandle을 합류한 뒤 입력 순서로 결과를 출력하는 예제를 사용합니다. 네트워크 요청, sleep, 실행 시간 측정은 포함하지 않습니다.
1. 소유권을 작업 스레드로 옮기기
일반 thread::spawn의 타입 경계는 다음과 같습니다.
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
클로저는 새 스레드로 전달되고 반환값은 join을 거쳐 호출자에게 돌아옵니다. 그래서 둘 다 Send여야 합니다. 일반 spawn은 호출자보다 오래 실행될 수 있으므로 클로저와 반환값에 'static도 요구합니다. 여기서 'static은 소유한 String이나 Endpoint가 프로세스 종료까지 살아 있어야 한다는 뜻이 아닙니다. 필요한 기간보다 짧게 빌린 데이터를 내부에 품지 않아야 한다는 뜻이며 소유한 값은 작업이 끝날 때 해제될 수 있습니다.
예제의 한 항목 경로는 Endpoint를 복제하지 않고 move 클로저로 옮깁니다.
pub fn run_one(endpoint: Endpoint, checker: Arc<ImmediateChecker>) -> CheckResult {
thread::spawn(move || {
let _checker = checker;
CheckResult {
endpoint_id: endpoint.id,
health: Health::Healthy,
status: 200,
}
})
.join()
.expect("the one-worker fixture must complete")
}
move는 클로저가 주변 값을 빌리지 않고 값으로 캡처하게 합니다. 소유한 non-Copy 값을 옮기면 부모 스레드는 그 값을 다시 쓸 수 없습니다. 나중에 식별자가 필요하다면 이 예처럼 결과에 넣어 돌려받아야 합니다. 다만 move가 타입에 Send를 부여하거나 짧은 수명의 참조를 소유한 'static 값으로 바꾸지는 않습니다.
빌린 벡터를 일반 spawn에서 캡처하면 이 차이를 보여 주는 진단이 나옵니다. 아래 블록은 rustc 1.98.1 출력의 발췌입니다.
error[E0373]: closure may outlive the current function, but it borrows `endpoints`, which is owned by the current function
--> tests/compile_fail/borrowed_spawn.rs:5:32
|
5 | let worker = thread::spawn(|| endpoints.len());
| ^^ --------- `endpoints` is borrowed here
| |
| may outlive borrowed value `endpoints`
|
note: function requires argument type to outlive `'static`
--> tests/compile_fail/borrowed_spawn.rs:5:18
|
5 | let worker = thread::spawn(|| endpoints.len());
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: to force the closure to take ownership of `endpoints` (and any other referenced variables), use the `move` keyword
|
5 | let worker = thread::spawn(move || endpoints.len());
| ++++
지역 데이터를 빌려 병렬 작업을 해야 한다면 thread::scope가 다른 선택지입니다. scope가 반환되기 전에 그 안의 스레드가 모두 합류하므로 non-'static 지역 참조를 빌릴 수 있습니다. 자동 합류 중 작업자가 패닉하면 scope도 패닉합니다. 직접 처리하려면 scoped handle을 명시적으로 합류해야 합니다. 이번 예제는 소유한 작업 항목의 이동 자체를 설명하므로 scoped thread를 사용하지 않습니다.
2. 핸들을 보관하고 모든 작업을 합류하기
JoinHandle<T>는 특정 스레드에 합류할 수 있는 유일하게 소유된 권한입니다. join(self)은 핸들을 소비하고 스레드 종료를 기다린 뒤 std::thread::Result<T>를 반환합니다. 합류가 성공하면 작업 스레드에서 수행한 연산은 join 뒤의 연산보다 먼저 일어난 것으로 보장됩니다. 핸들을 버리면 작업 스레드는 detach되어 더는 합류할 수 없습니다. spawn만 호출해서는 부모가 기다린다는 보장이 생기지 않습니다.
세 항목 실행 경로는 (입력 인덱스, 식별자, 핸들)을 부모에 보관합니다. 작업자는 공유 결과 벡터를 변경하지 않고 값을 반환합니다.
pub fn run_fixture_checks<C>(endpoints: [Endpoint; 3], checker: Arc<C>) -> RunReport
where
C: BlockingChecker + Send + Sync + 'static,
{
let handles: Vec<_> = endpoints
.into_iter()
.enumerate()
.map(|(inventory_index, endpoint)| {
let checker = Arc::clone(&checker);
let endpoint_id = endpoint.id;
let handle = thread::spawn(move || {
let result = checker.check(endpoint);
(inventory_index, endpoint_id, result)
});
(inventory_index, endpoint_id, handle)
})
.collect();
let mut indexed_results = Vec::with_capacity(3);
let mut indexed_failures = Vec::new();
for (inventory_index, endpoint_id, handle) in handles {
match handle.join() {
Ok((returned_index, returned_id, result)) => {
debug_assert_eq!(
(returned_index, returned_id),
(inventory_index, endpoint_id)
);
indexed_results.push((returned_index, result));
}
Err(_) => {
indexed_failures.push((inventory_index, WorkerFailure::Panicked { endpoint_id }))
}
}
}
indexed_results.sort_by_key(|(inventory_index, _)| *inventory_index);
indexed_failures.sort_by_key(|(inventory_index, _)| *inventory_index);
RunReport {
results: indexed_results
.into_iter()
.map(|(_, result)| result)
.collect(),
failures: indexed_failures
.into_iter()
.map(|(_, failure)| failure)
.collect(),
}
}
핸들을 입력 순서로 합류하고 결과를 인덱스로 정렬하면 수집과 출력 순서는 결정적입니다. 작업자의 실행 순서를 고정하는 장치는 아닙니다. 먼저 생성한 스레드가 먼저 시작하거나 끝난다고 가정해서는 안 됩니다.
3. Send: 스레드 경계를 건너는 값
Send는 메서드가 없는 unsafe auto trait이며 타입 값을 다른 스레드로 전달할 수 있음을 뜻합니다. spawn에 넘기는 클로저 값에는 캡처한 값도 들어 있으므로 클로저 전체가 Send여야 합니다. JoinHandle을 통해 돌아오는 결과 타입에도 같은 조건이 붙습니다.
구조체, enum, union, tuple은 일반적으로 모든 필드가 auto trait을 만족할 때 그 trait을 얻습니다. 클로저의 auto trait 구현 여부는 캡처 타입과 캡처 방식에 따라 결정됩니다. 하지만 generic wrapper에는 조건부 구현이 있을 수 있고 명시적인 구현이나 negative implementation이 자동 판정을 바꿀 수 있습니다. 안정적인 일반 사용자 코드가 임의의 negative impl을 추가할 수 있는 것도 아닙니다. unsafe impl Send는 컴파일러가 확인할 수 없는 계약을 작성자가 책임지는 선언이므로 이 글의 예제에서는 사용하지 않습니다.
Rc<T>는 명시적으로 Send와 Sync가 아니며 참조 카운트도 원자적으로 갱신하지 않습니다. 다음 소스는 move를 썼지만 컴파일되지 않습니다.
use std::rc::Rc;
use std::thread;
fn main() {
let endpoint = Rc::new(String::from("home"));
let worker = thread::spawn(move || endpoint.len());
let _ = worker.join();
}
rustc 1.98.1 진단 발췌는 실패 지점을 spawn이 요구한 Send 제약으로 연결합니다.
error[E0277]: `Rc<String>` cannot be sent between threads safely
--> tests/compile_fail/rc_not_send.rs:6:32
|
6 | let worker = thread::spawn(move || endpoint.len());
| ------------- -------^^^^^^^^^^^^^^^
| | |
| | `Rc<String>` cannot be sent between threads safely
| | within this `{closure@tests/compile_fail/rc_not_send.rs:6:32: 6:39}`
| required by a bound introduced by this call
|
= help: within `{closure@tests/compile_fail/rc_not_send.rs:6:32: 6:39}`, the trait `Send` is not implemented for `Rc<String>`
note: required because it's used within this closure
--> tests/compile_fail/rc_not_send.rs:6:32
|
6 | let worker = thread::spawn(move || endpoint.len());
| ^^^^^^^
note: required by a bound in `spawn`
move와 Send는 서로 다른 질문에 답합니다. 전자는 캡처 방식이고 후자는 그 값을 스레드 경계 너머로 옮겨도 되는지에 관한 타입 계약입니다. 공동 소유가 필요하지 않다면 Rc를 무조건 Arc로 바꾸기보다 평범한 소유 값을 작업자 하나에 옮기는 편이 더 단순합니다.
4. Sync: 여러 스레드가 공유하는 참조
T: Sync는 &T: Send와 정확히 같은 조건입니다. 여러 스레드가 같은 C를 &self로 호출하는 예제에서는 C가 Sync여야 합니다. 한편 Arc<C> 자체를 각 클로저로 옮기므로 관련 Send 조건도 만족해야 합니다. Arc<T>는 payload의 실제 bound가 맞을 때만 Send와 Sync를 구현합니다.
동작 단위로 나누면 경계가 선명합니다. Endpoint 하나를 작업자 하나로 옮길 때는 Endpoint: Send가 필요합니다. 여러 작업자가 Arc<C>를 복제해 check(&self, ...)를 호출할 때는 공유되는 C: Sync와 전송에 필요한 제약을 만족해야 합니다. CheckResult가 핸들로 돌아올 때는 CheckResult: Send가 필요합니다.
Arc가 payload를 자동으로 스레드 안전하게 만들지는 않습니다. 다음 Arc<RefCell<_>>도 실제로 거부됩니다.
use std::cell::RefCell;
use std::sync::Arc;
use std::thread;
fn main() {
let results = Arc::new(RefCell::new(Vec::<u16>::new()));
let worker_results = Arc::clone(&results);
let worker = thread::spawn(move || worker_results.borrow_mut().push(200));
let _ = worker.join();
}
아래는 rustc 1.98.1 출력의 발췌입니다.
error[E0277]: `RefCell<Vec<u16>>` cannot be shared between threads safely
--> tests/compile_fail/arc_refcell_not_sync.rs:8:32
|
8 | let worker = thread::spawn(move || worker_results.borrow_mut().push(200));
| ------------- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `RefCell<Vec<u16>>` cannot be shared between threads safely
| |
| required by a bound introduced by this call
|
= help: the trait `Sync` is not implemented for `RefCell<Vec<u16>>`
= note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` instead
= note: required for `Arc<RefCell<Vec<u16>>>` to implement `Send`
Arc<Mutex<T>>는 공동 소유와 동기화된 변경을 각각 조합하는 타입입니다. 다만 실제 generic bound를 만족할 때만 스레드 사이에서 사용할 수 있습니다. 이번 예제는 결과를 공유 변경하지 않고 핸들로 돌려주므로 Arc<Mutex<Vec<_>>>가 필요 없습니다.
5. 패닉은 합류 지점에서 정책이 된다
작업 스레드 루트까지 unwind된 Rust panic은 join에서 Err(Box<dyn Any + Send + 'static>)로 나타납니다. 합류하는 스레드가 자동으로 패닉하는 것은 아닙니다. 그 결과에 .unwrap()이나 .expect()를 호출하면 합류하는 쪽에서 새 패닉이 발생합니다. std::panic::resume_unwind는 잡힌 패닉을 의도적으로 다시 전파합니다. 도메인 실패로 바꾸면 해당 작업 실패를 격리합니다. 어느 선택이 맞는지는 호출자의 정책입니다.
예제는 모든 핸들을 끝까지 합류하고 payload 문자열을 계약으로 노출하지 않은 채 엔드포인트 식별자만 실패로 남깁니다. 다음 테스트에서는 세 작업자가 rendezvous에 도달한 뒤 가운데 작업자가 패닉합니다. 이후 항목의 성공 결과와 가운데 항목의 실패가 함께 남아야 통과합니다.
#[test]
fn every_handle_is_joined_when_one_worker_panics() {
let checker = Arc::new(PanickingChecker {
barrier: Barrier::new(3),
});
let report = run_fixture_checks(fixture_endpoints(), checker);
assert_eq!(
report
.results
.iter()
.map(|result| result.endpoint_id)
.collect::<Vec<_>>(),
vec![EndpointId::Home, EndpointId::Metrics]
);
assert_eq!(
report.failures,
vec![WorkerFailure::Panicked {
endpoint_id: EndpointId::Health,
}]
);
}
이 경계는 일반적인 장애 격리를 약속하지 않습니다. panic 전에 외부 상태나 애플리케이션 불변식이 이미 바뀌었을 수 있고 process abort 설정에서는 unwind가 일어나지 않습니다. foreign unwinding에도 별도 제약이 있습니다.
6. 순서나 시간에 기대지 않고 차단 작업 검증하기
FixtureChecker는 Barrier::new(3)을 소유합니다. 각 check는 한 번 wait()한 다음 고정 테이블의 결과를 반환합니다. 세 작업자가 rendezvous에 모두 도달하기 전에는 누구도 통과하지 못한다는 사실만 사용합니다. 시작 순서, 공정성, 코어 수, 처리량은 증명하지 않습니다.
pub struct FixtureChecker {
barrier: Barrier,
}
impl FixtureChecker {
pub fn new() -> Self {
Self {
barrier: Barrier::new(3),
}
}
}
impl Default for FixtureChecker {
fn default() -> Self {
Self::new()
}
}
impl BlockingChecker for FixtureChecker {
fn check(&self, endpoint: Endpoint) -> CheckResult {
self.barrier.wait();
match endpoint.id {
EndpointId::Home => CheckResult {
endpoint_id: endpoint.id,
health: Health::Healthy,
status: 200,
},
EndpointId::Health => CheckResult {
endpoint_id: endpoint.id,
health: Health::Unhealthy,
status: 503,
},
EndpointId::Metrics => CheckResult {
endpoint_id: endpoint.id,
health: Health::Healthy,
status: 204,
},
}
}
}
추론된 auto trait은 컴파일 시점 assertion으로도 확인합니다. 이 함수들은 성능, 공정성, 논리적 race 부재를 증명하지 않습니다.
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
fn assert_checker_contract<C: BlockingChecker>() {
assert_send::<C>();
assert_sync::<C>();
}
프로젝트 디렉터리에서 다음 명령을 실행합니다.
cd examples/article-21-threads-send-sync
cargo fmt --all -- --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet
Rust 1.98.1과 Cargo 1.98.1에서 다섯 명령은 종료 코드 0을 반환해야 합니다. 테스트 모음에는 동작 테스트 5개와 compile-fail 테스트 3개가 있습니다. 실행 출력은 다음과 같이 고정됩니다.
home: healthy (200)
health: unhealthy (503)
metrics: healthy (204)
summary: checked=3 healthy=2 unhealthy=1 worker_panics=0
안전한 Rust와 올바른 Send·Sync 구현은 유효하지 않은 교차 스레드 참조와 data race를 막습니다. 하지만 deadlock, starvation, 중복 작업, 오래된 비즈니스 판단, 잘못된 집계, 스케줄 순서까지 없애지는 않습니다. 스레드를 썼다는 사실만으로 speedup도 보장되지 않습니다. unsafe trait 구현과 unsafe·foreign code는 선언한 불변식을 직접 지켜야 합니다.
이 예제는 고정된 세 항목을 위한 교육용 구성입니다. 임의 개수의 엔드포인트마다 스레드를 하나씩 만드는 운영 설계를 권하지 않습니다. channel, bounded queue, worker pool, backpressure, shutdown, 작업 분배와 shared state 대비 message passing은 22편에서 다룹니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 21 프로젝트에서 확인할 수 있습니다.
출처
- The Rust Programming Language 1.98.1: Using Threads to Run Code Simultaneously
- The Rust Programming Language 1.98.1: Extensible Concurrency with Send and Sync
- Rust standard library 1.98.1:
thread::spawn - Rust standard library 1.98.1:
JoinHandle - Rust standard library 1.98.1:
thread::scope - Rust standard library 1.98.1:
Send - Rust standard library 1.98.1:
Sync - The Rust Reference 1.98.1: Auto traits
- Rust standard library 1.98.1:
Rc - Rust standard library 1.98.1:
Arc - Rust standard library 1.98.1:
Barrier - Rust standard library 1.98.1:
thread::Result
답글 남기기