메시지 전달로 스레드 간 데이터 전송하기

메시지 전달로 스레드 간 데이터 전송하기

안전한 동시성을 보장하는 점점 더 인기 있는 접근 방식은 메시지 전달(message passing)이에요. 스레드나 액터가 데이터를 담은 메시지를 서로 주고받으며 통신하는 방식이죠. Go 언어 문서의 슬로건에 아이디어가 이렇게 나와 있어요. "메모리를 공유해 통신하지 말고, 통신하며 메모리를 공유하라." Rust의 표준 라이브러리는 메시지를 보내는 동시성을 구현하기 위해 채널(channel)의 구현을 제공해요. 채널은 데이터가 한 스레드에서 다른 스레드로 전송되는 방식을 다루는 일반적인 프로그래밍 개념입니다.

출처: The Rust Book

채널이란 무엇인가 (What Is a Channel?)

프로그래밍에서의 채널은 시냇물이나 강 같은 방향성 있는 물길로 상상할 수 있어요. 고무 오리 같은 것을 강에 넣으면 물을 따라 하류 끝까지 이동하죠.

채널은 송신부(transmitter)와 수신부(receiver), 두 반쪽을 가져요. 송신부는 고무 오리를 강에 넣는 상류 위치이고, 수신부는 고무 오리가 하류에 도착하는 곳이에요. 코드의 한 부분은 보내려는 데이터로 송신부의 메서드를 호출하고, 다른 부분은 도착하는 메시지가 있는지 수신부를 확인해요. 송신부나 수신부 반쪽이 드롭되면 채널이 닫혔다고 말합니다.

여기서는 값을 생성해 채널로 보내는 스레드 하나와, 값을 받아 출력하는 스레드 하나로 이루어진 프로그램을 만들어 볼게요. 채널로 스레드 간에 단순한 값을 주고받으며 그 기능을 보여줄 거예요. 이 기법에 익숙해지면, 채팅 시스템처럼 서로 통신해야 하는 어떤 스레드든, 또는 많은 스레드가 계산의 일부를 수행하고 그 조각들을 결과를 집계하는 한 스레드로 보내는 시스템 같은 데 채널을 쓸 수 있어요.

먼저 Listing 16-6에서 채널을 만들지만 아무것도 하지 않아요. Rust가 채널로 보내고 싶은 값의 타입을 알 수 없어서 아직 컴파일되지 않는다는 점에 유의하세요.

Filename: src/main.rs

use std::sync::mpsc;

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

Listing 16-6: 채널을 만들고 두 반쪽을 txrx에 할당하기

mpsc::channel 함수로 새 채널을 만들어요. mpsc는 multiple producer, single consumer(복수 생산자, 단일 소비자)의 약자예요. 짧게 말하면, Rust 표준 라이브러리가 채널을 구현하는 방식 덕분에 채널은 값을 생산하는 송신부가 여러 개일 수 있지만 그 값을 소비하는 수신부는 하나만 가질 수 있어요. 여러 개울이 하나의 큰 강으로 흘러드는 것을 상상해 보세요. 아무 개울로 보낸 것이든 결국 끝의 한 강에 도달합니다. 지금은 단일 생산자로 시작하지만, 이 예시가 동작하는 데 성공하면 여러 생산자를 추가할게요.

mpsc::channel 함수는 튜플을 반환하는데, 첫 번째 요소는 송신부(transmitter), 두 번째 요소는 수신부(receiver)예요. txrx라는 약자는 여러 분야에서 전통적으로 각각 transmitter와 receiver를 가리켜서, 각 끝을 나타내도록 변수를 그렇게 이름 지었어요. 튜플을 해체(destructure)하는 패턴이 있는 let 문을 사용하고 있는데, let 문에서의 패턴 사용과 해체는 19장에서 다룰게요. 지금은 이런 방식으로 let 문을 사용하는 것이 mpsc::channel이 반환하는 튜플의 조각들을 꺼내는 편리한 접근법이라는 것만 알면 됩니다.

Listing 16-7처럼 송신부를 스폰된 스레드로 옮기고 문자열 하나를 보내서, 스폰된 스레드가 메인 스레드와 통신하게 해 볼게요. 이는 상류에 고무 오리를 넣거나 한 스레드에서 다른 스레드로 채팅 메시지를 보내는 것과 같아요.

