#27 멀티 쓰레드와 메시지 전달

Pt J·2020년 9월 18일
1

[完] Rust Programming

목록 보기
30/41
post-thumbnail

이 시리즈는 Rust 공식문서를 통해 공부한 흔적임을 밝힙니다.

Rust는 여러 가지 측면에서 안전성과 효율성을 중시한다.
동시성 프로그래밍도 Rust가 안전성과 효율성을 신경쓰는 부분에 속한다.
Rust의 자료형과 소유권은 메모리 안전성뿐만 아니라 동시성 문제에도 유용하다.
자료형 및 소유권 검사를 컴파일 시간에 함으로써 많은 동시성 문제를 컴파일 시간에 해결 가능하다.

쓰레드 Thread

현존하는 대부분의 운영체제는 프로그램을 프로세스 단위로 실행한다.
프로세스는 독립적인 조각으로 나뉘어 동시에 실행되기도 하는데
이 조각을 우리는 쓰레드라고 부른다.
작업을 여러 개의 쓰레드로 나누어 서로 다른 코어에서 동시에 실행되도록 하면
프로그램의 성능을 향상시킬 수 있지만 그만큼 프로그램의 복잡도가 높아진다.
그리고 동시에 실행되길 기대하는 쓰레드끼리의 코드의 순서를 보장할 수 없다.

여러 개의 쓰레드를 사용하는 멀티 쓰레드 환경은 다음과 같은 문제를 야기할 수 있다.

  • 쓰레드들이 자료 또는 자원에 일관성 없는 순서로 접근하는 레이스 컨디션
    Race conditions, where threads are accessing data or resources in an inconsistent order
  • 두 쓰레드가 상대방이 사용 중인 자원의 사용을 마치길 기다리며 서로의 실행을 막는 데드락
    Deadlocks, where two threads are waiting for each other to finish using a resource the other thread has, preventing both threads from continuing
  • 특정 상황에서만 발생하여 다시 발생시키거나 수정하기 난해한 각종 버그들
    Bugs that happen only in certain situations and are hard to reproduce and fix reliably

Rust가 쓰레드 사용의 안전성을 높이려고 노력함에도 불구하고
그것을 사용할 때는 상당한 주의가 필요하다.

쓰레드 생성

쓰레드는 thread::spawn 함수를 통해 생성할 수 있다.
생성된 쓰레드에서 수행할 코드를 클로저의 형태로 인자로 전달한다.

다음은 쓰레드를 생성하는 예제다.
메인 쓰레드와 생성된 쓰레드가 각각 1 밀리초마다 문자열을 출력한다.
thread_sleep 함수는 현재 쓰레드만 일정 시간 정지하도록 한다.

peter@hp-laptop:~/rust-practice$ mkdir chapter16
peter@hp-laptop:~/rust-practice$ cd chapter16
peter@hp-laptop:~/rust-practice/chapter16$ cargo new thread_spawn
     Created binary (application) `thread_spawn` package
peter@hp-laptop:~/rust-practice/chapter16$ cd thread_spawn/
peter@hp-laptop:~/rust-practice/chapter16/thread_spawn$ vi src/main.rs

src/main.rs

use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(move || {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });
    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }
}
peter@hp-laptop:~/rust-practice/chapter16/thread_spawn$ cargo run
   Compiling thread_spawn v0.1.0 (/home/peter/rust-practice/chapter16/thread_spawn)
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/thread_spawn`
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!
peter@hp-laptop:~/rust-practice/chapter16/thread_spawn$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/thread_spawn`
hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 2 from the main thread!
hi number 3 from the spawned thread!
hi number 3 from the main thread!
hi number 4 from the spawned thread!
hi number 4 from the main thread!
hi number 5 from the spawned thread!
peter@hp-laptop:~/rust-practice/chapter16/thread_spawn$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/thread_spawn`
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!
peter@hp-laptop:~/rust-practice/chapter16/thread_spawn$ 

실행할 때마다 순서가 달라지는 것을 볼 수 있다.
물론 한 번 출력 후 1 밀리초 쉬기 때문에 완전히 뒤섞이진 않고
같은 숫자 내에서 어떤 쓰레드가 먼저 실행되냐 정도의 차이다.

그런데 한 가지 눈의 띄는 부분이 있다.
분명 새로 생성한 쓰레드는 1..10으로 범위를 지정했는데
4까지 출력되거나 많이 출력되어도 5까지 밖에 출력되지 않는다.
이는 어떤 쓰레드가 종료되면 그 쓰레드로부터 파생된 쓰레드는
완전히 실행되었는지 여부와 무관하게 종료되기 때문에 발생하는 현상이다.
// 새로 생성한 쓰레드를 자식 쓰레드, 그것을 생성해낸 쓰레드를 부모 쓰레드라고 한다.
심지어 쓰레드를 생성하고 그것이 실행되기 전에 부모 쓰레드가 종료될 경우
자식 쓰레드는 실행도 못해보고 끝나게 된다.

하지만 때로는 자식 쓰레드의 실행 종료가 보장되어야 할 때가 있는데
그 경우 thread::spawn이 반환하는 JoinHandle의 메서드 join을 사용한다.
부모 쓰레드에서 join을 호출하면 그 JoinHandle을 반환한 쓰레드가 종료할 때까지
부모 쓰레드는 쉬고 있다가 자식 쓰레드가 종료되고 나면 join에서 반환된다.

앞서 작성한 예제에 join을 추가해보자.

peter@hp-laptop:~/rust-practice/chapter16/thread_spawn$ vi src/main.rs

`src/main.rs

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(move || {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });
    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}
