Rust 쓰레드

mohadang·2023년 2월 26일
0

Rust

목록 보기
25/30
post-thumbnail

쓰레드

동시성 프로그래밍 (concurrent programming - 프로그램의 서로 다른 부분이 독립적으로 실행)과 병렬 프로그래밍 (parallel programming - 프로그램의 서로 다른 부분이 동시에 실행되는 것)은 더 많은 컴퓨터들이 여러 개의 프로세서로 이점을 얻음에 따라 그 중요성이 증가하고 있다.

프로그래밍 언어들은 몇가지 다른 방식으로 스레드를 구현.
많은 운영 체제들이 새로운 스레드를 만들기 위한 System API를 제공한다.
언어가 운영 체제의 API를 호출하여 스레드를 만드는 이러한 구조는 때때로 1:1이라 불리는데, 이는 하나의 운영 체제 스레드가 하나의 언어 스레드에 대응된다는 의미

// C/C++ Windows System API, 쓰레드 생성
HANDLE CreateThread(
  [in, optional]  LPSECURITY_ATTRIBUTES   lpThreadAttributes,
  [in]            SIZE_T                  dwStackSize,
  [in]            LPTHREAD_START_ROUTINE  lpStartAddress,
  [in, optional]  __drv_aliasesMem LPVOID lpParameter,
  [in]            DWORD                   dwCreationFlags,
  [out, optional] LPDWORD                 lpThreadId
);

많은 프로그래밍 언어들은 그들만의 특별한 스레드 구현을 제공.
프로그래밍 언어가 제공하는 스레드는 그린(green) 스레드라고 알려져 있으며, 이러한 그린 스레드를 사용하는 언어들은 다른 숫자의 운영 체제 스레드로 구성된 콘텍스트 내에서 그린 스레드들을 실행할 것이다.
이러한 이유로 인하여 그린 스레드 구조는 M:N이라고 불린다.(M과 N은 반드시 동일한 숫자가 아니다)
M 개의 그린 스레드가 N 개의 시스템 스레드에 대응된다.

// C++ std 쓰레드 생성
thread t1(func1);
t1.join();

각각의 구조는 고유한 장점과 트레이드 오프를 가지고 있으며, 러스트에게 있어 가장 중요한 트레이드 오프는 런타임 지원이다.

이 글의 맥락에서 런타임이라 하는 것은 언어에 의해 모든 바이너리 내에 포함되는 코드를 의미한다.
언어에 따라 크거나 작을 수 있지만, 모든 어셈블리 아닌 언어들은 어느 정도 크기의 런타임 코드를 가지게 될것이다.
이러한 이유로 인하여, 흔히 사람들이 “런타임이 없다”라고 말할 때는, 종종 “런타임이 작다”는 것을 의미한다.
런타임이 작을 수록 더 적은 기능을 갖지만 더 작아진 바이너리로 인해 얻어지는 장점을 갖는데, 이는 더 큰 콘텍스트 내에서 다른 언어(ex: C)들과 조합하기 쉬워진다.
비록 많은 언어들이 더 많은 기능을 위하여 런타임 크기를 늘리는 거래를 수락하더라도, Rust는 성능을 관리하기 위해 C를 호출하는 것에 대해 포기할 수 없다.

그린 스레드 M:N 구조는 스레드들을 관리하기 위해 더 큰 언어 런타임이 필요하다.
그런 이유로 러스트 표준 라이브러리는 오직 1:1 스레드 구현만 제공한다.
Rust가 이러한 저수준 언어이기 때문에, 어떤 스레드를 언제 실행시킬지에 대한 더 많은 제어권과 콘텍스트 교환(context switching)의 더 저렴한 비용 같은 관점을 위해 오버헤드와 맞바꾸겠다면 M:N 스레드를 구현한 크레이트도 있다.

메모리 안정성과 쓰레드 안정성

