320x100
320x100

동시성 프로그래밍 (concurrent programming)

프로그램의 서로 다른 부분이 독립적으로 실행되는 것을 의미

 

 

 

 

병렬 프로그래밍 (parallel programming)

프로그램의 서로 다른 부분이 동시에 실행되는 것을 의미

 

 

 

 

겁 없는 동시성 (fearless concurrency)

러스트에서  동시성 버그는 소유권과 타입 검사로 인해 컴파일 타임 에러로 발생

부정확한 코드가 컴파일되지 않고 문제점을 설명하는 에러가 발생

결과적으로 프로덕션에 배포된 이후가 아닌 작업을 하는 동안에 코드를 고칠 수 있음

이를 겁 없는 동시성이라고 함

겂 없는 동시성은 미묘한 버그가 없으면서 새로운 버그 생성 없이 리팩터링 하기 쉬운 코드를 작성하도록 해줌

참고로 러스트에서 동시성은 동시성 및 병렬성을 의미

 

 

 

 

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

러스트의 표준 라이브러리는 스레드 구현에 대해 1:1 모델을 사용하는데, 이에 따라 프로그램은 하나의 언어 스레드당 하나으 ㅣ운영체제 스레드를 사용

1:1 모델과는 다른 절충안이 있는 있는 그 밖의 스레드 모델을 구현한 크레이트도 존재

 

 

spawn으로 새로운 스레드 생성하기

새로운 스레드를 생성하기 위해서는 thread::spawn 함수를 호출하고 여기에 새로운 스레드에서 실행하고 싶은 코드가 담긴 클로저를 넘김

 

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

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

 

운영체제의 스레드 스케줄링에 따라 스레드의 실행 타이밍이 달라질 수 있음

그리고 메인 스레드가 종료되면 하위 스레드도 종료됨

 

 

 

 

 

join 핸들을 사용하여 모든 스레드가 끝날 때까지 기다리기

생성된 스레드가 실행되지 않거나, 전부 실행되지 않는 문제는 thread::spawn의 반환 값을 변수에 저장하여 해결할 수 있음

thread::spawn의 반환 타입은 JoinHandle로 자신의 join 메서드를 호출했을 때 그 스레드가 끝날 때까지 기다리는 소유값임

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

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("number {} from the spawned thread", i);
            thread::sleep(Duration::from_millis(1));
        }
    });
    
    handle.join().unwrap(); // 생성된 스레드가 종료될 때까지 대기 후 다음 메인 스레드의 로직 실행
    
    for i in 1..5 {
        println!("number {} from the main thread", i);
        thread::sleep(Duration::from_millis(1));
    }
}

 

핸들에 대해 join을 호출하면 핸들에 대한 스레드가 종료될 때까지 현재 실행 중인 스레드를 블록함

스레드를 블록한다는 것은 그 스레드의 작업을 방지한다는 것임

 

 

 

 

스레드에 move 클로저 사용하기

move 클로저는 thread::spawn에 넘겨지는 클로저와 함께 자주 사용되는데,

그렇게하면 클로저가 환경으로부터 사용하는 값의 소유권을 갖게되어 한 스레드에서 다른 스레드로 소유권이 이동될 것이기 때문

use std::thread;

fn main() {
    let v = vec![1, 2, 3];
    
    let handle = thread::spawn(move || {
       println!("vector: {:?}", v); 
    });
    
    handle.join().unwrap();
}

 

 

 

 

 

메시지 패싱을 사용하여 스레드 간 데이터 전송하기

메시지 패싱이란 스레드들 혹은 액터들이 서로 데이터를 담은 메시지를 보내서 통신하는 것을 의미

러스트는 메시지 보내기 동시성을 달성하기 위해 채널이라는 구현체를 제공

채널은 한 스레드에서 다른 쪽으로 데이터를 보내기 위한 일반적인 프로그래밍 개념

 

채널은 송신자와 수신자로 나뉨

코드의 어떤 곳에서 보내고자 하는 데이터와 함께 송신자의 메서드를 호출하면 

다른 곳에서는 도달한 메시지에 대한 수신 종료를 검사

