비욘드 JS: 러스트 - 동시성 프로그래밍

dante Yoon·2023년 1월 6일
1

beyond js

목록 보기
18/20
post-thumbnail

글을 시작하며

안녕하세요, 단테입니다.

동시성은 컴퓨터 시스템이 여러 가지 일을 특정 시간동안 함께 실행시키는 것을 말하며 병렬성은 여러 개의 작업을 동일 시점에 함께 실행하는 것을 의미합니다. 러스트에서 동시성 프로그램은 멀티 스레드를 사용해 수행하며 여기서 스레드는 동시에 생성되고 실행될 수 있는 경량 실행 단위를 의미합니다.

동시성 프로그래밍을 위해 러스트가 제공하는 것은 러스트 런타임으로 스레드의 스케쥴링과 실행을 책임지고 있습니다. 러스트 런타임은 채널과 뮤텍스, 원자 변수와 같은 도구를 통해 스레드 세이프한 통신 매커니즘을 제공합니다.

스레드 안정성을 보장하기 위해 소유권 시스템과 borrowing(이제부터 차용이라고 하겠습니다.)을 사용합니다. 차용 규칙을 준수하고 동일 시점에 데이터를 변경하지 않는 한 여러 데이터를 동시에 접근할 수 있습니다.

차용과 소유권 시스템 외에도 러스트는 동시성 프로그래밍을 위한 여러가지 모범 사례를 제공합니다. 데이터가 해제된 후 액세스되지 않도록 라이프타임을 사용하는 것과 원자 변수와 뮤텍스를 사용해 공유 데이터 접근을 접근을 동기화 하는 방법이 여기 포함됩니다.

전반적으로 러스트 동시성 프로그램이은 성능과 안정성 두 가지의 균형이 필요합니다. 러스트에서 제공하는 모범 사례를 사용함으로써 견고하고 효율적인 동시성 시스템을 만들 수 있습니다.

동시성 프로그래밍의 주요 과제는 경쟁 조건과 교착상태를 피하는 것입니다. 경쟁 조건은 두개 이상의 스레드가 공유 데이터를 동시에 접근하여 변경하려고 할 때 발생하며 예상치 못한 에러의 원인이됩니다. 경쟁 조건을 에방하기 위해 러스트는 뮤텍스나 원자 변수와 같은 동기화 매커니즘
을 제공합니다. 이를 통해 오직 한 시점에는 하나의 스레드만 공유 데이터에 접근할 수 있습니다.

데드락은 두개 이상의 스레드가 서로 자원을 해제하기 까지 계속 기다리고 있을 때 발생하며 결과적으로 진행이 불가능한 상황이 벌어집니다. 교착상태를 방지하기 위해서는 어떤 스레드가 자원을 사용할지, 그리고 잠재적 데드락을 피하기 위해 시간 제한과 같은 전략을 사용할지 디자인 하는 것이 중요합니다.

경쟁 조건과 교착상태에 더해서 동시성 프로그래밍은 여러개의 스레드를 관리하고 잠재적인 성능 병목 현상 가능성으로 어려움을 겪을 수 있습니다. 이러한 문제를 해결하기 위해 적절한 수준의 동시성을 선택하고 스레드 풀과 같은 병렬 반복기와 같은 도구를 사용항여 여러 스레드의 실행을 효율적으로 관리하는 것이 중요합니다.

스레드 사용하기

많은 OS에서 프로그램의 코드는 프로세스에서 실행됩니다. 운영체제는 여러 개의 프로세스를 관리합니다. 한 프로그램의 범주 안에서 또한 여러 개의 파트를 동시에 실행시킬 수 있습니다. 여러 독립된 부분을 실행시키는 이 기능은 스레드라고 부릅니다 예를 들어 웹 서버는 여러개의 스레드를 가질 수 있어서 여러 개의 요청을 동시에 처리할 수 있습니다.

프로그램에서의 연산을 여러 스레드에서 나눠 처리하기 위해서 동시에 여러 개의 작업을 수행해 성능을 높일 수 있지만 복잡성이 올라가는 트레이드 오프가 있습니다. 스레드는 동시에 실행되기 때문에 어떤 부분의 코드가 먼저 실행될지 그 순서를 보장할 수 없습니다.

이러한 문제가 앞서 말했던 경합조건이나 교차조건을 불러 일으킵니다.

