스레드로 코드를 동시에 실행하기

스레드로 코드를 동시에 실행하기

운영체제는 프로그램 하나를 프로세스 단위로 실행하면서, 여러 프로세스를 한꺼번에 관리해요. 그리고 그 안에서도 서로 독립적인 부분을 동시에 돌릴 수 있는데, 이 독립적인 실행 단위를 스레드라고 불러요. 웹 서버에 스레드가 여러 개 있으면 요청을 한 번에 둘 이상 처리할 수 있죠. 다만 스레드를 나누는 일은 성능을 올려 주는 대신 코드에 복잡함도 함께 가져오는데, 그 이유를 본문에서 차근차근 살펴볼게요.

출처: Rust 공식문서

본문

시작하기 전에 — 스레드라는 선택이 주는 것과 대가

현재 나오는 대부분의 운영체제에서는, 실행 중인 프로그램의 코드가 프로세스 안에서 실행되고, 운영체제가 한 번에 여러 프로세스를 관리해요. 그런데 프로그램 안에서도 서로 독립적인 부분이 동시에 실행되게 할 수 있어요. 이렇게 독립적인 부분들을 실행해 주는 기능을 _스레드_라고 불러요. 예를 들어 웹 서버는 스레드를 여러 개 둘 수 있는데, 그렇게 하면 요청 두 개 이상에 동시에 응답할 수 있게 되죠.

프로그램의 계산을 여러 스레드로 나눠 여러 작업을 동시에 실행하면 성능이 좋아질 수 있어요. 하지만 그만큼 복잡해지기도 하죠. 스레드는 동시에 실행되기 때문에, 서로 다른 스레드에 있는 코드가 어떤 순서로 실행될지 보장해 주는 건 원래 없어요. 그래서 이런 문제들이 생길 수 있어요:

  • 경쟁 조건(race conditions) — 스레드들이 데이터나 자원에 일관되지 않은 순서로 접근하는 경우
  • 교착 상태(deadlocks) — 두 스레드가 서로를 기다리느라 둘 다 진행하지 못하는 경우
  • 특정 상황에서만 생기고, 재현하거나 고치기 어려운 버그

Rust는 스레드를 쓰면서 생기는 부정적인 영향을 줄이려고 하지만, 멀티스레드 환경에서 프로그래밍하는 일은 여전히 신중한 생각과, 단일 스레드로 도는 프로그램과는 다른 코드 구조를 요구해요.

프로그래밍 언어마다 스레드를 구현하는 방식이 조금씩 달라요. 많은 운영체제가 새 스레드를 만들 때 언어가 호출할 수 있는 API를 제공하고 있고요. Rust 표준 라이브러리는 스레드 구현에 1:1 모델을 써요. 언어 스레드 하나가 운영체제 스레드 하나에 대응되는 방식이죠. 1:1 모델과는 다른 트레이드오프를 가진 스레딩 모델을 구현한 크레이트들도 있어요. (다음 장에서 볼 Rust의 async 시스템은 동시성에 대한 또 다른 접근법을 제공해요.)

thread::spawn으로 새 스레드 만들기

새 스레드를 만들려면 thread::spawn 함수를 호출하고, 새 스레드에서 실행하고 싶은 코드를 담은 클로저(13장에서 클로저를 다뤘죠)를 넘겨주면 돼요. 예제 16-1은 main 스레드에서 어떤 텍스트를, 새 스레드에서 다른 텍스트를 출력하는 코드예요.

Filename: src/main.rs

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));
}

}

Listing 16-1: 새 스레드를 만들어, main 스레드가 다른 걸 출력하는 동안 하나를 출력하기

Rust 프로그램의 main 스레드가 끝나면, 실행 중이었는지와 상관없이 모든 생성된 스레드가 함께 종료된다는 점을 기억해 두세요. 이 프로그램의 출력은 실행할 때마다 조금씩 다를 수 있지만, 대략 이런 모습이에요:

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 호출은 스레드의 실행을 잠시 멈추게 해서, 다른 스레드가 실행될 기회를 줘요. 스레드들이 번갈아 가며 실행될 가능성이 높지만 보장되지는 않아요. 스레드를 어떻게 스케줄할지는 운영체제가 정하니까요. 위 실행에서는 새 스레드의 print 문이 코드상에 먼저 나오는데도 main 스레드가 먼저 출력했어요. 또 i가 9가 될 때까지 출력하라고 했는데도, main 스레드가 종료되기 전까지 5까지만 도달했죠.

이 코드를 실행했는데 main 스레드 출력만 보이거나 겹침이 전혀 보이지 않는다면, 범위의 숫자를 키워서 운영체제가 스레드를 전환할 기회를 더 만들어 보세요.

모든 스레드가 끝날 때까지 기다리기

Listing 16-1의 코드는 main 스레드가 먼저 끝나서 새 스레드가 대부분 일찍 멈추는 문제가 있는데, 여기에 더해 스레드의 실행 순서가 보장되지 않으니, 생성된 스레드가 아예 실행될 기회를 얻지 못할 수도 있어요!

이 문제는 thread::spawn의 반환 값을 변수에 저장해서 해결할 수 있어요. thread::spawn의 반환 타입은 JoinHandle인데, JoinHandle은 소유(owned) 값으로, 그 위에서 join 메서드를 호출하면 해당 스레드가 끝날 때까지 기다려 줘요. Listing 16-2는 Listing 16-1에서 만든 스레드의 JoinHandle을 저장하고, main이 끝나기 전에 생성된 스레드가 끝나도록 join을 호출하는 방법을 보여줘요.

