스레드로 코드 동시 실행하기
스레드로 코드 동시 실행하기
대부분의 운영체제에서 실행되는 프로그램의 코드는 '프로세스(process)' 안에서 돌아가고, 운영체제는 여러 프로세스를 동시에 관리해요. 그런데 프로그램 하나 안에서도 서로 독립된 부분을 동시에 실행할 수 있어요. 이렇게 독립된 부분을 실행시키는 기능이 '스레드(thread)'예요. 예를 들어 웹 서버는 여러 스레드를 둬서 여러 요청에 동시에 응답할 수 있죠.
프로그램의 계산을 여러 스레드로 나눠 동시에 여러 작업을 돌리면 성능은 좋아지지만 복잡도도 올라가요. 스레드는 동시에 실행되므로 서로 다른 스레드의 코드가 어떤 순서로 실행될지는 보장이 없어요. 그래서 경쟁 조건(race condition)처럼 스레드들이 데이터에 일관되지 않은 순서로 접근하는 문제, 두 스레드가 서로를 기다리며 둘 다 멈춰버리는 교착 상태(deadlock), 특정 상황에서만 나타나 재현하고 고치기 어려운 버그 같은 문제가 생길 수 있어요.
Rust는 스레드를 쓸 때의 부정적인 영향을 줄이려고 하지만, 멀티스레드 프로그래밍은 여전히 신중한 사고와 단일 스레드 프로그램과는 다른 코드 구조를 요구해요. 이 페이지에서는 스레드를 만들고, 기다리고, 데이터를 옮기는 방법을 볼게요.
출처: The Rust Programming Language — Using Threads to Run Code Simultaneously
스레드 모델
프로그래밍 언어는 다양한 방식으로 스레드를 구현해요. Rust 표준 라이브러리는 1:1 모델을 써요. 언어 스레드 하나당 운영체제 스레드 하나를 쓰는 방식이죠. 1:1 모델과 다른 트레이드오프를 가진 스레딩 모델을 구현하는 크레이트도 있어요. (다음 장에서 볼 async 시스템은 동시성의 또 다른 접근법이에요.)
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));
}
}
주의할 점은, Rust 프로그램의 메인 스레드가 끝나면 스폰(spawn)된 스레드는 실행을 끝냈는지와 무관하게 전부 종료돼요. 이 프로그램의 출력은 매번 조금씩 달라지지만 대략 비슷해요. thread::sleep 호출은 스레드 실행을 잠시 멈추게 해서 다른 스레드가 돌 기회를 줘요. 스레드들이 아마 번갈아 실행되겠지만 그건 보장되지 않아요. 운영체제가 어떻게 스케줄하느냐에 달려 있죠. 이 실행에서는 코드에서 스폰된 스레드의 print 문이 먼저 나오는데도 메인 스레드가 먼저 출력했고, 스폰된 스레드에 i가 9까지 출력하라고 했는데도 메인 스레드가 종료돼서 5까지만 갔어요. 실행해 봤는데 메인 스레드 출력만 보이거나 겹침이 안 보인다면, 범위의 숫자를 늘려서 운영체제가 스레드 사이를 전환할 기회를 더 만들어 보세요.
모든 스레드가 끝날 때까지 기다리기
방금 예시는 메인 스레드가 끝나서 스폰된 스레드가 대부분 미리 종료되고, 순서 보장이 없으니 스폰된 스레드가 아예 실행될지조차 보장할 수 없어요. 이 문제를 고치려면 thread::spawn의 반환 값을 변수에 저장하면 돼요. thread::spawn의 반환 타입은 JoinHandle<T>예요. JoinHandle<T>는 소유한 값으로, 여기에 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();
}
핸들에 join을 호출하면, 그 핸들로 표현된 스레드가 종료될 때까지 현재 실행 중인 스레드를 블록해요. '블록한다'는 건 그 스레드가 일을 하거나 종료하는 걸 막는다는 뜻이에요. 위 코드에서는 join 호출을 메인 스레드의 for 루프 뒤에 뒀으므로, 두 스레드가 번갈아 실행되지만 메인 스레드는 handle.join() 호출 때문에 기다렸다가 스폰된 스레드가 끝난 뒤에야 종료돼요.
반면 handle.join()을 for 루프 앞으로 옮기면, 메인 스레드가 스폰된 스레드가 끝나기를 먼저 기다렸다가 자기 for 루프를 돌 거라 출력이 더 이상 섞이지 않아요. join을 어디서 호출하느냐 같은 사소한 세부 사항이 스레드가 동시에 실행되는지에 영향을 줄 수 있어요.
스레드와 함께 move 클로저 사용하기
thread::spawn에 넘기는 클로저에서 move 키워드를 자주 쓰게 돼요. 그러면 클로저가 환경에서 쓰는 값들의 소유권을 가져가서, 그 값들의 소유권을 한 스레드에서 다른 스레드로 옮기게 되거든요. 목적은 간단해요. 새 스레드가 메인 스레드의 데이터를 쓰려면 그 데이터를 캡처해야 하는데, 스폰된 스레드는 언제 실행될지 모르니 메인 스레드가 가진 값에 대한 참조가 항상 유효하다는 보장이 없어요.
예를 들어 메인 스레드에서 벡터를 만들고 스폰된 스레드에서 쓰려 하면, 컴파일러가 v를 어떻게 캡처할지 추론해요. println!은 v에 대한 참조만 필요하므로 클로저가 v를 빌리려고 해요. 그런데 문제가 생겨요. Rust는 스폰된 스레드가 얼마나 오래 실행될지 알 수 없어서, 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();
}
여기서 클로저 앞에 move 키워드를 붙임으로써, Rust가 값을 빌리도록 추론하게 두는 대신 클로저가 사용하는 값들의 소유권을 가져가도록 강제해요. v의 소유권을 스폰된 스레드로 옮기는 거죠. 그렇게 하면 메인 스레드가 더 이상 v를 쓰지 않는다고 Rust에 보장하게 됩니다.
move가 콘솔에 있는 Rust의 보수적인 기본 동작인 '빌리기'를 덮어쓴다고 해서, 소유권 규칙을 어겨도 된다는 뜻은 아니에요. move 클로저 안으로 v를 옮긴 뒤 메인 스레드에서 drop(v)를 또 호출하려 하면, 이동된 값을 쓰는 것이므로 컴파일 오류가 나요. 소유권 규칙이 또 한 번 우리를 지켜준 거죠.
더 알아보기 (Learn more)
- 스레드 사이에 메시지를 주고받아 데이터를 전달하는 방식은 Chapter 16 — 메시지 전달에서 다뤄요.
- 여러 스레드가 상태(공유 메모리)를 안전하게 공유하는 방법은 Chapter 16 — 공유 상태에서 살펴볼 수 있어요.
- 스레드 간 안전 전송을 보장하는
Send·Sync트레이트는 Chapter 16 — 확장 가능한 동시성에서 확인하세요. move클로저가 환경의 값을 어떻게 캡처하는지는 Chapter 13 — 클로저를 참고하세요.