스레드를 사용하여 코드 동시 실행하기

Beginner

This tutorial is from open-source community. Access the source code

소개

코드를 동시에 실행하기 위한 스레드 사용에 오신 것을 환영합니다. 이 랩은 Rust Book의 일부입니다. LabEx 에서 Rust 기술을 연습할 수 있습니다.

이 랩에서는 프로그래밍에서 스레드 (thread) 의 개념과 이를 사용하여 코드를 동시에 실행하는 방법을 살펴봅니다. 이는 성능을 향상시키지만, 경쟁 조건 (race condition), 교착 상태 (deadlock), 재현하기 어려운 버그와 같은 복잡성과 잠재적인 문제를 추가합니다.

코드를 동시에 실행하기 위한 스레드 사용

대부분의 현재 운영 체제에서 실행되는 프로그램의 코드는 프로세스 (process) 내에서 실행되며, 운영 체제는 여러 프로세스를 동시에 관리합니다. 프로그램 내에서도 동시에 실행되는 독립적인 부분을 가질 수 있습니다. 이러한 독립적인 부분을 실행하는 기능을 *스레드 (thread)*라고 합니다. 예를 들어, 웹 서버는 여러 스레드를 가질 수 있어 동시에 여러 요청에 응답할 수 있습니다.

프로그램의 계산을 여러 스레드로 분할하여 여러 작업을 동시에 실행하면 성능을 향상시킬 수 있지만, 복잡성도 추가됩니다. 스레드는 동시에 실행될 수 있으므로, 서로 다른 스레드의 코드 부분이 실행되는 순서에 대한 본질적인 보장이 없습니다. 이는 다음과 같은 문제로 이어질 수 있습니다.

  • 경쟁 조건 (race condition): 스레드가 일관성 없는 순서로 데이터 또는 리소스에 접근하는 경우
  • 교착 상태 (deadlock): 두 스레드가 서로를 기다리면서 두 스레드 모두 계속 진행하지 못하는 경우
  • 특정 상황에서만 발생하고 안정적으로 재현하고 수정하기 어려운 버그

Rust 는 스레드 사용의 부정적인 영향을 완화하려고 시도하지만, 멀티스레드 (multithreaded) 컨텍스트에서 프로그래밍하는 것은 여전히 신중한 사고를 필요로 하며, 단일 스레드에서 실행되는 프로그램과는 다른 코드 구조가 필요합니다.

프로그래밍 언어는 스레드를 몇 가지 다른 방식으로 구현하며, 많은 운영 체제는 언어가 새로운 스레드를 생성하기 위해 호출할 수 있는 API 를 제공합니다. Rust 표준 라이브러리는 1:1 모델의 스레드 구현을 사용하며, 여기서 프로그램은 언어 스레드 하나당 하나의 운영 체제 스레드를 사용합니다. 1:1 모델과 다른 트레이드 오프를 만드는 다른 스레딩 모델을 구현하는 크레이트 (crate) 가 있습니다.

spawn 을 사용하여 새로운 스레드 생성

새로운 스레드를 생성하려면 thread::spawn 함수를 호출하고, 새로운 스레드에서 실행하려는 코드를 포함하는 클로저 (closure, 13 장에서 클로저에 대해 이야기했습니다) 를 전달합니다. Listing 16-1 의 예제는 메인 스레드 (main thread) 에서 텍스트를 출력하고, 새로운 스레드에서 다른 텍스트를 출력합니다.

파일 이름: 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: 메인 스레드가 다른 것을 출력하는 동안 새로운 스레드를 생성하여 한 가지를 출력

Rust 프로그램의 메인 스레드가 완료되면, 생성된 모든 스레드는 실행이 완료되었는지 여부에 관계없이 종료됩니다. 이 프로그램의 출력은 매번 약간 다를 수 있지만, 다음과 유사하게 보일 것입니다.

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 문이 코드에서 먼저 나타났음에도 불구하고 그렇습니다. 그리고 생성된 스레드에 i가 9 가 될 때까지 출력하도록 지시했지만, 메인 스레드가 종료되기 전에 5 까지만 출력되었습니다.

이 코드를 실행하고 메인 스레드의 출력만 보거나, 겹치는 부분이 전혀 보이지 않는다면, 운영 체제가 스레드 간에 전환할 수 있는 더 많은 기회를 만들기 위해 범위의 숫자를 늘려보세요.

join 핸들을 사용하여 모든 스레드가 완료될 때까지 대기

Listing 16-1 의 코드는 메인 스레드가 종료되어 생성된 스레드가 대부분의 경우 조기에 중단될 뿐만 아니라, 스레드가 실행되는 순서에 대한 보장이 없기 때문에 생성된 스레드가 전혀 실행되지 않을 수도 있습니다!