Filename: src/main.rs

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();

}

Listing 16-2: 스레드가 끝까지 실행되도록 thread::spawnJoinHandle<T>를 저장하기

핸들에 join을 호출하면, 핸들이 가리키는 스레드가 끝날 때까지 현재 실행 중인 스레드를 블록해요. 스레드를 블록한다는 건, 그 스레드가 작업을 수행하거나 종료하지 못하게 막는다는 뜻이에요. Listing 16-2에서는 main 스레드의 for 루프 뒤에 join 호출을 뒀기 때문에, 실행하면 대략 이렇게 출력돼요:

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 스레드는 기다렸다가, 생성된 스레드가 끝난 뒤에야 종료돼요.

그런데 handle.join()을 main의 for 루프 앞으로 옮기면 어떻게 될까요? 이렇게 말이죠:

Filename: src/main.rs

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));
}

}

main 스레드는 생성된 스레드가 끝날 때까지 기다렸다가 자기 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장의 ""Capturing References or Moving Ownership""에서 클로저의 맥락에서 move를 다뤘는데, 이제는 movethread::spawn의 상호작용에 좀 더 집중해 볼게요.

Listing 16-1에서 thread::spawn에 넘긴 클로저는 인자를 하나도 받지 않아요. 생성된 스레드의 코드에서 main 스레드의 데이터를 전혀 쓰지 않으니까요. 생성된 스레드에서 main 스레드의 데이터를 쓰려면, 그 클로저가 필요한 값을 캡처해야 해요. Listing 16-3은 main 스레드에서 벡터를 만들고 생성된 스레드에서 그걸 쓰려는 시도인데, 얼마 뒤에 알게 되겠지만 이건 아직 동작하지 않아요.

Filename: src/main.rs

use std::thread;

fn main() { let v = vec![1, 2, 3];

let handle = thread::spawn(|| {
    println!("Here's a vector: {v:?}");
});

handle.join().unwrap();

}

Listing 16-3: main 스레드에서 만든 벡터를 다른 스레드에서 사용하려는 시도

클로저가 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

Rust는 v를 어떻게 캡처할지 추론해요. println!v에 대한 참조만 필요하니까, 클로저는 v를 빌리려고 하죠. 그런데 여기에 문제가 있어요. Rust는 생성된 스레드가 얼마나 오래 실행될지 알 수 없으니, v에 대한 참조가 항상 유효할지를 알 수 없는 거예요.

Listing 16-4는 v에 대한 참조가 유효하지 않을 가능성이 더 큰 상황을 보여줘요.

Filename: src/main.rs

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();

}

Listing 16-4: v를 drop하는 main 스레드의 v에 대한 참조를 캡처하려는 클로저를 가진 스레드

Rust가 이 코드를 실행하게 내버려 뒀다면, 생성된 스레드가 전혀 실행되지 않은 채 바로 백그라운드로 밀려날 가능성이 있어요. 생성된 스레드는 안에 v에 대한 참조를 갖고 있는데, main 스레드는 15장에서 다룬 drop 함수로 v를 곧바로 버려 버리죠. 그러면 생성된 스레드가 실행을 시작할 때쯤엔 v가 더 이상 유효하지 않고, 그에 대한 참조도 마찬가지로 유효하지 않게 돼요. 아참, 곤란하네요!

Listing 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 키워드를 붙이면, Rust가 값들을 빌려야 한다고 추론하게 두는 대신, 클로저가 쓰는 값들의 소유권을 강제로 가져가게 해요. Listing 16-3을 이렇게 수정한 Listing 16-5는 의도한 대로 컴파일되고 실행돼요.

Filename: src/main.rs

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();

}

Listing 16-5: move 키워드로 클로저가 사용하는 값들의 소유권을 강제로 가져가기

아마 우리도 main 스레드가 drop을 호출하는 Listing 16-4의 코드를 고치려고 move 클로저를 같은 방식으로 써 보고 싶어질 거예요. 그런데 이 방법은 통하지 않아요. Listing 16-4가 하려는 일은 다른 이유로 금지되어 있거든요. 클로저에 move를 붙이면 v를 클로저 환경으로 옮기게 되고, 그러면 main 스레드에서 더 이상 vdrop을 호출할 수 없어요. 대신 이런 컴파일 에러를 받게 되죠:

$ 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); // 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. error: could not compile threads (bin "threads") due to 1 previous error

Rust의 소유권 규칙이 또 우리를 구해 줬어요! Listing 16-3의 코드에서 에러가 난 건, Rust가 보수적으로 스레드에 v를 빌려 주기만 해서, main 스레드가 생성된 스레드의 참조를 이론상 무효화할 수 있기 때문이에요. movev의 소유권을 생성된 스레드로 옮기라고 말해 주면, main 스레드는 v를 더 이상 쓰지 않겠다고 Rust에 보장하는 셈이 돼요. Listing 16-4에도 같은 방식으로 고치면, main 스레드에서 v를 쓰려고 할 때 소유권 규칙을 위반하게 되는 거죠. move 키워드는 Rust의 보수적인 기본값인 빌리기를 덮어쓸 뿐이지, 소유권 규칙을 위반하게 해 주는 게 아니에요.

이제 스레드가 무엇이고, 스레드 API가 제공하는 메서드가 무엇인지 다뤘으니, 스레드를 실제로 어떤 상황에서 쓸 수 있는지 몇 가지 살펴볼게요.

더 알아보기