송신자 혹은 수신자가 버려지면 채널이 닫혔다(closed)라고 함

 

러스트의 표준 라이브러리는 한 채널이 값을 생산하는 송신 단말을 여러 개 가질 수 있지만

값을 소비하는 수신 단말은 하나만 가지도록 구현함

 

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();
    });
    
    // recv는 메인 스레드의 실행을 블록시키고 채널로부터 값을 받을때 까지 기다리고 Result<T, E>를 반환
    // 채널의 송신 단말이 닫히면 recv는 에러를 반환
    // try_recv를 사용하면 메인 스레드를 블록하지 않는 대신 즉시 Result<T, E>를 반환 (폴링 할 때 유용)
    let received = rx.recv().unwrap();
    println!("got: {}", received);
}

 

- 채널과 소유권 이동

소유권 규칙은 메시지 전송에서 안전하면서 동시적인 코드를 작성하는데 중요한 역할을 수행

send 함수가 그 매개변수의 소유권을 가져자고 이 값이 이동되면 수신자가 이에 대한 소유권을 가짐

이는 값을 보낸 이후에 우발적으로 이 값을 다시 사용하는 것을 방지

 

 

 

 

 

여러 값 보내기

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: {}", receiced);
    }
}

 

- 송신자를 복제하여 여러 생산자 만들기

mpsc는 복수 생산자, 단일 소비자의 약어로 동일한 수신자에 대해 값들을 보내는 여러 스레드를 만들 수 있음

`tx.clone()`으로 송신자를 복제한 뒤 send하면 복수 생산자가 단일 소비자에게 데이터를 보냄

 

 

 

 

 

뮤텍스를 사용하여 한 번에 하나의 스레드에서만 데이터 접근을 허용하기

뮤텍스(mutex)는 상호 배제 (mutual exclusion)의 줄임말로, 한 번에 하나의 스레드만 데이터 접근을 허용

뮤텍스 내부의 데이터에 접근하려면 스레드는 뮤텍스의 락을 얻는 요청을해서 접근을 희망하는 신호를 보내야함

락은 누가 현재 배타적으로 데이터에 접근하는지 추적하는 뮤텍스의 일부에 해당하는 데이터 구조

그러므로 뮤텍스는 잠금 시스템을 가지고 있는 데이터를 보호하는 것으로 묘사됨

 

 

 

 

싱글 스레드 콘텍스트에서 Mutex<T>의 API 탐색하기

use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);
    
    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }
    
    println!("m = {:?}", m);
}

 

뮤텍스 내 데이터에 접근하기 위해서는 lock 메서드를 통해 락을 얻음

이 호출은 현재의 스레드를 불록할 것이므로 락을 얻을 차례가 될 때까지 아무런 작업을 할 수 없음

락을 가진 다른 스레드가 패닉 상태인 경우 lock의 호출은 실패함

 

Mutex<T>는 스마트 포인터, 정확히는 lock의 호출이 MutexGuard라는 스마트 포인터를 반환하는데 unwrap 호출을 통해 처리되는 LockResult로 감싸져있음

MutexGuard 스마트 포인터는 내부 데이터를 가리키도록 Deref가 구현되어 있고, Drop 구현체가 있어서 스코프를 벗어났을 때 자동으로 락을 해제함

 

 

 

 

 

여러 스레드 사이에서 Mutex<T> 공유하기

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

fn main() {
    let counter = Arc::new(Mutext::new(0));
    let mut handles = vec![];
    
    for _ in 0..10 {
        let counter = Arc::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());
}

 

러스트는 락 counter의 소유권을 여러 스레드로 옮길 수 없음

그렇다고 Mutex<T>를 Rc<T>로 감싸서 스레드로 소유권을 넘기기전에 Rc<T>를 복제해도 Rc<T>가 스레드를 복제하기에 안전하지 않기 때문에 컴파일 에러가 발생함

Rc<T>가 참조 카운트를 관리할 때 각 clone 호출마다 카운터에 더하고 각 클론이 버려질 때 카운트에서 뺌