Filename: src/main.rs

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

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

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

Listing 16-7: tx를 스폰된 스레드로 옮기고 "hi" 보내기

다시, thread::spawn으로 새 스레드를 만들고 movetx를 클로저로 옮겨 스폰된 스레드가 tx를 소유하게 해요. 스폰된 스레드는 채널을 통해 메시지를 보내려면 송신부를 소유해야 합니다.

송신부는 보내고 싶은 값을 받는 send 메서드를 가져요. send 메서드는 Result<T, E> 타입을 반환해서, 수신부가 이미 드롭되어 값을 보낼 곳이 없다면 send 연산이 에러를 반환해요. 이 예시에서는 에러가 나면 패닉하도록 unwrap을 호출하고 있지만, 실제 애플리케이션에서는 적절하게 처리할 거예요. 적절한 에러 처리 전략을 복습하려면 9장으로 돌아가세요.

Listing 16-8에서는 메인 스레드의 수신부에서 값을 얻을게요. 이는 강 끝의 물에서 고무 오리를 꺼내거나 채팅 메시지를 받는 것과 같아요.

Filename: src/main.rs

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

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

Listing 16-8: 메인 스레드에서 값 "hi"를 받아 출력하기

수신부에는 recvtry_recv라는 두 가지 유용한 메서드가 있어요. 여기서는 receive의 약자인 recv를 사용하는데, 이 메서드는 메인 스레드의 실행을 막고(block) 채널로 값이 보내질 때까지 기다려요. 값이 보내지면 recv는 그 값을 Result<T, E>로 반환합니다. 송신부가 닫히면 recv는 더 이상 값이 오지 않음을 알리는 에러를 반환해요.

try_recv 메서드는 막지 않고 즉시 Result<T, E>를 반환해요. 메시지가 있다면 메시지를 담은 Ok 값을, 이번에는 메시지가 없다면 Err 값을 반환하죠. try_recv는 이 스레드가 메시지를 기다리는 동안 다른 할 일이 있을 때 유용해요. try_recv를 가끔씩 호출하는 루프를 써서, 메시지가 있으면 처리하고 없으면 잠시 다른 일을 하다가 다시 확인할 수 있어요.

이 예시에서는 단순함을 위해 recv를 사용했어요. 메인 스레드가 메시지를 기다리는 것 외에 할 일이 없으므로 메인 스레드를 막는 것이 적절하죠.

Listing 16-8의 코드를 실행하면 메인 스레드에서 값이 출력되는 것을 볼게요.

Got: hi

완벽하죠!

채널을 통한 소유권 전달 (Transferring Ownership Through Channels)

소유권 규칙은 안전한 동시성 코드를 쓰는 데 도움을 주기 때문에 메시지 보내기에서 중요한 역할을 해요. 동시성 프로그래밍에서의 에러를 막는 것이 Rust 프로그램 전반에서 소유권을 생각하는 것의 이점이에요. 채널과 소유권이 어떻게 함께 문제를 막는지 보여주는 실험을 해 볼게요. 값을 채널로 보낸 뒤 스폰된 스레드에서 그 val 값을 사용해 보려고 할 거예요. Listing 16-9의 코드를 컴파일해 이 코드가 왜 허용되지 않는지 확인해 보세요.

Filename: src/main.rs

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

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

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

    let received = rx.recv().unwrap();
    println!("Got: {received}");
}

Listing 16-9: 값을 채널로 보낸 뒤 val을 사용하려 시도하기

여기서는 tx.send로 값을 채널로 보낸 뒤 val을 출력하려고 해요. 이것을 허용하는 것은 좋지 않은 생각이에요. 값이 다른 스레드로 보내지면, 우리가 값을 다시 사용하려고 하기 전에 그 스레드가 값을 수정하거나 드롭할 수 있으니까요. 다른 스레드의 수정이 불일치하거나 존재하지 않는 데이터로 인한 에러나 예상치 못한 결과를 일으킬 가능성이 있어요. 하지만 Rust는 Listing 16-9의 코드를 컴파일하려 하면 에러를 줍니다.