Rust에서는 메모리 안정성과 쓰레드 안정성 문제를 별개의 문제로 처리하지 않는다.
두 문제는 연관된 문제로 인식하며 이를 해결하기 위한 솔루션 역시 두 문제와 연관되어 있다.

Rust의 이런 관점으로 메모리 안정성 처럼 쓰레드 안정성도 Rust 컴파일러의 도움을 받아 컴파일 시점에 문제를 확인할 수 있다. 다른 언어에서 보기 힘든 장점이다.

Rust는 아래 코드에 대해 컴파일 에러를 발생 시킨다.

use std::thread;

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

    let handle = thread::spawn(|| {
    	// 메인 스레드에서 생성한 v 소유권에 접근한다.
        println!("Here's a vector: {:?}", v); // Error
    });

    handle.join().unwrap();
}

두 스레드가 같은 메모리에 접근하고 있어 동기화 처리가 없다면 문제가 발생할 수 있다.
문제를 해결하기 위해서는 하나의 쓰레드만 해당 메모리에 접근할 수 있도록 보장 해야한다.
move 키워드를 사용하여 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();

	/*
    컴파일 에러 발생
    이미 소유권은 새로 생성한 쓰레드에 전달하고 사라졌기에
    */
    // drop(v);
}

메세지 패싱 (message passing)

"메모리를 공유하는 것으로 통신하지 마세요; 대신, 통신해서 메모리를 공유하세요"
- Go 슬로건 -

Rust는 메시지 보내기 방식의 동시성을 달성하기 위해 채널을 사용한다.
다른 쓰레드끼리 채널을 통해 메모리를 소유권을 주고 받으면서 안정성 있게 메모리 공유를 한다.

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

//마치 TCP 소켓과 같이 소켓을 이용하여 통신하는 모습이다.
fn main() {
	/*
	mpsc : 복수 생성자, 단수 소비자 (multiple producer, single consumer) 를 나타낸다.
    러스트의 표준 라이브러리가 채널을 구현한 방법은 한 채널이 값을 생성하는 
    복수개의 송신 단말을 가질 수 있지만 값을 소비하는 단 하나의 수신 단말을 가질 수 있음을 의미
    채널 생성, 첫번째 요소는 송신 단말이고 두번째 요소는 수신 단말
    */
    let (tx, rx) = mpsc::channel();
    thread::spawn(move || {
    	/*
        send 메소드는 Result<T, E> 타입을 반환하므로, 
        만일 수신 단말이 이미 드롭되어 있고 값을 보내는 곳이 없다면, 
        송신 연산은 에러를 반환할 것입니다.
        이 예제에서는 에러가 나는 경우 패닉을 일으키기 위해 unwrap을 호출하는 중이다.
        그러나 실제 애플리케이션에서는 이를 적절히 다뤄야 한다.
        */
        let val = String::from("hi");
        tx.send(val).unwrap();// 값 전달, 소유권 전달
        //println!("val is {}", val); // 이미 소유권을 전달해서 다시 접근하면 에러
    });
    /*
	채널의 수신 단말은 두 개의 유용한 메소드를 가지고 있다. recv와 try_recv 이다.
    
    recv : 
    스레드의 실행을 블록시키고 채널로부터 값이 보내질 때까지 기다린다.
    값이 일단 전달되면, recv는 Result<T, E> 형태로 이를 반환.
    채널의 송신 단말이 닫히면, recv는 더 이상 어떤 값도 오지 않을 것이란 
    신호를 하는 에러를 반환할 것입니다.

	try_recv 
    블록하지 않는 대신 즉시 Result<T, E>를 반환.
    전달 받은 메세지가 있다면 이를 담고 있는 Ok 값을, 이 시점에서 메세지가 없다면 Err 값을 반환.
    try_recv를 사용하는 것은 메세지를 기다리는 동안 해야 하는 다른 작업이 있을 때 유용.
    try_recv을 매번마다 호출하여, 가능한 메세지가 있으면 이를 처리하고, 
    그렇지 않으면 다음번 검사때까지 잠시동안 다른 일을 하는 루프를 만들 수 있다.
	*/
    let received = rx.recv().unwrap(); // 값 받음
    println!("Got: {}", received);
}