이는 잘못된 카운트를 야기하여 메모리 누수나 쓰기 전에 값이 버려지는 버그를 야기할 수 있음

 

Arc<T>를 이용하면 Rc<T>와 비슷하지만 스레드 안전 방식으로 참조 카운트를 바꿀 수 있음 

Arc<T>는 동시적 상황에서 사용하는 Rc<T> 같은 타입으로,

a는 아토믹(atomic)을 의미. 즉, 원자적으로 참조자를 세는 (atomically reference counted) 타입임을 의미

 

아토믹은 추가적인 종류의 동시성 기초 재료로서 기초 타입처럼 작동하지만 스레드를 교차하며 공유해도 안전함

모든 기초 타입이 아토믹 하지 않은 이유는 스레드 안전성이라는 것이 정말로 필요할 때만 감내하고 사용하고 싶을 만큼 성능 저하를 일으키기 때문

그래서 표준 라이브러리 타입에서는 기본적으로 Arc<T>를 구현에 사용하지 않음

 

참고로 단순 산술 연산을 하는 중이라면 std::sync::atomic 모듈이 제공하는 Mutex<T> 보다 단순한 타입이 있음

이 타입은 기초 타입에 대한 안전하고, 동시적이며 원자적인 접근을 제공

 

 

 

 

RefCell<T>, Rc<T>와 Mutex<T>, Arc<T> 간의 유사성

Rc<T>의 내용물을 변경할 수 있도록 하기 위해 RefCell<T>를 사용한 것과 같은 방식으로

Arc<T> 내부의 값을 변경하기 위해 Mutex<T>를 사용

 

Mutex<T>를 사용할 때 러스트가 모든 종류의 논리 에러로 부터 보호해줄 수 없음

Rc<T>를 사용하는 것은 두 Rc<T> 값들이 서로를 참조하여 메모리 누수를 야기하는 순환 참조자를 만들 위험성이 따라오듯이

Mutex<T>에는 교착 상태를 생성할 위험성이 따라옴 

 

이것은 어떤 두 연산이 두 개의 리소스에 대한 락을 얻을 필요가 있고 두개의 스레드가 락을 하나씩 얻는다면 

서로가 서로를 영원히 기다리는 형태로 발생함

 

 

 

 

확장 가능한 동시성을 위한 std::marker::{Sync, Send} 트레이트

Send 마커 트레이트

Send가 구현된 타입의 소유권이 스레드 사이에서 이동될 수 있음을 나타냄

대부분의 러스트 타입이 Send이지만 Rc<T>의 경우 Send가 될 수 없음

Rc<T> 값을 복제하여 다른 스레드로 복제본의 소유권 전송을 시도하면 두 스레드 모두 동시에 참조 카운트 값을 업데이트 할 수도 있기 때문

그러한 이유로 Rc<T>는 스레드 안전을 위한 성능 페널티를 원치 않는 싱글 스레드 상황에서 사용되도록 구현됨

 

 

Sync

Sync 마커 트레이트는 Sync가 구현된 타입이 여러 스레드로 부터 안전하게 참조 가능함을 나타냄

바꿔말하면 &T가 Send면 T는 Sync함

Rc<T>는 Send와 동일한 이유로 Sync하지 않음

RefCell<T> 타입과 연관된 Cell<T> 타입의 유형의 타입들도 Sync하지 않음

 

 

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

Send와 Sync 트레이트들도 구성된 타입들이 자동으로 Send 될 수 있고 Sync하기 때문에

이 트레이트들은 손수 구현하지 않아도 됨

이들은 심지어 마커 트레이트로서 구현할 어떠한 메서드도 없음

이들은 그저 동시성과 관련된 불변성을 강제하는데 유용할 따음

 

 

300x250
728x90

'Programming > Rust' 카테고리의 다른 글

러스트 패턴과 매칭  (0) 2025.10.11
러스트의 객체 지향 프로그래밍 기능  (0) 2025.10.11
러스트 스마트 포인터  (0) 2025.10.09
러스트 카고와 crates.io  (0) 2025.10.09
러스트 반복자와 클로저  (0) 2025.10.08