thread::spawn의 반환 값을 변수에 저장하여 생성된 스레드가 실행되지 않거나 조기에 종료되는 문제를 해결할 수 있습니다. thread::spawn의 반환 유형은 JoinHandle<T>입니다. JoinHandle<T>는 소유된 값으로, join 메서드를 호출하면 해당 스레드가 완료될 때까지 대기합니다. Listing 16-2 는 Listing 16-1 에서 생성한 스레드의 JoinHandle<T>를 사용하는 방법과 join을 호출하여 main이 종료되기 전에 생성된 스레드가 완료되도록 하는 방법을 보여줍니다.

파일 이름: 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::spawn에서 JoinHandle<T> 저장

핸들에서 join을 호출하면 핸들로 표현되는 스레드가 종료될 때까지 현재 실행 중인 스레드가 블록됩니다. 스레드를 *블로킹 (blocking)*한다는 것은 해당 스레드가 작업을 수행하거나 종료되는 것을 방지한다는 의미입니다. 메인 스레드의 for 루프 뒤에 join 호출을 배치했으므로, Listing 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() 호출로 인해 대기하며, 생성된 스레드가 완료될 때까지 종료되지 않습니다.

하지만 mainfor 루프 앞에 handle.join()을 이동하면 어떻게 되는지 살펴보겠습니다. 다음과 같습니다.

파일 이름: 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));
    }
}

메인 스레드는 생성된 스레드가 완료될 때까지 대기한 다음 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에 대해 논의했습니다. 이제 movethread::spawn 간의 상호 작용에 더 집중하겠습니다.

Listing 16-1 에서 thread::spawn에 전달하는 클로저가 인수를 받지 않는다는 점에 유의하세요. 생성된 스레드의 코드에서 메인 스레드의 데이터를 사용하지 않고 있습니다. 메인 스레드의 데이터를 생성된 스레드에서 사용하려면 생성된 스레드의 클로저가 필요한 값을 캡처해야 합니다. Listing 16-3 은 메인 스레드에서 벡터를 생성하고 이를 생성된 스레드에서 사용하려는 시도를 보여줍니다. 그러나 잠시 후에 보시겠지만, 이것은 아직 작동하지 않습니다.

파일 이름: 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: 다른 스레드에서 메인 스레드가 생성한 벡터를 사용하려는 시도

클로저는 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 || {
  |                                ++++

Rust 는 v를 캡처하는 방법을 추론하고, println!v에 대한 참조만 필요하므로 클로저는 v를 빌리려고 시도합니다. 그러나 문제가 있습니다. Rust 는 생성된 스레드가 얼마나 오래 실행될지 알 수 없으므로, v에 대한 참조가 항상 유효한지 알 수 없습니다.

Listing 16-4 는 v에 대한 유효하지 않은 참조가 있을 가능성이 더 높은 시나리오를 제공합니다.

파일 이름: 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를 삭제하는 클로저가 있는 스레드가 v에 대한 참조를 캡처하려고 시도

Rust 가 이 코드를 실행하도록 허용했다면, 생성된 스레드가 전혀 실행되지 않고 즉시 백그라운드로 이동될 가능성이 있습니다. 생성된 스레드는 내부에 v에 대한 참조를 가지고 있지만, 메인 스레드는 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-5 에 표시된 Listing 16-3 에 대한 수정 사항은 의도한 대로 컴파일되고 실행됩니다.

파일 이름: 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 키워드를 사용하여 클로저가 사용하는 값의 소유권을 가져오도록 강제

메인 스레드가 drop을 호출한 Listing 16-4 의 코드를 수정하기 위해 동일한 작업을 시도하고 move 클로저를 사용할 수도 있습니다. 그러나 이 수정 사항은 작동하지 않습니다. Listing 16-4 가 시도하는 작업은 다른 이유로 허용되지 않기 때문입니다. 클로저에 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

Rust 의 소유권 규칙이 다시 우리를 구했습니다! Listing 16-3 의 코드에서 오류가 발생한 이유는 Rust 가 보수적으로 작동하여 스레드에 대해 v를 빌리기만 했기 때문입니다. 즉, 메인 스레드가 이론적으로 생성된 스레드의 참조를 무효화할 수 있었습니다. Rust 에 v의 소유권을 생성된 스레드로 이동하도록 지시함으로써, 메인 스레드가 더 이상 v를 사용하지 않을 것이라고 Rust 에 보장하는 것입니다. Listing 16-4 를 동일한 방식으로 변경하면, 메인 스레드에서 v를 사용하려고 할 때 소유권 규칙을 위반하게 됩니다. move 키워드는 Rust 의 보수적인 기본 빌림을 무시합니다. 소유권 규칙을 위반하도록 허용하지 않습니다.

이제 스레드가 무엇인지와 스레드 API 에서 제공하는 메서드를 다뤘으므로, 스레드를 사용할 수 있는 몇 가지 상황을 살펴보겠습니다.

요약

축하합니다! 코드를 동시에 실행하기 위한 스레드 사용 랩을 완료했습니다. LabEx 에서 더 많은 랩을 연습하여 실력을 향상시킬 수 있습니다.