여러 송신자를 만들어서 하나의 수신자에 전달

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("aaaa"),
            String::from("bbbb"),
            String::from("cccc"),
            String::from("dddd"),
        ];
    
        for val in vals {
            tx1.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });
    
    thread::spawn(move || {
        let vals = vec![
            String::from("AAAA"),
            String::from("BBBB"),
            String::from("CCCC"),
            String::from("DDDD"),
        ];
    
        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });
    
    for received in rx {
        println!("Got: {}", received);
    }
}

Rust 언어의 동시성

Rust의 메시지 패싱, Arc, Mutex 등은 Rust 언어에서 제공하는 동시성 기능이 아닌 표준 라이브러리에서 제공한다.
Rust 언어단에서 제공하는 동시성 기능은 std::marker 트레잇인 Sync와 Send이다.
표준 라이브러리 또는 사용자들이 직접 만들어 사용하는 동시성 기능 라이브러리들은 바로 이 Sync와 Send를 이용하여 기능 확장을 한것이다. 따라서 표준 라이브러리의 메시지 패싱, Arc, Mutex 말고 일반 사용자들이 개발한 다른 동시성 기능을 사용할 수 있다.

Send를 사용하여 스레드 사이에 소유권 이전 허용

  • Send 마커 트레잇은 Send가 구현된 타입의 소유권이 스레드 사이에서 이전될 수 있음을 나타낸다.
  • 거의 대부분의 러스트 타입이 Send이지만, 몇 개의 예외가 있는데, 그 중 Rc도 있다.
    • Rc가 Send 될 수 없는 이유는 Rc 값을 클론하여 다른 스레드로 복제본의 소유권 전송을 시도한다면, 두 스레드 모두 동시에 참조 카운트 값을 갱신할지도 모르기 때문이다.
    • Rc는 스레드-안전성 성능 저하를 지불하지 않아도 되는 단일 스레드의 경우에 사용되도록 구현되어 있다.(즉 Rc는 멀티 스레드 용이 아니다)
    • 러스트의 타입 시스템과 트레잇 바운드는 여러분들이 결코 우연히라도 스레드 사이로 Rc 값을 불안전하게 보낼 수 없도록 컴파일 에러를 발생 시켜준다.
  • 전체적으로 Send 타입으로 구성된 어떤 타입은 또한 자동적으로 Send로 마킹된다. 로우 포인터 (raw pointer)를 빼고 거의 모든 기초 타입이 Send이다.

Sync를 사용하여 여러 스레드로부터의 접근을 허용

  • Sync 마커 트레잇은 Sync가 구현된 타입이 여러 스레드로부터 안전하게 참조 가능함을 나타낸다.
  • Send와 유사하게, 기초 타입들은 Sync하고, 또한 Sync한 타입들로 전체가 구성된 타입(ex:구조체) 또한 Sync 하다.
  • 스마트 포인터 Rc는 Send가 아닌 이유와 동일한 이유로 또한 Sync하지 않다.
  • RefCell 타입과 연관된 Cell 타입의 계열들도 Sync하지 않다.
  • RefCell가 런타임에 수행하는 참조 검사 구현은 스레드-안전하지 않다.
  • 스마트 포인터 Mutex는 Sync하고 여러 스레드에서 접근을 공유하는데 사용될 수 있다.

Send와 Sync를 손수 구현하는 것은 안전하지 않다.

Send와 Sync 트레잇들로 구성된 타입들이 자동적으로 Send 될 수 있고 Sync하기 때문에, 우리가 이 트레잇들을 손수 구현치 않아도 되며 직접 구현하는 것은 지양하기를 권고한다.
마커 트레잇으로서, 이들은 심지어 구현할 어떠한 메소드도 없다. 이들은 그저 동시성과 관련된 불변성을 강제하는데 유용할 뿐이다.

profile
mohadang

0개의 댓글