많은 운영체제에서 각 프로그래밍 언어가 스레드를 만들기 위해 제공하는 API가 있습니다.
러스트 표준 라이브러리는 스레드 구현의 1:1 모델을 사용해 하나의 언어 스레드당 하나의 운영체제 스레드를 사용합니다. 1:1 모델을 사용하지 않는 trait도 있습니다.
Rust 표준 라이브러리는 스레드 구현의 1:1 모델을 사용하므로 프로그램은 하나의 언어 스레드당 하나의 운영 체제 스레드를 사용합니다. 1:1 모델과 다른 트레이드오프를 만드는 다른 스레딩 모델을 구현하는 크레이트가 있습니다.

언어 모델 스레드

영어로는 language-level thread라고 불리는데 프로그래밍 언어, 즉 운영체제가 아닌 러스트 언어로 관리되고 스케쥴되는 스레드를 말합니다. 러스트 언어 레벨의 스레드는 std:thread 모듈을 통해 구현되고 여러 개의 스레드를 생성하거나 조작할 수 있게 해줍니다.

1:1 모델 스레드의 장단점

1:1 스레딩 모델에서 각 언어 수준 스레드는 운영체제 스래드에 매핑되어 스레드를 실행하고 세밀하게 조정할 수 있게 해줍니다. 이 덕분에 사용할 스레드 수를 지정하거나 스레드를 특정 프로세서에 할당하는 동시성 프로그래밍을 위한 고급 기능을 러스트를 통해 사용할 수 있습니다.

하지만 1:1 모델에도 단점은 있습니다. 스레드는 고유의 자원과 스택을 필요로 하므로 많은 수의 운영체제 스레드를 사용하면 리소스를 많이 사용할 수 있다는 것입니다. 결과적으로 1:1 모델 스레드는 많은 수의 스레드를 사용해야 하는 프로그램에는 적합하지 않을 수 있습니다.

M:N 모델 스레드

다대다 모델 스레드는 적은 수의 운영체제 스레드를 사용해 많은 수의 언어레벨 스레드를 실행함으로 1:1 모델 스레드의 문제점을 해결할 수 있습니다. 이 모델에서 언어모델 스레드는 보다 작은 풀의 운영체제 스레드로 스케쥴되며 여러 스레드를 생성하고 관리해야 하는 오버헤드를 감소할 수 있게 해줍니다.

Rust에서 M:N 모델을 구현하는 런타임 라이브러리의 한 예는 빠르고 확장 가능한 네트워크 애플리케이션을 구축하기 위한 고성능 비동기 런타임을 제공하는 tokio 크레이트입니다. tokio는 녹색 스레드('파이버'라고도 함)와 작업 도용 스케줄러의 조합을 사용하여 더 적은 수의 운영 체제 스레드에서 동시에 많은 수의 비동기 작업을 실행합니다.

다대다 모델을 구현한 런타임 라이브러리의 예제로 고성능 동기화 런타임을 제공해 빠르고 확장가능한 네트워크 어플리케이션을 구현할 수 있게 해주는 tokio create가 있습니다. 토키오는 fibers라고 불리는 그린 스레드의 조합을 사용하고 work-stealing 스케쥴러를 사용해 많은 수의 비동기 작업을 적은 수의 운영체제 스레드에서 동시에 수행합니다.

또 다른 예시로 async-std create가 있습니다. 러스트 1.39 버전에서 소개된 async/await 문법을 사용해 비동기 어플리케이션을 만들 수 있게 해주며 토키오와 동일하게 그린 스레드와 work-stealing scheduler 를 사용해 적은 운영체제 스레드를 사용해 비동기 작업을 동시에 할 수 있게 해줍니다.

spawn을 사용해 새로운 스레드 만들기

use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        println!("Hello from a new thread!");
    });

    handle.join().unwrap();
}

이 예시코드에서 thread::spawn 함수를 사용해 인자로 클로저를 스레드의 인자로 넘기고 있습니다. 클로저는 단순히 콘솔에 메시지를 출력하는 역할을 합니다. join 메소드는 스레드가 완료되고 값을 받아올 때까지 기다립니다.

use std::thread;

fn main() {
    let handle = thread::spawn(move || {
        let data = vec![1, 2, 3];
        println!("Data: {:?}", data);
    });

    handle.join().unwrap();
}

