스레드로 코드를 동시에 실행하기
대부분의 현대 운영체제에서, 실행 중인 프로그램 코드는 하나의 프로세스(process) 안에서 돌고, 운영체제가 여러 프로세스를 동시에 관리합니다. 또한 프로그램 내부에도 동시에 독립적으로 실행되는 부분들을 둘 수 있습니다. 이런 독립된 부분을 실행하는 기능을 스레드(threads) 라고 부릅니다. 예를 들어 웹 서버는 여러 스레드를 두어 동시에 여러 요청에 응답할 수 있습니다.
프로그램의 계산을 여러 스레드로 나누어 여러 작업을 동시에 실행하면 성능을 높일 수 있지만, 그만큼 복잡성도 커집니다. 스레드들은 동시에 실행될 수 있기 때문에, 서로 다른 스레드에서 코드가 어떤 순서로 실행될지에 대한 보장은 기본적으로 없습니다. 그래서 다음과 같은 문제가 생길 수 있습니다.
- 스레드가 데이터나 자원에 일관성 없는 순서로 접근하는 경쟁 상태(race conditions)
- 두 스레드가 서로를 기다리기만 하며 둘 다 더 이상 진행하지 못하는 교착 상태(deadlocks)
- 특정 상황에서만 발생하고, 재현과 수정이 어려운 버그
러스트는 스레드 사용의 부정적인 영향을 줄이려 하지만, 멀티스레드 문맥의 프로그래밍이 여전히 신중한 사고를 요구하고 단일 스레드 프로그램과는 다른 코드 구조를 요구한다는 사실은 변하지 않습니다.
프로그래밍 언어는 스레드를 여러 방식으로 구현합니다. 또한 많은 운영체제는 새 스레드를 만들기 위한 API를 제공합니다. 러스트 표준 라이브러리는 1:1 모델을 사용합니다. 즉, 프로그램의 언어 수준 스레드 하나당 운영체제 스레드 하나를 사용하는 방식입니다. 다른 스레딩 모델을 구현한 크레이트도 있고, 각각 1:1 모델과는 다른 트레이드오프를 가집니다. (다음 장에서 볼 러스트의 async 시스템 역시 동시성을 다루는 또 다른 방법을 제공합니다.)
spawn 으로 새 스레드 만들기
새 스레드를 만들려면 thread::spawn 함수를 호출하고, 그 안에 새 스레드에서 실행할
코드를 담은 클로저(13장에서 다뤘습니다)를 넘기면 됩니다. 목록 16-1은 메인 스레드와
새 스레드가 각각 다른 문자열을 출력하는 예입니다.
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까지만 출력하고 끝났습니다.
이 코드를 실행했는데 메인 스레드 출력만 보이거나, 두 스레드 출력이 전혀 섞이지 않는다면 반복 범위 숫자를 키워 운영체제가 스레드를 바꿔 탈 기회를 더 많이 만들어 보세요.
모든 스레드가 끝날 때까지 기다리기
목록 16-1의 코드는 생성된 스레드가 대개 끝나기도 전에 메인 스레드가 먼저 종료해 버리는 문제가 있을 뿐만 아니라, 스레드 실행 순서도 보장되지 않기 때문에 생성된 스레드가 실행될 기회 자체가 없을 수도 있습니다.
생성된 스레드가 아예 실행되지 않거나, 끝나기 전에 잘리는 문제는 thread::spawn
의 반환값을 변수에 저장함으로써 해결할 수 있습니다. thread::spawn 의 반환 타입은
JoinHandle<T> 입니다. JoinHandle<T> 는 소유권을 가진 값이며, 여기에 대해
join 메서드를 호출하면 해당 스레드가 끝날 때까지 기다립니다. 목록 16-2는 목록
16-1에서 만든 스레드의 JoinHandle<T> 를 저장하고, main 이 종료되기 전에
생성된 스레드가 끝나도록 join 을 호출하는 방법을 보여 줍니다.
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();
}
thread::spawn 의 JoinHandle<T> 를 저장해 스레드가 끝까지 실행되도록 보장하기핸들에 대해 join 을 호출하면 현재 실행 중인 스레드는, 그 핸들이 나타내는 스레드가
끝날 때까지 블록됩니다. 스레드를 블록한다 는 것은, 해당 스레드가 더 이상 일을
하거나 종료하지 못하고 기다리게 된다는 뜻입니다. 우리는 메인 스레드의 for
루프 뒤에 join 을 두었으므로, 목록 16-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() 호출 때문에
생성된 스레드가 끝날 때까지 종료되지 않습니다.
그렇다면 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 키워드를 함께 쓰는 경우가 많습니다.
클로저가 환경에서 사용하는 값을 소유권과 함께 가져오게 만들어, 그 값들의 소유권을
한 스레드에서 다른 스레드로 넘기기 위함입니다. 13장의 [“참조를 캡처하거나 소유권을
이동하기”][capture] 절에서 클로저의 move 를 이미 다루었죠.
여기서는 move 와 thread::spawn 의 상호작용에 좀 더 집중하겠습니다.
목록 16-1에서 thread::spawn 에 넘긴 클로저는 인수를 받지 않았다는 점에 주목하세요.
생성된 스레드 코드 안에서 메인 스레드의 데이터는 아무 것도 사용하지 않았기 때문입니다.
메인 스레드의 데이터를 새 스레드에서 쓰려면, 생성된 스레드의 클로저가 필요한 값을
캡처해야 합니다. 목록 16-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` (bin "threads") due to 1 previous error
러스트는 v 를 어떻게 캡처할지 추론 하는데, 여기서는 println! 이 v 에 대한
참조만 필요하므로 클로저는 v 를 빌리려 합니다. 하지만 문제가 있습니다. 러스트는
생성된 스레드가 얼마나 오래 실행될지 알 수 없으므로, v 에 대한 참조가 언제나
유효할지 판단할 수 없습니다.
목록 16-4는 이 참조가 실제로 유효하지 않을 가능성이 높은 상황을 보여 줍니다.
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 한 뒤, 생성된 스레드 클로저가 그 참조를 쓰려는 상황만약 러스트가 이 코드를 허용한다면, 생성된 스레드는 전혀 실행되지 못한 채 뒤로
밀릴 수도 있습니다. 하지만 생성된 스레드 안에는 v 에 대한 참조가 있습니다.
그런데 메인 스레드는 곧바로 15장에서 다뤘던 drop 함수를 사용해 v 를 drop
해 버립니다. 그렇게 되면 생성된 스레드가 실제로 실행되기 시작할 때는 v 가 더 이상
유효하지 않고, 따라서 그것에 대한 참조도 무효가 됩니다. 큰일이죠!
목록 16-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 키워드를 추가하면, 러스트가 참조를 빌리는 대신 클로저가 사용하는
값의 소유권을 가져오게 강제할 수 있습니다. 목록 16-3을 이렇게 수정한 버전이
목록 16-5이고, 이번에는 우리가 원한 대로 컴파일되고 실행됩니다.
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 사용하기