$ cargo run
   Compiling message-passing v0.1.0 (file:///projects/message-passing)
error[E0382]: borrow of moved value: `val`
  --> src/main.rs:10:27
   |
 8 |         let val = String::from("hi");
   |             --- move occurs because `val` has type `String`, which does not implement the `Copy` trait
 9 |         tx.send(val).unwrap();
   |                 --- value moved here
10 |         println!("val is {val}");
   |                           ^^^ value borrowed here after move
   |
   = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0382`.
error: could not compile `message-passing` (bin "message-passing") due to 1 previous error

우리의 동시성 실수가 컴파일 타임 에러를 일으켰어요. send 함수는 파라미터의 소유권을 가져가고, 값이 이동되면 수신부가 그것을 소유합니다. 이는 전송한 뒤 값을 실수로 다시 사용하는 것을 막아 주고, 소유권 시스템이 모든 것이 괜찮은지 확인해 줘요.

여러 값 보내기 (Sending Multiple Values)

Listing 16-8의 코드는 컴파일되고 실행됐지만, 두 개의 별개 스레드가 채널을 통해 서로 이야기하고 있다는 것을 명확히 보여주지 않았어요.

Listing 16-10에서는 Listing 16-8의 코드가 실제로 동시에 실행되고 있음을 증명할 수정을 했어요. 스폰된 스레드는 이제 여러 메시지를 보내고 각 메시지 사이에 1초씩 멈춥니다.

Filename: src/main.rs

use std::sync::mpsc;
use std::thread;
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}");
    }
}

Listing 16-10: 여러 메시지를 보내고 각 메시지 사이에 멈추기

이번에는 스폰된 스레드가 메인 스레드로 보내고 싶은 문자열의 벡터를 가져요. 그 벡터를 순회하며 각각을 개별적으로 보내고, 1초의 Duration 값으로 thread::sleep 함수를 호출해 매번 멈춥니다.

메인 스레드에서는 recv 함수를 명시적으로 호출하지 않아요. 대신 rx를 반복자(iterator)로 취급합니다. 수신된 각 값에 대해 출력하죠. 채널이 닫히면 반복이 끝납니다.

Listing 16-10의 코드를 실행하면 각 줄 사이에 1초씩 멈추면서 다음 출력이 보여야 해요.

Got: hi
Got: from
Got: the
Got: thread

메인 스레드의 for 루프에는 멈추거나 지연시키는 코드가 없으므로, 메인 스레드가 스폰된 스레드로부터 값을 받기 위해 기다리고 있다는 것을 알 수 있어요.

여러 생산자 만들기 (Creating Multiple Producers)

앞서 mpsc가 multiple producer, single consumer의 약자라고 언급했어요. mpsc를 실제로 활용해 Listing 16-10의 코드를 확장해서, 모두 같은 수신부에 값을 보내는 여러 스레드를 만들어 볼게요. Listing 16-11처럼 송신부를 클론하면 됩니다.

Filename: src/main.rs

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

fn main() {
    // --snip--

    let (tx, rx) = mpsc::channel();

    let tx1 = tx.clone();
    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}");
    }

    // --snip--
}

Listing 16-11: 여러 생산자에서 여러 메시지 보내기

이번에는 첫 번째 스폰된 스레드를 만들기 전에 송신부에 clone을 호출해요. 그러면 첫 번째 스폰된 스레드에 넘길 수 있는 새 송신부가 생깁니다. 원래 송신부는 두 번째 스폰된 스레드에 넘겨요. 이렇게 하면 각각 다른 메시지를 단일 수신부로 보내는 두 개의 스레드가 생겨요.

코드를 실행하면 출력이 대략 이렇게 보여야 합니다.

Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you

시스템에 따라 값을 다른 순서로 보게 될 수도 있어요. 이것이 동시성을 흥미롭게 만드는 동시에 어렵게 만드는 점이에요. thread::sleep로 서로 다른 스레드에 다양한 값을 주며 실험하면, 매 실행이 더 비결정적이고 매번 다른 출력을 만들 거예요.

이제 채널이 어떻게 동작하는지 살펴봤으니, 동시성의 다른 방법을 살펴볼게요.

더 알아보기 (Learn more)

  • std::sync::mpsc 문서 — 채널과 Sender/Receiver 메서드의 전체 API를 확인해 보세요.
  • 16장의 다음 절에서 공유 상태 동시성(공유 메모리)로 데이터를 공유하는 방법을 살펴보세요.