이 예제는 첫번째 예제와 유사하나 클로저가 메인 스레드의 변수를 캡쳐하여 클로저의 환경으로 이동합니다. move 키워드는 클로저가 변수의 소유권을 가져서 새로운 스레드에서 사용할 수 있게 함을 나타냅니다.

use std::thread;

fn main() {
    let handle = thread::spawn(|a, b| {
        a + b
    }, 1, 2);

    println!("Result: {}", handle.join().unwrap());
}

이 예제에서 새로운 스레드를 만든 다음 두 인자의 합을 반환하는 것을 보여줍니다. 인자들은 thread::spawn 함수의 인자로 보내져서 join 메소드를 통해 반환 값을 받을 수 있게 합니다.

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

fn main() {
    thread::spawn(|| {
        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));
    }
}

메인 스레드의 러스트 프로그램이 완료되면 생성된 모든 스레드가 스레드의 작업이 끝나지 않았음에도 모두 실행을 종료하게 됩니다. 위 코드의 실행 결과는 매번 다를 수 있습니다.

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 함수는 짧은 시간 동안 실행을 멈추어 다른 스레드에서 코드를 실행시킬 수 있게 합니다. 운영체제마다 스케쥴링 방법이 다르기 때문에 순서와 결과를 보장할 수 없습니다.

위에서 std::thread 모듈을 통해 단순히 스레드를 만드는 방법을 알아봤습니다. 클로저를 spawn 함수에 보내 새로운 스레드를 만들 수 있고 목적에 맞게 스레드를 조작할 수 있습니다.

join 을 통해 스레드가 완료될 때까지 기다리기

앞서봤던 예제코드를 통해 join문이 스레드가 완료될때까지 기다린다는 것을 유추할 수 있었습니다.

thread::spawn에서 반환되는 값을 변수에 저장해보겠습니다. thread::spawn의 반환 타입은 JoinHandle입니다. JoinHandle은 join 메소드를 호출할 때 스레드가 완료될때까지 기다리는 소유된 값입니다.

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

