스레드 동시 실행
thread spawn으로 실행 흐름을 만들고 move 클로저로 소유권을 넘기며 join 핸들로 스레드 완료 시점을 동기화합니다.
동시성 프로그래밍을 안전하고 효율적으로 다루는 것은 러스트의 또 다른 주요 목표입니다. 프로그램의 서로 다른 부분이 독립적으로 실행되는 동시성 프로그래밍(concurrent programming)과, 프로그램의 서로 다른 부분이 같은 시간에 실행되는 병렬 프로그래밍(parallel programming)은, 더 많은 컴퓨터가 여러 프로세서의 이점을 누리게 되면서 점점 중요해지고 있습니다. 역사적으로 이런 맥락의 프로그래밍은 어렵고 오류가 나기 쉬웠습니다. 러스트는 이것을 바꾸고 싶어 합니다.
처음에 러스트 팀은 메모리 안전성을 보장하는 일과 동시성 문제를 막는 일이 서로 다른 방법으로 풀어야 할 별개의 과제라고 생각했습니다. 시간이 지나며 러스트 팀은 소유권과 타입 시스템이 메모리 안전성과 동시성 문제를 함께 다루는 데 도움이 되는 도구 모음이라는 것을 알게 되었습니다. 소유권과 타입 검사를 활용하면, 많은 동시성 오류가 런타임 오류가 아니라 러스트의 컴파일 시점 오류가 됩니다. 그래서 런타임 동시성 버그가 일어나는 정확한 상황을 재현하려고 많은 시간을 쓰는 대신, 잘못된 코드는 컴파일되지 않고 문제를 설명하는 오류를 보여 줍니다. 그 결과 코드를 프로덕션에 배포한 뒤가 아니라 작업하는 동안 고칠 수 있습니다. 러스트의 이런 면에 겁 없는 동시성(fearless concurrency)이라는 별명을 붙였습니다. 겁 없는 동시성은 미묘한 버그가 없고, 새 버그를 들이지 않고 리팩터링하기 쉬운 코드를 쓰게 해 줍니다.
이번 장에서는 간단하게 하려고, 동시성과 병렬성을 아울러 말할 때도 그냥 동시성이라고 부릅니다. 이번 장에서 동시성이라는 말이 나오면 동시성이나 병렬성, 또는 둘 다라고 생각하면 됩니다.
많은 언어는 동시성 문제를 다룰 때 자기가 내놓는 해결책에 대해 고집이 있습니다. 예를 들어 Erlang은 메시지 전달(message-passing) 동시성에 우아한 기능을 갖추었지만, 스레드 사이에 상태를 공유하는 방법은 알기 어렵습니다. 가능한 해결책의 일부만 지원하는 것은 고수준 언어에서는 합리적인 전략입니다. 고수준 언어는 추상화를 얻으려고 일부 제어권을 포기해 이득을 얻기 때문입니다. 하지만 저수준 언어는 어떤 상황에서든 가장 좋은 성능을 내는 해결책을 제공하고, 하드웨어에 대한 추상화를 더 적게 해야 합니다. 그래서 러스트는 상황과 요구 사항에 알맞은 방식으로 문제를 모델링할 수 있도록 여러 도구를 제공합니다.
| 글 | 다루는 것 |
|---|---|
| 이 글 | 스레드를 만들어 여러 코드 조각을 동시에 실행하는 방법 |
| 스레드 간 메시지 전달 | 채널로 스레드 사이에 메시지를 보내는 메시지 전달 동시성 |
| 공유 상태 동시성 | 여러 스레드가 같은 데이터에 접근하는 공유 상태(shared-state) 동시성 |
Sync와 Send 트레이트를 이용한 확장 가능한 동시성 | 러스트의 동시성 보장을 표준 라이브러리의 타입뿐 아니라 직접 만든 타입까지 넓히는 Sync와 Send 트레이트 |
스레드를 이용하여 코드를 동시에 실행하기
지금의 운영체제 대부분에서, 실행되는 프로그램의 코드는 프로세스(process) 안에서 실행되며, 운영체제는 여러 프로세스를 한꺼번에 관리합니다. 프로그램 안에서도 동시에 실행되는 독립적인 부분을 둘 수 있습니다. 이런 독립적인 부분을 실행하는 기능을 스레드(thread)라고 합니다. 예를 들어 웹 서버에 스레드를 여럿 두면 한 번에 여러 요청에 응답할 수 있습니다.
프로그램의 계산을 여러 스레드로 나눠 여러 작업을 동시에 실행하면 성능이 좋아질 수 있지만, 복잡성도 늘어납니다. 스레드는 동시에 실행될 수 있으므로, 서로 다른 스레드의 코드가 어떤 순서로 실행될지 본질적으로 보장할 수 없습니다. 이 때문에 다음과 같은 문제가 생길 수 있습니다.
| 문제 | 내용 |
|---|---|
| 경쟁 조건(race condition) | 스레드들이 데이터나 자원에 일관되지 않은 순서로 접근합니다. |
| 교착 상태(deadlock) | 두 스레드가 서로를 기다려, 두 스레드 모두 계속 진행하지 못합니다. |
| 특정 상황에서만 나는 버그 | 특정 상황에서만 일어나 재현하고 안정적으로 고치기 어려운 버그입니다. |
러스트는 스레드를 쓸 때의 부정적인 영향을 줄이려고 하지만, 멀티스레드 상황에서 프로그래밍하는 데는 여전히 신중하게 생각해야 하며, 단일 스레드에서 실행되는 프로그램과는 다른 코드 구조가 필요합니다.
프로그래밍 언어는 스레드를 몇 가지 서로 다른 방식으로 구현하며, 많은 운영체제는 언어가 새 스레드를 만들기 위해 부를 수 있는 API를 제공합니다. 러스트 표준 라이브러리는 1:1 스레드 구현 모델을 쓰며, 이는 프로그램이 언어의 스레드 하나마다 운영체제 스레드 하나를 쓴다는 뜻입니다. 일대일, 다대일, 다대다 모델의 차이는 운영체제 교재의 멀티스레딩 모델에서 비교합니다. 1:1 모델과 다른 트레이드오프를 가진 다른 스레드 모델을 구현한 크레이트도 있습니다.
spawn으로 새로운 스레드 생성하기
새 스레드를 만들려면 thread::spawn 함수를 부르고, 새 스레드에서 실행할 코드가 담긴 클로저를 넘깁니다. 클로저는 클로저: 자신의 환경을 캡처하는 익명 함수에서 다루었습니다. 다음 예제는 메인 스레드에서 어떤 텍스트를, 새 스레드에서 다른 텍스트를 출력합니다.
use std::thread;
use std::time::Duration;
fn main() {
thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {i} from the main thread!");
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 핸들을 사용하여 모든 스레드가 끝날 때까지 기다리기
앞의 코드는 대부분 메인 스레드가 끝나 생성된 스레드를 일찍 멈추게 할 뿐 아니라, 스레드가 실행되는 순서도 보장되지 않으므로 생성된 스레드가 실행될 수 있다는 것조차 보장할 수 없습니다.
생성된 스레드가 실행되지 않거나 일찍 끝나는 문제는, thread::spawn의 반환값을 변수에 저장해 고칠 수 있습니다. thread::spawn의 반환 타입은 JoinHandle<T>입니다. JoinHandle<T>는 소유한 값이며, 그것에 join 메서드를 부르면 그 스레드가 끝날 때까지 기다립니다. 다음 코드는 앞에서 만든 스레드의 JoinHandle<T>를 저장하고 join을 불러, main이 끝나기 전에 생성된 스레드가 끝나게 하는 방법을 보여 줍니다.
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi number {i} from the main thread!");
thread::sleep(Duration::from_millis(1));
}
handle.join().unwrap();
}핸들에 join을 부르면, 지금 실행 중인 스레드는 핸들이 나타내는 스레드가 끝날 때까지 막힙니다(block). 스레드를 막는다는 것은 그 스레드가 일을 하거나 끝나지 못하게 한다는 뜻입니다. join 호출을 메인 스레드의 for 루프 뒤에 두었으므로, 이 코드를 실행하면 다음과 비슷한 출력이 나옵니다.
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()을 부른 덕분에 메인 스레드가 기다리며, 생성된 스레드가 끝나기 전에는 끝나지 않습니다. 그런데 다음처럼 handle.join()을 main의 for 루프 앞으로 옮기면 무슨 일이 일어나는지 봅니다.
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..10 {
println!("hi number {i} from the spawned thread!");
thread::sleep(Duration::from_millis(1));
}
});
handle.join().unwrap();
for i in 1..5 {
println!("hi number {i} from the main thread!");
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 클로저 사용하기
thread::spawn에 넘기는 클로저에는 move 키워드를 자주 씁니다. 그러면 클로저가 환경에서 쓰는 값의 소유권을 가져가, 그 값의 소유권이 한 스레드에서 다른 스레드로 옮겨 가기 때문입니다. 참조자를 캡처하거나 소유권 이동하기에서 클로저의 맥락으로 move를 이야기했습니다. 이제 move와 thread::spawn의 상호작용에 더 집중합니다.
앞의 첫 예제에서 thread::spawn에 넘긴 클로저는 인수를 받지 않습니다. 생성된 스레드의 코드에서 메인 스레드의 데이터를 전혀 쓰지 않습니다. 생성된 스레드에서 메인 스레드의 데이터를 쓰려면, 생성된 스레드의 클로저가 필요한 값을 캡처해야 합니다. 다음 코드는 메인 스레드에서 벡터를 만들어 생성된 스레드에서 쓰려는 시도입니다. 하지만 곧 보듯 아직 동작하지 않습니다.
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에 접근할 수 있어야 합니다. 하지만 이 예제를 컴파일하면 다음 오류가 납니다.
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`.러스트는 v를 어떻게 캡처할지 추론하며, println!에는 v의 참조만 필요하므로 클로저는 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); // oh no!
handle.join().unwrap();
}러스트가 이 코드를 실행하도록 허용한다면, 생성된 스레드가 전혀 실행되지 않은 채 곧바로 뒤로 밀려날 수 있습니다. 생성된 스레드 안에는 v의 참조가 있지만, 메인 스레드는 Drop과 자원 정리에서 이야기한 drop 함수로 곧바로 v를 버립니다. 그다음 생성된 스레드가 실행되기 시작하면 v는 더는 유효하지 않으므로, 그 참조도 유효하지 않습니다. 큰일입니다.
앞의 컴파일러 오류를 고치려면, 오류 메시지의 조언을 따르면 됩니다.
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 키워드를 붙이면, 러스트가 값을 빌려야 한다고 추론하게 두는 대신 클로저가 쓰는 값의 소유권을 가져가게 강제합니다. 다음은 앞의 첫 v 예제를 이렇게 고친 것으로, 의도한 대로 컴파일되고 실행됩니다.
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();
}drop을 부른 앞의 코드에서도 move 클로저로 고치면 되겠다고 생각할 수 있습니다. 하지만 그 코드는 다른 까닭으로 허용되지 않으므로, 이 방법으로는 고쳐지지 않습니다. 클로저에 move를 붙이면 v가 클로저의 환경 안으로 옮겨지므로, 메인 스레드에서 더는 drop을 부를 수 없습니다. 대신 다음 컴파일러 오류가 납니다.
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); // oh no!
| ^ value used here after move
|
help: consider cloning the value before moving it into the closure
|
6 ~ let value = v.clone();
7 ~ let handle = thread::spawn(move || {
8 ~ println!("Here's a vector: {value:?}");
|
For more information about this error, try `rustc --explain E0382`.러스트의 소유권 규칙이 또 우리를 구했습니다. 앞의 코드에서 오류가 난 까닭은 러스트가 보수적이어서 스레드에 v를 빌려주기만 했기 때문이며, 그러면 메인 스레드가 이론적으로 생성된 스레드의 참조를 유효하지 않게 만들 수 있었습니다. 러스트에게 v의 소유권을 생성된 스레드로 옮기라고 하면, 메인 스레드가 더는 v를 쓰지 않을 것이라고 보장하는 셈입니다. drop을 부른 코드를 같은 방식으로 바꾸면, 메인 스레드에서 v를 쓰려 할 때 소유권 규칙을 어기게 됩니다. move 키워드는 빌리는 것을 기본으로 하는 러스트의 보수적인 동작을 뒤집습니다. 그렇다고 소유권 규칙을 어기게 해 주지는 않습니다. 아래 그림은 spawn, 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를 받는다. |
|
|
thread::spawn은 클로저와 그 반환값이 Send + 'static을 만족하라고 요구합니다. move는 캡처한 값을 클로저로 가져올 뿐 이 조건을 저절로 만족시키지는 않으며, 참조를 move로 캡처해도 그 참조가 가리키는 값의 수명이 늘어나지는 않습니다. spawn이 JoinHandle을 돌려준 뒤에는 두 스레드의 코드가 동시에 실행될 수 있고, 출력이 섞이는 순서는 보장되지 않습니다. handle.join()은 자식 스레드가 아직 실행 중이면 기다리고, 이미 끝났다면 곧바로 결과를 관찰해, 정상이면 Ok(T)를, 패닉이 났다면 Err(payload)를 돌려줍니다. 그림의 아래쪽처럼 이 글의 Vec 예제에서는 move로 벡터를 캡처하므로 그 뒤로 원래의 v를 쓸 수 없습니다.
join 대신 drop(handle)로 핸들을 버리면 스레드가 떨어져 나가(detach), 그 핸들로는 완료나 반환값, 패닉을 관찰할 수 없게 됩니다. 핸들을 버린다고 자식 스레드가 강제로 멈추지는 않으며, 프로세스의 main이 끝나면 남은 스레드도 함께 끝납니다. 빌린 값을 스레드에서 써야 한다면 thread::scope를 쓸 수 있습니다. scope는 정해진 수명 동안 빌리는 것을 허용하고, scope가 돌아오기 전에 그 안에서 만든 스레드에 모두 합류합니다. thread::sleep은 실행 순서나 완료를 보장하는 동기화 수단이 아닙니다.
스레드가 무엇이고 스레드 API가 제공하는 메서드가 무엇인지 기본적으로 알았으니, 스레드를 쓸 수 있는 몇 가지 상황을 봅니다.
더 깊이: 표준 라이브러리가 운영체제 스레드를 그대로 쓰는 까닭
러스트가 처음부터 1:1 모델만 썼던 것은 아닙니다. 예전의 표준 라이브러리에는 런타임이 있어서, 언어가 직접 스케줄링하는 그린 스레드(green thread)와 운영체제 스레드를 하나의 인터페이스로 함께 지원했습니다. RFC 230은 이 런타임을 표준 라이브러리에서 없애고 그린 스레드 구현을 별도의 패키지로 옮겨, std::io를 운영체제 스레드에 맞추자고 제안했습니다. 두 모델을 한 I/O 인터페이스로 감싸느라 스레드 모델과 I/O 모델이 서로 묶이고, 그 구조 때문에 표준 라이브러리를 정적으로 연결한 모든 바이너리에 I/O 시스템 전체가 들어가며, C 같은 다른 언어에서 러스트를 부를 때 런타임을 준비하는 일이 복잡하다는 것이 주된 까닭이었습니다.
운영체제 스레드를 그대로 쓰므로 프로그램이 끝나는 방식도 그에 맞춰져 있습니다. std::thread 문서는 메인 스레드가 끝나면 다른 스레드가 실행 중이어도 프로그램 전체가 끝나며, 남은 스레드의 스택에 있는 값의 소멸자는 실행되지 않을 수 있다고 설명합니다. 또 메인 스레드가 잡히지 않은 패닉을 내면 프로그램은 0이 아닌 종료 코드로 끝나지만, 다른 스레드의 잡히지 않은 패닉은 그 스레드만 끝내고 join으로 알아낼 수 있습니다. 그래서 파일을 마무리하는 것처럼 정리가 중요한 일을 맡은 스레드라면, 메인 스레드가 끝나기 전에 join으로 기다려야 합니다.
정리
- 러스트의 소유권과 타입 검사는 많은 동시성 오류를 컴파일 시점 오류로 바꿔 주며, 이것을 겁 없는 동시성이라고 부릅니다.
thread::spawn에 클로저를 넘기면 새 스레드에서 실행되며, 스레드의 실행 순서는 보장되지 않고 메인 스레드가 끝나면 남은 스레드도 멈춥니다.spawn이 돌려주는JoinHandle에join을 부르면 그 스레드가 끝날 때까지 기다리고, 결과를Result로 받습니다.join을 부르는 위치에 따라 동시 실행 여부가 달라집니다.- 생성된 스레드에서 메인 스레드의 값을 쓰려면
move클로저로 소유권을 넘겨야 하며, 빌리기만 하면 E0373 오류가 납니다. - 소유권을 넘긴 뒤 원래 스레드에서 그 값을 쓰면 E0382 오류가 나며, 빌린 값을 스레드에서 써야 한다면
thread::scope를 씁니다.
연습 문제
1. 무엇이 출력될까
다음 프로그램은 언제나 sum = 6을 출력하나요? handle.join() 줄을 지우면 어떻게 되나요?
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
let sum: i32 = v.iter().sum();
println!("sum = {sum}");
});
handle.join().unwrap();
}해설 보기
join이 생성된 스레드가 끝날 때까지 기다리므로 언제나 sum = 6을 출력합니다. join 줄을 지우면 메인 스레드가 곧바로 끝날 수 있고, 그러면 생성된 스레드가 출력하기 전에 프로세스가 끝나 아무것도 출력되지 않을 수 있습니다. 실행할 때마다 결과가 다를 수 있으며, 이 경우 handle을 쓰지 않는다는 경고도 나옵니다.
2. 스레드의 반환값 받기
생성된 스레드에서 계산한 합계를 메인 스레드로 돌려받고 싶습니다. 앞의 코드를 어떻게 고치면 되나요?
해설 보기
클로저가 합계를 돌려주게 하면 thread::spawn은 JoinHandle<i32>를 돌려주고, join이 그 값을 Ok에 담아 돌려줍니다.
let handle = thread::spawn(move || v.iter().sum::<i32>());
let sum = handle.join().unwrap();
println!("sum = {sum}");생성된 스레드가 패닉을 냈다면 join은 Err를 돌려주므로, unwrap 대신 match로 그 경우를 처리할 수도 있습니다.
다음 글 스레드 간 메시지 전달에서는 채널로 스레드 사이에 데이터를 주고받는 방법을 살펴봅니다.