peter@hp-laptop:~/rust-practice/chapter16/thread_spawn$ cargo run
   Compiling thread_spawn v0.1.0 (/home/peter/rust-practice/chapter16/thread_spawn)
    Finished dev [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/thread_spawn`
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 spawned thread!
hi number 4 from the main 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!
peter@hp-laptop:~/rust-practice/chapter16/thread_spawn$ 

이제 자식 쓰레드가 끝까지 실행된 후 종료되는 것을 확인할 수 있다.
join은 병렬적으로 진행하고자 하는 코드 이후에 호출해야 한다.

자식에게 소유권 이전하기

부모 쓰레드에서 생성한 자료를 자식 클래스에서 사용하기 위해서는
그 자료를 자식 쓰레드에게 전달해야 한다.
단순 대여만 하고자 해도 자식 쓰레드가 얼마나 살아있을지 알 수 없으므로
그 참조가 얼마나 유효한지 알 수 없어 그냥 사용할 수 없다.
그런데 thread::spawn 함수의 인자로 들어가는 클로저는 매개변수가 없다.
하지만 다행히도 우리에겐 move 키워드가 존재한다.
클로저 앞에 move 키워드를 붙이면 주변값의 소유권을 가질 수 있다는 내용은
클로저에 대해 배울 때 다뤘던 내용이다.

peter@hp-laptop:~/rust-practice/chapter16/thread_spawn$ cd ..
peter@hp-laptop:~/rust-practice/chapter16$ cargo new move_closure
     Created binary (application) `move_closure` package
peter@hp-laptop:~/rust-practice/chapter16$ cd move_closure/
peter@hp-laptop:~/rust-practice/chapter16/move_closure$ vi src/main.rs

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();
}
peter@hp-laptop:~/rust-practice/chapter16/move_closure$ cargo run
   Compiling move_closure v0.1.0 (/home/peter/rust-practice/chapter16/move_closure)
    Finished dev [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/move_closure`
Here's a vector: [1, 2, 3]
peter@hp-laptop:~/rust-practice/chapter16/move_closure$ 

메시지 전달

Rust에는 채널Channel이라는 개념이 존재한다.
이것의 사전적 의미는 수로水路, 즉, 물길이다.
채널은 상류에서 하류로 향하는 자료의 흐름이다.
상류에서는 송신자Transmitter가 자료를 흘려 보내고
하류에서는 수신자Receiver가 흘러온 자료를 받는다.

채널은 mpsc::channel 함수를 통해 생성할 수 있다.
여기서 mpsc란 Multiple Producer, Single Consumer의 약자로
Rust 표준 라이브러리의 채널은 송신자와 수신자가 N:1 관계라는 것을 알 수 있다.
작은 물줄기들이 모여 하나의 강으로 흘러가듯이 말이다.
mpsc::channel(송신자, 수신자) 튜플을 반환하는데
송신자는 주로 tx, 수신자는 주로 rx라는 이름의 변수를 사용한다.

송신자와 수신자는 N:1 관계지만 가장 단순한 1:1 관계부터 알아보도록 하자.
자식 쓰레드가 부모 쓰레드에게 문자열을 흘려 보내는 예제다.

peter@hp-laptop:~/rust-practice/chapter16/move_closure$ cd ..
peter@hp-laptop:~/rust-practice/chapter16$ cargo new one_to_one
     Created binary (application) `one_to_one` package
peter@hp-laptop:~/rust-practice/chapter16$ cd one_to_one/
peter@hp-laptop:~/rust-practice/chapter16/one_to_one$ vi src/main.rs

src/main.rs

use std::thread;
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}
peter@hp-laptop:~/rust-practice/chapter16/one_to_one$ cargo run
   Compiling one_to_one v0.1.0 (/home/peter/rust-practice/chapter16/one_to_one)
    Finished dev [unoptimized + debuginfo] target(s) in 0.46s
     Running `target/debug/one_to_one`
Got: hi
peter@hp-laptop:~/rust-practice/chapter16/one_to_one$ 