fn main() {
    let handle = thread::spawn(|| {
        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();
}

join 메소드를 호출하는 것은 현재 실행하는 스레드를 블로킹하고 handle이 소유하는 스레드종료될때까지 기다리는 것을 말합니다.

move 클로저를 스레드와 함께 사용하기

앞서서 move 키워드를 사용하는 예제를 살펴봤었습니다.

우리는 클로저가 환경에서 사용하는 값의 소유권을 가져가서 해당 값의 소유권을 한 스레드에서 다른 스레드로 이전할 수 있게하도록 thread::spawn에 전달된 클로저와 함께 move 키워드를 자주 사용하게 될 것입니다.

use std::thread;

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

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

    handle.join().unwrap();
}

thread::spawn 함수에 클로저를 전달할때 인자가 없는데요, 메인 스레드 환경에 있는 어떤 데이터도 생성된 스레드의 코드에 사용하지 않기 때문입니다. 메인 스레드에서 파생된 데이터를 생성된 스레드의 코드에서 사용하기 위해서는 스폰된 스레드의 클로저가 필요한 값을 캡쳐해야 합니다.

$ 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` due to previous error

짧게 에러가 난 부분을 살펴보면 클로저가 v를 사용하는데 새 스레드가 이 클로저를 실행하기 때문에 v에 접근하기 위해서는 이 오너쉽을 가져와야 합니다.
러스트는 이 스레드가 얼마나 오랫동안 유지될지 모르기 때문에 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 키워드를 클로저에 선언함으로 인해 클로저가 해당 값에 대한 오너쉽을 취득할 수 있게 해줍니다.

Channel - Message Passing

서두에 채널이라는 키워드를 사용했었는데요, 채널을 사용해 메세지 전달 기법에 대해 알아보겠습니다.

Go 언어 문서에는 다음과 같은 슬로건이 있습니다.

메모리를 공유함으로 대화를 주고받지 말고 대화를 주고받음으로써 메모리를 공유하세요.

채널에는 transmitter(송신자)와 receiver(수신자)가 있습니다. 송신자는 고무 오리를 강에 넣으면 내려가는 상류와 같은 위치에 있고 수신자는 고무 오리가 다다르는 하류의 위치에 있습니다. 송신자에서 데이터와 함꼐 호출하는 메소드들은 수신자에서 데이터를 받으며 완료됩니다. 송신자나 수신자둘 중에 하나가 떨어져 버리면 (drop) 채널이 닫혔다고 합니다.

두 개의 스레드를 만들어 하나는 송신자, 하나는 수신자 역할을 시키겠습니다.
아래 코드만 가지고는 러스트가 아무것도 하지 못합니다. 채널을 만들었지만 채널 너머로 보낼 데이터의 타입을 모르기 때문에 컴파일 에러가 발생합니다.

use std::sync::mpsc;

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

mpsc는 mutiple producer, single consumer의 줄임말입니다. 채널은 여러 개의 송신자를 가질 수 있지만수신자는 오직 하나만 가질 수 있습니다. 모든 물길은 한 곳으로 이르는 것 과 같은 이치입니다. (ㅋ)
mpsc::channel 함수는 튜플을 반환하는데 튜플의 두 요소는 각각 송신자, 수신자입니다. tx, rx가 바로 그것입니다. 구조분해를 통해 tx,rx를 가져왔습니다.

스폰된 스레드와 메인 스레드 간 통신을 해보겠습니다.

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

move 키워드를 사용해 클로저 내부에 tx를 이관시켜(move) 새로운 스레드 내부에서 tx를 소유하게 했습니다. 이 생성된 스레드는 tx를 소유하고 있어야 채널을 통해 메시지를 보낼 수 있습니다.

tx의 send 메소드를 통해 데이터를 보낼 수 있습니다. 반환 타입은 Result<T,E> 이며 수신자가 떨어져 나갔으면 데이터를 보낼 종착지가 없기 때문에 send 메소드는 에러를 발생시킵니다. 예제 코드에서는 unwrap을 사용해 에러 상황에서 패닉을 발생하게 했습니다. 실제 어플리케이션에서는 잘 사용하지 않는 패턴입니다.

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

메인스레드 내부에 있는 rx를 통해 값을 가져오겠습니다.

rx는 두 가지 메소드 recv, try_recv가 있는데, recv는 receive의 줄임말입니다.(별다줄이다 진짜)
recv는 메인 스레드의 실행을 블로킹하고 채널로부터 값이 전달되기만을 기다립니다. 값이 보내지면 recv는 Result<T,E>를 반환합니다. tx가 닫히면 recv는 에러를 발생시키고 앞으로 전달되는 값을 받지 못합니다.

try_recv는 블로킹하지 않고 Result<T,E>를 즉시 반환합니다. Ok는 메시지를 가지고 있을 때 Err는 메시지가 이번에 오지 않았을 떄 가지고 있습니다. try_recv는 스레드가 다른 일을 하고 있어야 할 때 유용합니다. 루프를 돌려 자주 try_recv가 Ok인지 확인할 수 있습니다.

채널과 소유권 이전

소유권은 안전한 동시성 작업을 위한 메세지 전달에서 중요한 역할을 수행합니다. 러스트 프로그램에서 소유권에 대해 다루기 때문에 동시성 프로그래밍에서 에러를 예방할 수 있습니다.

채널과 소유권을 가지고 어떻게 작업하는지 한번 보겠습니다.
아래 코드에서 채널에 내려보낸 이후 val 값을 스폰된 스레드에서 다시 사용해볼 것입니다.

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

tx.send를 통해 메인 스레드로 데이터가 옮겨갔기 떄문에 스폰된 스레드에서 해당 값을 사용하려고 하기 전에 해당 값이 drop되었는지 어떤 변경이 일어났는지 알 수 없습니다
러스트는 컴파일러를 발생시킵니다.

$ cargo run
   Compiling message-passing v0.1.0 (file:///projects/message-passing)
error[E0382]: borrow of moved value: `val`
  --> src/main.rs:10:31
   |
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` (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` due to previous error

send 함수가 파라메터에 대한 소유권을 가져갔으므로 값이 이관되면 수신자는 해당 값에 대한 소유권을 가지게 됩니다.
잠재적 런타임 에러발생을 컴파일 에러를 통해 막을 수 있습니다.

sending multiple values

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

이번에 스폰된 스레드는 메인스레드로 보낼 문자열의 벡터를 가지고 있습니다. 벡터 리스트를 반복하면서 각 값을 개별적으로 보내려고 합니다.

메인 스레드에서 recv함수를 명시적으로 호출하지 않고 rx를 반복자로 활용했습니다.
수신된 각 값마다 콘솔에 출력을 합니다

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

creating multipe producers by clonging the transmitter

mpsc가 multiple producer, single consumer의 약자라고 했지요?
mpsc를 이용해 코드를 확장해 여러 개의 스레드를 생성하고 동일한 수신자에게 메세지를 보내겠습니다.

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

첫번째 스레드를 생성하기 이전에 송신자를 clone 합니다. 스폰된 스레드에 새로운 송신자를 보낼 수 있습니다. 두 개의 스레드를 만들어서 각기 다른 메세지를 수신자에게 보낼 수 있습니다.

또 다른 방법, Shared-State

메모리를 공유하면 어떤 모양이 될까요?

채널을 이용한 방법이 값에 대해 단일 소유권을 가지는 것이라면 메모리를 공유하는 것은 동시에 하나의 메모리 주소에 대해 여러 소유권을 갖게 되는 것을 의미합니다.

스마트 포인터에 대해 공부할 때 여러개의 오너쉽을 가질 수 있음을 설명했었는데요, 러스트이 타입 시스템과 소유권 규칙은 이 메모리 공유 관리를 하는데 도움을 줍니다. 뮤텍스에 대해 알아보겠습니다.

Mutex

상호배타적이라는 뜻의 mutual exclusion의 줄임말입니다.
mutex는 특정 시점에 오직 하나의 스레드만 데이터에 접근하는 것을 허용합니다. 뮤텍스 안에서 데이터를 접근하면 접근 요청을 먼저 요구한 시그널이 뮤텍스락을 요청하게됩니다. 이 락은 뮤텍스가 데이터에 대한 상호배타적 접근을 가능하게 하는 자료구조입니다. 따라서 뮤텍스는 잠금 시스템을 통해 데이터를 보호하는 역할을 합니다.

use std::thread;

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    for _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

Mutex<T>를 뮤텍스라고 하겠습니다. 뮤텍스를 이용해 여러 개의 스레드와 값을 공유할 것입니다. 10개의 스레드를 만들고 각 스레드에서 카운터를 1씩 늘리는 코드를 작성했습니다. 0부터 10까지 카운터가 올라갑니다.

큐텍스 내부에 i32 타입 값을 넣고 counter 변수에 패턴 바인딩 했습니다. 10스레드를 루프를 돌면서 생성하고 모든 스레드에 동일한 클로저를 제공합니다. 뮤텍스에서 제공하는 lock 메서드를 호출해 락을 획득하고 뮤텍스 값에 1을 더했습니다. 스레드가 클로저 실행을 완료하면 num 변수는 스코프 밖으로 벗어나고 락을 해제하며 다른 스레드에서 해당 락을 취득할 수 있게 됩니다.

메인 스레드 코드를 보겠습니다.
메인 스레드에서 join 핸들링을 사용했습니다. 메인스레드가 종료되기 전에 모든 스폰된 스레들르 종료할 수 있습니다. 이 시점에 메인 스레드는 락을 취득하고 결과를 출력합니다.

컴파일 에러가 발생하는 것을 살펴보겠습니다.

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: use of moved value: `counter`
  --> src/main.rs:9:36
   |
5  |     let counter = Mutex::new(0);
   |         ------- move occurs because `counter` has type `Mutex<i32>`, which does not implement the `Copy` trait
...
9  |         let handle = thread::spawn(move || {
   |                                    ^^^^^^^ value moved into closure here, in previous iteration of loop
10 |             let mut num = counter.lock().unwrap();
   |                           ------- use occurs due to use in closure

For more information about this error, try `rustc --explain E0382`.
error: could not compile `shared-state` due to previous error

에러 메시지는 counter 값이 이전 루프에서 이관되었을 알립니다.
러스트는 락된 카운터를 다른 스레드로 소유권 이전하지 못함을 알립니다. 다른 스레드에서 락 을 취득하려는 행동 자체가 소유권 문제 때문에 안되는 것입니다.

우리가 배운 스마트 포인터를 이용하자.

이전 강의에서 우리는 스마트 포인터 Rc<T> 타입을 이용해 참조 카운터를 생성했습니다.
아래 코드에서 Mutex<T>Rc<T> 내부에 래핑하고 소유권을 이전하기 전에 Rc 타입을 clone할 것입니다.

use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Rc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

다음 컴파일 에러 발생을 보면 Rc<Mutex<i32>>가 스레드간 안전하게 전송될 수 없다고 합니다. 컴파일러는 trait SendRc<Mutex<i32>>를 구현하지 않았기 때문이라고 합니다. Rc<T>는 스레드간 공유해서 사용하기 안전하지 않은 타입인ㅂ니다. 레퍼런스 카운트를 관리할 떄 clone을 호출할 때마다 카운터를 올리고 clone이 드롭될 때마다 카운터를 내리는데 동시성 프리미티브를 사용하지 않아 다른 스레드로부터 작업을 방해받는것을 막지 못합니다. 이는 잠재적인 버그와 메모리릭을 발생할 수 있습니다.

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
   --> src/main.rs:11:22
    |
11  |           let handle = thread::spawn(move || {
    |  ______________________^^^^^^^^^^^^^_-
    | |                      |
    | |                      `Rc<Mutex<i32>>` cannot be sent between threads safely
12  | |             let mut num = counter.lock().unwrap();
13  | |
14  | |             *num += 1;
15  | |         });
    | |_________- within this `[closure@src/main.rs:11:36: 15:10]`
    |
    = help: within `[closure@src/main.rs:11:36: 15:10]`, the trait `Send` is not implemented for `Rc<Mutex<i32>>`
    = note: required because it appears within the type `[closure@src/main.rs:11:36: 15:10]`
note: required by a bound in `spawn`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `shared-state` due to previous error

Arc<T> - 원자 레퍼런스 카운팅

앞서서 원자 변수라는 말을 사용했었는데

Arc<T>Rc<T> 타입과 유사하면서 동시성 상황에서 사용하기 안전합니다.
동시성 프리미티브의 종류중 하나이며 스레드 간 사용해도 안전한 타입입니다.

다른 타입이 아토믹하게 구현되지 않은 이유는 해당 타입 사용이 성능 이슈를 가져올 수 있기 때문입니다. 단일 스레드에서 값을 다뤄야 할 때는 원자성을 강제하지 않음으로 더 빠르게 코드를 실행할 수 있습니다.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for i in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            println!("{} th thread",i);
            *num += 1;
        });
        println!("i traverse {} ",i);
        handles.push(handle);
    }

    for handle in handles {
        println!("in handle");
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

우리는 이제 스레드간 소유권을 공유하는데 있어서 안전한 타입인 Arc를 사용할 수 있게 되었습니다.

코드의 실행 순서가 헷갈릴 수 있어 코드 중간에 매크로를 넣어 콘솔에 출력해봤습니다.

첫번째 실행

i traverse 0 
0 th thread
i traverse 1 
i traverse 2 
i traverse 3 
i traverse 4 
i traverse 5 
i traverse 6 
i traverse 7 
i traverse 8 
i traverse 9 
in handle
in handle
1 th thread
2 th thread
3 th thread
4 th thread
5 th thread
6 th thread
7 th thread
8 th thread
9 th thread
in handle
in handle
in handle
in handle
in handle
in handle
in handle
in handle
Result: 10

두번째 실행

i traverse 0 
0 th thread
i traverse 1 
1 th thread
i traverse 2 
2 th thread
i traverse 3 
3 th thread
i traverse 4 
i traverse 5 
4 th thread
i traverse 6 
i traverse 7 
i traverse 8 
7 th thread
i traverse 9 
in handle
in handle
in handle
in handle
in handle
in handle
5 th thread
8 th thread
9 th thread
6 th thread
in handle
in handle
in handle
in handle
Result: 10

오너쉽 이전을 허용하는 Send

앞서 예제 코드에서 Rc<Mutex<i32>>가 Send trait 구현하지 않았기 때문에
Rc<Mutex<i32>>가 스레드간 안전하게 전송될 수 없다고 했는데요

std::marker에서 제공하는 trait인 Send에 대해 알아보겠습니다.

Send 마커는 값의 소유권이 스레드간 이동할 수 있음을 말합니다. 러스트에서 사용하는 대부분의 타입이 Send이며 Rc는 이 Send 마커를 구현하지 않았기 때문에 소유권을 이전할 수 없습니다.

Rc 는 싱글 스레드 상황에서 사용할 수 있게 설계되었습니다.

글을 마치며

오늘은 러스트의 동시성 프로그래밍에 대해 알아보았습니다.
긴 포스팅 읽느라 고생하셨습니다. 이제 얼마 안남았습니다. 남은 시간도 힘내서 함께 공부해겠습니다.

profile
성장을 향한 작은 몸부림의 흔적들

0개의 댓글