스레드 동시 실행
thread spawn으로 실행 흐름을 만들고 move 클로저로 소유권을 넘기며 join 핸들로 스레드 완료 시점을 동기화합니다.
안전하고 효율적으로 동시성 프로그래밍을 다루는 것은 러스트의 또 다른 주요 목표 중 하나입니다.
동시성 프로그래밍(concurrent programming), 즉 프로그램의 서로 다른 부분이 독립적으로 실행되는 것과, 병렬 프로그래밍(parallel programming), 즉 프로그램의 서로 다른 부분이 동시에 실행되는 것은 더 많은 컴퓨터가 여러 개의 프로세서를 활용함에 따라 그 중요성이 커지고 있습니다.
역사적으로, 동시성 및 병렬 컨텍스트에서의 프로그래밍은 어렵고 에러를 내기 쉬웠습니다.
러스트는 이를 바꾸기를 희망합니다.
초기에 러스트 팀은 메모리 안전을 보장하는 것과 동시성 문제를 방지하는 것은 다른 방법으로 해결돼야 하는 별개의 도전 과제라고 생각했습니다.
시간이 흘러 러스트 팀은 소유권과 타입 시스템이 메모리 안전성 및 동시성 문제를 관리하는 데 필요한 도구들을 제공한다는 점을 확인했습니다.
소유권과 타입 검사를 지렛대 삼음으로써, 많은 동시성 에러는 런타임 에러가 아닌 컴파일 타임 에러가 됩니다.
따라서 런타임 동시성 버그가 발생하는 정확한 환경을 재현하는 시도에 많은 시간을 쓰게 되는 것이 아니라, 부정확한 코드가 컴파일되지 않고 문제점을 설명하는 에러가 나타납니다.
결과적으로 잠재적으로 프로덕션에 배포된 이후가 아니라 작업을 하는 동안에 여러분의 코드를 고칠 수 있습니다.
우리는 러스트의 이러한 측면에 겁 없는 동시성(fearless concurrency)이란 별명을 지어 주었습니다.
안전한 러스트의 소유권과 타입 검사는 잘못된 참조와 데이터 경합(data race)을 막아 동시성 코드를 수정할 때 생길 수 있는 많은 실수를 컴파일 단계에서 잡아 줍니다. 다만 데드록이나 작업 순서에 따른 논리 오류까지 막는 것은 아니므로, 이 부분은 프로그램을 설계할 때 따로 살펴봐야 합니다.
노트: 간결성을 위해서 동시성 및/또는 병렬성이라는 정확한 표현 대신 많은 문제를 동시성이라고 지칭하겠습니다. 만일 이 책이 동시성 및/또는 병렬성에 대한 것이었다면 더 정확하게 말했을 것입니다. 이번 장에서는 동시성이라는 단어가 사용될 때마다 마음속으로 동시성 및/또는 병렬성을 대입해 주세요.
많은 언어는 동시성 문제를 다룰 때 특정한 해결 방식을 선호합니다.
예를 들어, 얼랭(Erlang)은 메시지-패싱(message-passing) 동시성을 위한 우아한 기능을 가지고 있지만 스레드 간에 상태를 공유하는 방법은 모호합니다.
고수준 언어의 경우 추상화를 위해 일부 제어권을 포기함으로써 얻을 수 있는 이점이 있기 때문에 가능한 해결책 중 일부만을 제공하는 것은 고수준 언어의 합리적인 전략입니다.
하지만 저수준 언어는 주어진 상황에서 성능 요구사항에 맞는 해결책을 제공하고 하드웨어에 대한 추상화가 적을 것으로 기대됩니다.
따라서 러스트는 여러분의 상황과 요구사항에 적합한 방법으로 문제를 모델링하기 위한 다양한 도구들을 제공합니다.
이번 장에서 다루게 될 주제들입니다.
- 스레드를 생성하여 여러 조각의 코드를 동시에 실행시키는 방법
- 채널들이 스레드 간에 메시지를 보내는 메시지-패싱 동시성
- 여러 스레드가 어떤 동일한 데이터에 접근할 수 있는 상태-공유(shared-state) 동시성
- 러스트의 동시성 보장을 표준 라이브러리가 제공하는 타입은 물론
사용자 정의 타입으로 확장하는
Sync와Send트레이트
이제 동시성 도구를 안전하게 쓰는 기준을 살펴보겠습니다.
핵심 개념: spawn-move-join 안전 흐름
대부분의 최신 운영 체제에서, 실행된 프로그램의 코드는 프로세스 내에서 실행되고, 운영 체제는 한 번에 여러 개의 프로세스를 관리하게 됩니다.
프로그램 내에서도 동시에 실행되는 독립적인 부분들을 가질 수 있습니다.
이러한 독립적인 실행 흐름을 스레드라 합니다.
예를 들어 웹 서버는 여러 스레드를 가지고 동시에 하나 이상의 요청에 대한 응답을 할 수 있습니다.
여러분의 프로그램 내의 연산을 여러 스레드로 쪼개서 동시에 여러 일을 수행하게 하면 성능을 향상시킬 수 있지만, 프로그램을 복잡하게 만들기도 합니다.
스레드가 동시에 실행될 수 있기 때문에, 서로 다른 스레드에서 실행될 코드 조각들의 실행 순서에 대한 본질적인 보장이 없습니다.
이는 다음과 같은 문제들을 야기할 수 있습니다.
- 여러 스레드가 일관성 없는 순서로 데이터 혹은 리소스에 접근하게 되는, 경합 조건(race condition)
- 두 스레드가 서로를 기다려서 양쪽 스레드 모두 계속 실행되는 것을 막아버리는, 데드록(deadlock)
- 특정한 상황에서만 발생하여 안정적으로 재현하고 수정하기 힘든 버그들
러스트는 스레드 사용의 부정적인 효과를 완화하는 시도를 하지만, 멀티스레드 컨텍스트에서의 프로그래밍은 여전히 신중하게 생각해야 하고 싱글스레드로 실행되는 프로그램의 것과는 다른 코드 구조가 필요합니다.
프로그래밍 언어들은 몇 가지 다른 방식으로 스레드를 구현하고, 많은 운영 체제들이 새로운 스레드를 생성하기 위해 해당 언어가 호출할 수 있는 API를 제공합니다.
러스트 표준 라이브러리는 스레드 구현에 대해 1:1 모델을 사용하는데, 이에 따라 프로그램은 하나의 언어 스레드당 하나의 운영 체제 스레드를 사용합니다.
1:1 모델과는 다른 절충안이 있는 그 밖의 스레드 모델을 구현한 크레이트도 있습니다.
spawn으로 새로운 스레드 생성하기
새로운 스레드를 생성하기 위해서는 thread::spawn 함수를 호출하고 여기에 새로운 스레드에서 실행하고 싶은 코드가 담긴 클로저를 넘깁니다. (클로저에 대해서는 12장에서 다뤘습니다.)
예제 15-1의 예제는 메인 스레드에서 어떤 텍스트를 출력하고 새로운 스레드에서는 다른 텍스트를 출력합니다.
예제 15-1: 메인 스레드에서 무언가를 출력하는 동안 다른 것을 출력하는 새로운 스레드 생성하기use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
}
러스트 프로그램의 메인 스레드가 완료되면 생성된 모든 스레드는 실행이 종료되었든 혹은 그렇지 않든 멈추게 될 것이라는 점을 주의하세요.
이 프로그램의 출력은 매번 약간씩 다를 수 있으나, 아래와 비슷하게 보일 것입니다.
hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!thread::sleep의 호출은 스레드의 실행을 강제로 잠깐 멈추게 하는데, 다른 스레드는 실행될 수 있도록 합니다.
스레드들은 아마도 교대로 실행될 것이지만, 그게 보장되지는 않습니다.
실제 실행 순서는 여러분의 운영 체제가 스레드를 어떻게 스케줄링하는지에 따라 달라집니다.
소스에서는 생성된 스레드의 출력 구문이 먼저 등장하지만, 위의 실행 예에서는 메인 스레드가 먼저 출력하였습니다.
그리고 생성된 스레드에게 i가 9일 때까지 출력하라고 했음에도 불구하고, 메인 스레드가 멈추기 전까지 고작 5에 도달했습니다.
만일 이 코드를 실행하고 메인 스레드의 출력만 보았다면, 혹은 어떠한 겹침도 보지 못했다면, 운영 체제에게 스레드 간 전환에 대한 더 많은 기회를 주도록 범위 값을 늘려서 시도해보세요.
join 핸들을 사용하여 모든 스레드가 끝날 때까지 기다리기
예제 15-1의 코드는 메인 스레드의 종료 때문에 대체로 생성된 스레드를 조기에 멈추게 할 뿐만 아니라, 스레드들이 실행되는 순서에 대한 보장이 없기 때문에 생성된 스레드가 모든 코드를 실행할 것임을 보장해 줄 수도 없습니다!
생성된 스레드의 완료를 main 종료 전에 확인하려면 thread::spawn의 반환 값을 변수에 저장하고 그 핸들로 join을 호출해야 합니다. 변수에 저장하기만 하고 사용하지 않으면 완료 대기가 생기지 않습니다.
thread::spawn의 반환 타입은 JoinHandle입니다.
JoinHandle은 자신의 join 메서드를 호출했을 때 그 스레드가 끝날 때까지 기다리는 소윳값입니다.
예제 15-2는 예제 15-1에서 만들었던 스레드의 JoinHandle을 이용해서 join을 호출하여 main이 끝나기 전에 생성된 스레드가 종료됨을 보장하는 방법을 보여줍니다.
thread::spawn으로부터 JoinHandle을
저장하여 스레드가 완전히 실행되는 것을 보장하기
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
handle.join().unwrap();
}
핸들에 대해 join을 호출하면 핸들에 대한 스레드가 종료될 때까지 현재 실행 중인 스레드를 블록합니다.
스레드를 블록(Block)한다는 것은 기다리는 동안 그 스레드가 다음 작업으로 진행하지 못한다는 뜻입니다. 여기서 join은 호출한 스레드를 기다리게 할 뿐, 자식이나 다른 스레드의 실행을 정지시키지는 않습니다.
메인 스레드의 for 루프 이후에 join의 호출을 넣었으므로, 예제 15-2의 실행은 아래와 비슷한 출력을 만들어야 합니다.
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!두 스레드가 계속하여 교차하지만, handle.join()의 호출로 인하여 메인
스레드는 기다리고 생성된 스레드가 종료되기 전까지 끝나지 않습니다.
그런데 만일 아래와 같이 main의 for 루프 이전으로 handle.join()을
이동시키면 어떤 일이 생기는지 봅시다.
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {} from the spawned thread!", i);
thread::sleep(Duration::from_millis(1));
}
});
handle.join().unwrap();
for i in 1..5 {
println!("hi number {} from the main thread!", i);
thread::sleep(Duration::from_millis(1));
}
}
메인 스레드는 생성된 스레드가 종료될 때까지 기다릴 것이고 그다음 자신의 for
루프를 실행하게 되어, 아래처럼 출력값이 더 이상 교차하지 않을 것입니다.
hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!join이 호출되는 위치처럼 작은 세부 사항도 스레드가 동시에 실행되는지의
여부에 영향을 미칠 수 있습니다.
스레드에 move 클로저 사용하기
move 클로저는 thread::spawn에 넘겨지는 클로저와 함께 자주 사용되는데, 그렇게 하면 클로저가 환경으로부터 사용하는 값의 소유권을 갖게 되어 한 스레드에서 다른 스레드로 소유권이 이동될 것이기 때문입니다.
12장의 ‘참조자를 캡처하거나 소유권 이동하기’ 절에서 클로저의 컨텍스트에서의 move에 대해 다루었습니다.
지금은 move와 thread::spawn 사이의 상호작용에 더 집중해보겠습니다.
예제 15-1에서 'thread::spawn'에 전달된 클로저에는 어떤 인수도 없음을 주목하세요: 생성된 스레드의 코드에서는 메인 스레드로부터 온 어떤 데이터도 이용하고 있지 않습니다.
메인 스레드의 데이터를 생성된 스레드에서 사용하기 위해, 생성된 스레드의 클로저는 자신이 필요로 하는 값을 캡처해야 합니다.
예제 15-3은 메인 스레드에서 벡터를 생성하여 이를 생성된 스레드 내에서 사용하는 시도를 보여주고 있습니다.
그러나 잠시 후에 보시게 될 것처럼 아직은 동작하지 않습니다.
예제 15-3: 메인 스레드에서 생성된 벡터에 대한 다른 스레드에서의 사용 시도use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {:?}", v);
});
handle.join().unwrap();
}
클로저가 v를 사용하므로, v는 캡처되어 클로저 환경의 일부가 됩니다.
thread::spawn이 이 클로저를 새로운 스레드에서 실행하므로, v는 새로운 스레드 내에서 접근 가능해야 합니다.
하지만 이 예제를 컴파일하면 아래와 같은 에러를 얻게 됩니다.
$ cargo run
Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
--> src/main.rs:6:32
|
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `v`
7 | println!("Here's a vector: {:?}", v);
| - `v` is borrowed here
|
note: function requires argument type to outlive `'static`
--> src/main.rs:6:18
|
6 | let handle = thread::spawn(|| {
| __________________^
7 | | println!("Here's a vector: {:?}", v);
8 | | });
| |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
|
6 | let handle = thread::spawn(move || {
| ++++
For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` due to previous error
러스트는 v를 어떻게 캡처할지 추론하고, println!이 v의 참조자만 필요로 하기 때문에, 클로저는 v를 빌리려고 합니다.
하지만 문제가 있습니다.
러스트는 생성된 스레드가 얼마나 오랫동안 실행될지 알 수 없으므로, v에 대한 참조자가 항상 유효할 것인지 알지 못합니다.
'static 바운드와 실제 보관 시간. 일반 spawn은 클로저와 반환 타입 모두에 Send + 'static을 요구합니다. 여기서 타입의 'static 바운드는 잠깐 빌린 지역 참조를 포함하지 않아야 한다는 조건이지, 소유한 Vec<i32>를 프로그램 종료까지 반드시 보관하라는 명령이 아닙니다. 소유한 벡터는 자식이 끝나며 버리거나 반환할 수 있습니다. 바로 다음 줄에서 join하더라도 spawn의 타입 계약이 완화되지는 않으므로, 예제 15-3의 지역 빌림은 그대로 거부됩니다. 타입의 수명 바운드와 값이 언제 버려지는지를 나누어 보세요.
예제 15-4는 유효하지 않은 v의 참조자가 있을 가능성이 더 높은
시나리오를 제공합니다.
v를 버리는 메인 스레드로부터 v에
대한 참조자를 캡처하려 하는 클로저를 갖는 스레드
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Here's a vector: {:?}", v);
});
drop(v); // 오, 이런!
handle.join().unwrap();
}
만약 러스트가 이 코드의 실행을 허용했다면, 생성된 스레드가 전혀 실행되지 않고 즉시 백그라운드에 들어갔을 가능성이 있습니다.
생성된 스레드는 내부에 v의 참조자를 가지고 있지만, 메인 스레드는 14장에서 다루었던 drop 함수를 사용하여 v를 즉시 버립니다.
그러면 생성된 스레드가 실행되기 시작할 때 v가 더 이상 유효하지 않게 되어, 그에 대한 참조자 또한 유효하지 않게 됩니다.
이런!
예제 15-3의 컴파일 에러를 고치기 위해서 에러 메시지의 조언을 이용할 수 있습니다.
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
|
6 | let handle = thread::spawn(move || {
| ++++move 키워드를 클로저 앞에 추가함으로써 러스트가 값을 빌려와야 된다고 추론하도록 하는 것이 아니라 사용 중인 값의 소유권을 강제로 가지도록 합니다.
예제 15-3을 예제 15-5처럼 수정하면 컴파일되어 의도한 대로 실행됩니다.
예제 15-5:move 키워드를 이용하여 클로저가
사용하는 값의 소유권을 갖도록 강제하기
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Here's a vector: {:?}", v);
});
handle.join().unwrap();
}
move 클로저를 사용하여 메인 스레드에서 drop을 호출하는 예제 15-4의 코드를 고치려고 시도해 보고 싶을 수도 있습니다.
하지만 이 수정은 동작하지 않는데, 그 이유는 예제 15-4가 시도하고자 하는 것이 다른 이유로 허용되지 않기 때문입니다.
만일 클로저에 move를 추가하면, v를 클로저의 환경으로 이동시킬 것이고, 더 이상 메인 스레드에서 이것에 대한 drop 호출을 할 수 없게 됩니다.
대신 아래와 같은 컴파일 에러를 얻게 됩니다.
$ cargo run
Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
--> src/main.rs:10:10
|
4 | let v = vec![1, 2, 3];
| - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
5 |
6 | let handle = thread::spawn(move || {
| ------- value moved into closure here
7 | println!("Here's a vector: {:?}", v);
| - variable moved due to use in closure
...
10 | drop(v); // 오, 이런!
| ^ value used here after move
For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` due to previous error
러스트의 소유권 규칙이 우리를 또다시 구해주었습니다!
예제 15-3의 코드에서 에러가 발생한 이유는 러스트가 보수적으로 스레드에 대해 v만 빌려왔기 때문이었는데, 이는 메인 스레드가 이론적으로 생성된 스레드의 참조자를 무효화할 수 있음을 의미합니다.
러스트에게 v의 소유권을 생성된 스레드로 이동시키라고 함으로써, 메인 스레드가 v를 더 이상 이용하지 않음을 러스트에게 보장하고 있습니다.
만일 예제 15-4를 같은 방식으로 바꾸면, v를 메인 스레드에서 사용하려고 할 때 소유권 규칙을 위반하게 됩니다.
move 키워드는 캡처 방식을 값으로 정합니다. 대여 검사나 소유권 규칙을 무효화하는 기능이 아닙니다. 특히 참조를 값으로 캡처해도 참조 대상의 수명이 늘어나거나 Send 바운드가 자동으로 충족되지는 않습니다.
move 클로저와 join이 각각 어느 안전 지점을 맡는지는 아래처럼 나누어
보면 더 명확합니다.
spawn·move·join 스레드 수명
move spawn/JoinHandle→양쪽 실행 가능→join 대기 또는 이미 완료→Result 관측의 실제 수명 경계이다. 출력 스케줄을 고정하지 않는다.
RUST · THREAD OWNERSHIP & COMPLETION
move는 값을 넘기고, join은 자식의 완료를 관찰한다
thread::spawn은 closure와 반환값에 Send + 'static을 요구합니다. 아래는 자식이 아직 실행 중일 때 join하는 한 사례입니다. 완료가 먼저 일어나도 join의 결과 관찰 계약은 같습니다.
spawn · 핸들 반환
closure와 반환값은 Send + 'static 바운드를 만족해야 합니다. move는 캡처를 값으로 가져오지만 이 바운드를 자동 보장하지는 않습니다.
동시 실행
부모·자식의 출력 교차 순서는 보장되지 않습니다.
join · 완료 관찰
아직 실행 중이면 기다립니다. 이미 끝났다면 완료를 바로 관찰하며 Ok(T) 또는 panic의 Err(payload)를 반환합니다.
본문의 Vec 이동과 합류
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("{v:?}");
});
handle.join().unwrap();move로 Vec를 캡처하므로 원래 v는 이후 사용할 수 없습니다. 참조 값을 move로 캡처해도 참조 대상의 수명이 늘어나는 것은 아닙니다. 예시는 thread가 import된 본문 코드의 핵심 부분입니다.
핸들을 drop하는 대안
join 대신 drop(handle)하면 detach됩니다. 이 핸들로 완료·반환값·panic을 관찰할 수 없습니다. 핸들 drop은 자식 스레드를 강제로 멈추지 않습니다.
빌림이 필요한 대안 · scope
thread::scope는 제한된 수명의 빌림을 허용하고 scope가 반환하기 전에 scoped thread에 합류합니다. join 호출 위치는 부모 코드가 언제 완료를 기다리는지 바꿉니다.
프로세스의 main이 끝나면 남은 스레드도 종료됩니다. sleep은 실행 순서나 완료를 보장하는 동기화 수단이 아닙니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 원래 가로 도표의 호출/자식 2참여자와 3관계를 세로 도표에서는 3개의 독립적인 송신/수신 쌍으로 반복 표현한다. 번호는 그림에 제시된 한 사례의 시간 순서이며 화살표는 spawn/대기/완료 관찰의 방향이다. 세 쌍의 6노드는 6개의 새 스레드가 아니다. spawn 뒤 join 전에 양쪽 코드가 동시에 진행할 수 있으므로 위아래 쌍 사이에 추가 순차 간선은 없다. join 전에 자식이 이미 끝났으면 대기가 필요 없고 같은 결과를 관찰한다. move는 Send/static 조건을 자동으로 만족시키지 않는다. |
| 본문·주석 | move로 Vec을 캡처하므로 원래 v는 이후 사용할 수 없습니다. 참조 값을 move로 캡처해도 참조 대상의 수명이 늘어나는 것은 아닙니다. 예시는 thread가 import된 본문 코드의 핵심 부분입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| move와 spawn 클로저 캡처가 이동되어 새 스레드에서 실행된다. |
|
|
| join 경계 메인이 handle을 소비해 종료를 기다린다. |
|
|
| 결과 관측 스레드 완료의 Result를 받는다. |
|
|
spawn·move·join 스레드 수명
move spawn/JoinHandle→양쪽 실행 가능→join 대기 또는 이미 완료→Result 관측의 실제 수명 경계이다. 출력 스케줄을 고정하지 않는다.
RUST · THREAD OWNERSHIP & COMPLETION
move는 값을 넘기고, join은 자식의 완료를 관찰한다
thread::spawn은 closure와 반환값에 Send + 'static을 요구합니다. 아래는 자식이 아직 실행 중일 때 join하는 한 사례입니다. 완료가 먼저 일어나도 join의 결과 관찰 계약은 같습니다.
spawn · 핸들 반환
closure와 반환값은 Send + 'static 바운드를 만족해야 합니다. move는 캡처를 값으로 가져오지만 이 바운드를 자동 보장하지는 않습니다.
동시 실행
부모·자식의 출력 교차 순서는 보장되지 않습니다.
join · 완료 관찰
아직 실행 중이면 기다립니다. 이미 끝났다면 완료를 바로 관찰하며 Ok(T) 또는 panic의 Err(payload)를 반환합니다.
본문의 Vec 이동과 합류
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("{v:?}");
});
handle.join().unwrap();move로 Vec를 캡처하므로 원래 v는 이후 사용할 수 없습니다. 참조 값을 move로 캡처해도 참조 대상의 수명이 늘어나는 것은 아닙니다. 예시는 thread가 import된 본문 코드의 핵심 부분입니다.
핸들을 drop하는 대안
join 대신 drop(handle)하면 detach됩니다. 이 핸들로 완료·반환값·panic을 관찰할 수 없습니다. 핸들 drop은 자식 스레드를 강제로 멈추지 않습니다.
빌림이 필요한 대안 · scope
thread::scope는 제한된 수명의 빌림을 허용하고 scope가 반환하기 전에 scoped thread에 합류합니다. join 호출 위치는 부모 코드가 언제 완료를 기다리는지 바꿉니다.
프로세스의 main이 끝나면 남은 스레드도 종료됩니다. sleep은 실행 순서나 완료를 보장하는 동기화 수단이 아닙니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 원래 가로 도표의 호출/자식 2참여자와 3관계를 세로 도표에서는 3개의 독립적인 송신/수신 쌍으로 반복 표현한다. 번호는 그림에 제시된 한 사례의 시간 순서이며 화살표는 spawn/대기/완료 관찰의 방향이다. 세 쌍의 6노드는 6개의 새 스레드가 아니다. spawn 뒤 join 전에 양쪽 코드가 동시에 진행할 수 있으므로 위아래 쌍 사이에 추가 순차 간선은 없다. join 전에 자식이 이미 끝났으면 대기가 필요 없고 같은 결과를 관찰한다. move는 Send/static 조건을 자동으로 만족시키지 않는다. |
| 본문·주석 | move로 Vec을 캡처하므로 원래 v는 이후 사용할 수 없습니다. 참조 값을 move로 캡처해도 참조 대상의 수명이 늘어나는 것은 아닙니다. 예시는 thread가 import된 본문 코드의 핵심 부분입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| move와 spawn 클로저 캡처가 이동되어 새 스레드에서 실행된다. |
|
|
| join 경계 메인이 handle을 소비해 종료를 기다린다. |
|
|
| 결과 관측 스레드 완료의 Result를 받는다. |
|
|
스레드와 스레드 API에 대한 기본적인 이해를 바탕으로, 스레드로 할 수 있는 것들을 살펴봅시다.
스레드를 만들 때는 시작보다 종료 경계가 더 중요합니다.
move로 값을 넘기고 join으로 끝을 확인해야 수명 문제가 줄어듭니다.
예시
출력 교차 순서 대신 작업의 반환값을 확인하는 전체 프로그램입니다. String을 작업으로 이동시키고 길이를 계산한 뒤, main이 성공한 join 뒤에 결과를 출력합니다.
use std::thread;
fn main() {
let word = String::from("cloud");
let task = thread::spawn(move || word.len());
println!("main: submitted");
let length = task.join().expect("worker panicked");
println!("main: length={length}");
}정상 종료하면 main: submitted, main: length=5 순서입니다. 작업이 얼마나 빨리 끝났는지와 무관하게 main의 두 출력은 이 순서이며, 자식이 먼저 끝났다면 join은 이미 완료된 결과를 받습니다. word는 이동되었으므로 main에서 다시 읽을 수 없습니다. len의 5는 이 ASCII 문자열의 바이트 길이입니다.
스스로 완료 경계 고르기
A는 위와 같이 핸들에 join하는 경우, B는 join 대신 drop(task)하고 main이 끝나는 경우입니다. 어느 경우에 작업의 반환값과 panic 결과를 main이 확인하나요? B의 drop 자체가 자식을 강제로 중지시키나요? 또 작업에 move로 캡처한 값이 String이 아니라 &word였다면, move만 붙였다는 이유로 지역 word에 대한 빌림의 수명이 'static이 되나요?
핸들·캡처 확인
A의 join은 작업 완료 뒤 Ok(length) 또는 스레드 panic의 Err를 반환하며, 이 프로그램의 expect는 Err에서 main도 panic하게 합니다. B는 detach하여 그 핸들로 반환값·panic 결과를 관찰할 수 없습니다. 핸들 drop 자체는 자식을 멈추지 않지만 프로세스의 main이 끝나면 남은 작업의 완료는 보장되지 않습니다. 참조의 move는 참조 값의 캡처일 뿐 대상의 수명을 연장하지 않으므로, 지역 word를 빌린 일반 spawn의 수명 조건을 자동 충족하지 않습니다.
호출 조건은 thread::spawn, 결과 관찰은 JoinHandle::join의 계약을 확인하세요.
위의 panic을 Err로 관찰하는 설명은 스레드 안에서 panic이 스택을 풀며 종료되는 경우입니다. panic=abort 설정에서는 프로세스가 중단되므로 join으로 실패 결과를 받아 복구할 수 없습니다. 정상 반환과 panic 결과를 구별할 때는 panic 구현 방식도 함께 확인합니다.
스레드를 이용하여 코드를 동시에 실행하기 학습 판단 기준표
스레드를 이용하여 코드를 동시에 실행할 때 컴파일러가 막는 지점과 테스트로 확인할 지점을 분리해 봅니다.
직접 연습: 합류 순서와 소유권 반환을 구별한다.
먼저 시작한 두 작업의 결과를 차례로 받기
아래 전체 프로그램은 두 스레드 생성과 계산이 모두 성공하고, panic·외부 종료가 없다고 가정합니다. 마지막 출력과 벡터의 합·길이를 구하세요. 첫 spawn 직후, join하기 전의 main에 println!("{values:?}");를 넣어도 되나요? 또 first에 먼저 합류하므로 first 작업이 second보다 반드시 먼저 끝나나요?
use std::thread;
fn main() {
let values = vec![1, 2, 3];
let first = thread::spawn(move || {
let sum: i32 = values.iter().sum();
(values, sum)
});
let second = thread::spawn(|| 10);
let (values, sum) = first.join().unwrap();
let ten = second.join().unwrap();
println!("{:?} total={} len={}", values, sum + ten, values.len());
}풀이: 합은 6, 길이는 3이므로 출력은 [1, 2, 3] total=16 len=3입니다. 처음 values는 첫 클로저로 이동했기 때문에 main은 합류 전 그 변수를 사용할 수 없습니다. first의 반환 튜플을 성공한 join으로 받아 새 values에 바인딩하면서 벡터의 소유권을 main으로 돌려받습니다. 두 spawn은 첫 join보다 앞서 있으므로 두 작업은 이미 실행 가능하며, main이 결과를 받는 순서만 first → second입니다. second가 먼저 끝날 수도 있습니다. first에 합류한 뒤에야 second를 spawn하는 별도 변형은 second의 시작 시점까지 달라집니다. 계산 결과처럼 고정되는 사실과 스케줄러에 맡긴 완료 순서를 구별하세요.