수신자는 recv 또는 try_recv 메서드를 통해 자료를 수신할 수 있다.
recv는 값이 전달될 때까지 멈추고 기다리다가
값이 전달되면 Result 열거형의 Ok에 수신된 자료를 담아 반환한다.
만약 채널이 닫힐 때까지 아무것도 전달되지 않는다면 Err를 반환한다.
try_recv의 경우에는 그것이 호출된 순간까지 도착한 자료가 있는지에 따라
기다리지 않고 Ok 또는 Err를 바로 반환한다.

여기서 주의할 건, 자료를 상류에서 하류로 흘려 보내고 나면
이미 떠나간 자료이므로 상류에서는 그것에 접근할 수 없다는 것이다.
그 자료가 소유권까지 완전히 하류로 떠내려 갔으며
이는 의도치 않은 동시성 문제를 방지해준다.

수신자에서 자료를 수신하는 방법은 메서드를 활용하는 방법 말고도 존재한다.
for 문을 사용하면 Rust는 수신자를 반복자로 취급하고
값이 수신될 때마다 수신된 값을 아이템으로 하여 반복문을 실행할 수 있다.
그리고 채널이 열려 있는 동안 새로운 자료가 전달되길 기다리다가 전달되면 반복문을 실행하고
채널이 닫히면 반복문을 빠져 나오게 된다.

수신자에서 자료를 수신할 때까지 대기하는 것을 확인하기 위해
송신자가 1초마다 자료를 송신하는 예제를 작성해보자.

peter@hp-laptop:~/rust-practice/chapter16/one_to_one$ cd ..
peter@hp-laptop:~/rust-practice/chapter16$ cargo new iterate_message
     Created binary (application) `iterate_message` package
peter@hp-laptop:~/rust-practice/chapter16$ cd iterate_message/
peter@hp-laptop:~/rust-practice/chapter16/iterate_message$ vi src/main.rs

src/main.rs

use std::thread;
use std::sync::mpsc;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {}", received);
    }
}
peter@hp-laptop:~/rust-practice/chapter16/iterate_message$ cargo run
   Compiling iterate_message v0.1.0 (/home/peter/rust-practice/chapter16/iterate_message)
    Finished dev [unoptimized + debuginfo] target(s) in 0.51s
     Running `target/debug/iterate_message`
Got: hi
Got: from
Got: the
Got: thread
peter@hp-laptop:~/rust-practice/chapter16/iterate_message$ 

문자열이 1초 간격으로 출력되다가 종료되는 것을 확인할 수 있다.

전달자 복제

이번에는 송신자와 수신자가 N:1 관계라는 것을 떠올리며
두 개의 자식 쓰레드에서 자료를 전달하는 방법을 알아보자.

채널의 수신자는 자식 쓰레드로 소유권이 넘어가기 때문에
둘 이상의 수신자를 만들기 위해서는 그것을 복제해야 한다.
그리고 복제된 수신자를 각각의 쓰레드에서 사용하는 것이다.

peter@hp-laptop:~/rust-practice/chapter16/iterate_message$ cd ..peter@hp-laptop:~/rust-practice/chapter16$ cargo new many_to_one
     Created binary (application) `many_to_one` package
peter@hp-laptop:~/rust-practice/chapter16$ cp iterate_message/src/main.rs many_to_one/src/main.rs 
peter@hp-laptop:~/rust-practice/chapter16$ cd many_to_one/
peter@hp-laptop:~/rust-practice/chapter16/many_to_one$ vi src/main.rs

src/main.rs

use std::thread;
use std::sync::mpsc;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    let tx1 = mpsc::Sender::clone(&tx);
    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx1.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    thread::spawn(move || {
        let vals = vec![
            String::from("more"),
            String::from("messages"),
            String::from("for"),
            String::from("you"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {}", received);
    }
}
peter@hp-laptop:~/rust-practice/chapter16/many_to_one$ cargo run
   Compiling many_to_one v0.1.0 (/home/peter/rust-practice/chapter16/many_to_one)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/many_to_one`
Got: hi
Got: more
Got: from
Got: messages
Got: the
Got: for
Got: you
Got: thread
peter@hp-laptop:~/rust-practice/chapter16/many_to_one$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/many_to_one`
Got: hi
Got: more
Got: from
Got: messages
Got: the
Got: for
Got: you
Got: thread
peter@hp-laptop:~/rust-practice/chapter16/many_to_one$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/many_to_one`
Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you
peter@hp-laptop:~/rust-practice/chapter16/many_to_one$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/many_to_one`
Got: hi
Got: more
Got: messages
Got: from
Got: for
Got: the
Got: you
Got: thread
peter@hp-laptop:~/rust-practice/chapter16/many_to_one$ 

두 자식 쓰레드로부터 전달된 자료가 잘 출력되는 것을 확인할 수 있다.
그들간의 실행 순서는 역시 알 수 없다.

이 포스트의 내용은 공식문서의 16장 1절 Using Threads to Run Code Simultaneously & 16장 2절 Using Message Passing to Transfer Data Between Threads에 해당합니다.

profile
Peter J Online Space - since July 2020

0개의 댓글