Rust에서 두려움 없이 동시성 길들이기: 잠 못 드는 건 아닌지
James Reed
Infrastructure Engineer · Leapcell

동시성 프로그램은 여러 작업을 실행하는 (또는 그렇게 보이는) 프로그램으로, 둘 이상의 작업이 겹치는 시간 범위 내에서 번갈아 가며 실행됨을 의미합니다. 이러한 작업은 스레드(processing의 가장 작은 단위)에 의해 실행됩니다. 내부적으로는 이것은 실제 멀티태스킹(병렬 처리)이 아니라 인간이 인지할 수 없는 속도로 스레드 간에 빠르게 컨텍스트를 전환하는 것입니다. 많은 현대 애플리케이션이 이러한 착각에 의존합니다. 예를 들어 서버는 다른 요청을 기다리는 동안 하나의 요청을 처리할 수 있습니다. 스레드가 데이터를 공유할 때 많은 문제가 발생할 수 있으며, 가장 흔한 문제는 경쟁 조건과 교착 상태입니다.
Rust의 소유권 시스템과 타입 안전 시스템은 메모리 안전 및 동시성 문제를 해결하기 위한 강력한 도구입니다. 소유권 및 타입 검사를 통해 대부분의 오류가 런타임이 아닌 컴파일 타임에 발견됩니다. 이를 통해 개발자는 프로덕션 환경에 배포한 후가 아닌 개발 중에 코드를 수정할 수 있습니다. 코드가 컴파일되면 다른 언어에서 흔히 발생하는 추적하기 어려운 버그 없이 멀티스레드 환경에서 안전하게 실행될 것이라고 신뢰할 수 있습니다. 이것이 Rust가 두려움 없는 동시성이라고 부르는 것입니다.
멀티스레딩 모델
멀티스레드 프로그래밍의 위험
대부분의 최신 운영 체제에서 실행되는 프로그램 코드는 운영 체제가 관리하는 프로세스 내에서 실행됩니다. 프로그램 내에는 스레드라고 하는 여러 개의 독립적으로 실행되는 구성 요소가 있을 수도 있습니다.
프로그램 계산을 여러 스레드로 분할하면 프로그램이 여러 작업을 동시에 처리할 수 있으므로 성능이 향상될 수 있습니다. 그러나 복잡성도 증가합니다. 스레드는 동시에 실행되므로 서로 다른 스레드의 코드가 실행되는 순서를 보장할 수 없습니다. 이로 인해 다음과 같은 문제가 발생할 수 있습니다.
- 여러 스레드가 일관성 없는 순서로 데이터 또는 리소스에 액세스하는 경쟁 조건
- 두 스레드가 서로 리소스를 해제하기를 기다리는 교착 상태, 추가 진행 방지
- 특정 상황에서만 발생하고 일관되게 재현하거나 수정하기 어려운 버그
프로그래밍 언어는 스레드를 구현하는 다양한 방법을 가지고 있습니다. 많은 운영 체제가 새 스레드를 생성하는 API를 제공합니다. 언어가 OS API를 사용하여 스레드를 생성하는 경우 이를 1:1 모델이라고 하며, 여기서 하나의 OS 스레드는 하나의 언어 수준 스레드에 해당합니다.
Rust의 표준 라이브러리는 1:1 스레딩 모델만 제공합니다.
spawn
으로 새 스레드 만들기
use std::thread; use std::time::Duration; fn main() { let thread = thread::spawn(|| { for i in 1..10 { println!("this is thread {}", i); thread::sleep(Duration::from_millis(1)); } }); for k in 1..5 { println!("this is main {}", k); thread::sleep(Duration::from_millis(1)); } }
출력:
this is main 1
this is thread 1
this is main 2
this is thread 2
this is main 3
this is thread 3
this is main 4
this is thread 4
this is thread 5
main 스레드가 5-루프 반복을 마치고 종료된 후, 새로 생성된 스레드는 10번의 반복을 위해 설계되었지만 5번의 반복만 실행하고 종료됩니다. main 스레드가 종료되면 새 스레드도 완료 여부에 관계없이 종료됩니다.
main 스레드가 계속 진행되기 전에 새 스레드가 완료되기를 원하면 JoinHandle
을 사용할 수 있습니다.
use std::thread; use std::time::Duration; fn main() { let handler = thread::spawn(|| { for i in 1..10 { println!("this is thread {}", i); thread::sleep(Duration::from_millis(1)); } }); for k in 1..5 { println!("this is main {}", k); thread::sleep(Duration::from_millis(1)); } handler.join().unwrap(); // 새 스레드가 완료될 때까지 main 스레드를 차단합니다. }
출력:
this is main 1
this is thread 1
this is main 2
this is thread 2
this is main 3
this is thread 3
this is main 4
this is thread 4
this is thread 5
this is thread 6
this is thread 7
this is thread 8
this is thread 9
thread::spawn
의 반환 타입은 JoinHandle
입니다. JoinHandle
은 소유된 값이며, join
메서드를 호출하면 스레드가 완료될 때까지 기다립니다.
핸들에서 join
을 호출하면 핸들로 표시되는 스레드가 종료될 때까지 현재 스레드가 차단됩니다. 스레드를 차단하는 것은 추가 작업을 수행하거나 종료하지 못하도록 하는 것을 의미합니다.
스레드 및 move
클로저
move
클로저를 사용하여 main 스레드에서 클로저로 변수의 소유권을 이전할 수 있습니다.
use std::thread; fn main() { let v = vec![2, 4, 5]; // `move`는 `v`의 소유권을 클로저로 이전합니다. let thread = thread::spawn(move || { println!("v is {:?}", v); }); }
출력:
v is [2, 4, 5]
Rust는 변수 v
의 소유권을 새 스레드로 이동합니다. 이는 변수가 새 스레드 내에서 안전하게 사용되도록 보장하며, main 스레드가 더 이상 v
를 사용할 수 없음을 의미합니다(예: 삭제).
move
키워드를 생략하면 컴파일러에서 오류가 발생합니다.
$ cargo run 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
Rust의 소유권 규칙은 다시 한번 메모리 안전을 보장하는 데 도움이 되었습니다!
메시지 전달
Rust에서 메시지 전달 동시성을 위한 주요 도구 중 하나는 표준 라이브러리에서 제공하는 개념인 채널입니다. 그것을 물 채널(강 또는 시내)처럼 생각할 수 있습니다. 고무 오리나 보트와 같은 것을 넣으면 하류로 흐르고 수신기로 갑니다.
채널에는 전송기와 수신기의 두 부분이 있습니다. 전송기 또는 수신기가 삭제되면 채널은 닫힌 것으로 간주됩니다.
채널은 표준 라이브러리의 std::sync::mpsc
를 통해 구현되며, 이는 다중 생산자, 단일 소비자를 의미합니다.
참고: 독자 및 작가 수에 따라 채널은 다음과 같이 분류할 수 있습니다.
- SPSC – 단일 생산자, 단일 소비자 (원자만 사용할 수 있음)
- SPMC – 단일 생산자, 다중 소비자 (소비자 측에서 잠금이 필요함)
- MPSC – 다중 생산자, 단일 소비자 (생산자 측에서 잠금이 필요함)
- MPMC – 다중 생산자, 다중 소비자
스레드 간 메시지 전달
use std::thread; use std::sync::mpsc; fn main() { let (tx, rx) = mpsc::channel(); // 새 스레드가 소유하도록 `tx`를 클로저로 이동합니다. thread::spawn(move || { tx.send("hello").unwrap(); }); // `recv()`는 값을 받을 때까지 main 스레드를 차단합니다. let msg = rx.recv().unwrap(); println!("message is {}", msg); }
출력:
message is hello
채널의 수신 끝에는 recv
와 try_recv
라는 두 가지 유용한 메서드가 있습니다.
여기서는 _수신_의 약자인 recv
를 사용했는데, 값을 받을 때까지 main 스레드를 차단합니다. 값이 전송되면 recv
는 Result<T, E>
에서 반환합니다. 전송기가 닫히면 더 이상 값이 도착하지 않음을 나타내는 오류를 반환합니다.
try_recv
는 차단하지 않습니다. 대신 Result<T, E>
로 즉시 반환합니다. 데이터가 있으면 Ok
, 그렇지 않으면 Err
입니다.
새 스레드가 실행을 완료하지 않은 경우 try_recv
를 사용하면 런타임 오류가 발생할 수 있습니다.
use std::thread; use std::sync::mpsc; fn main() { let (tx, rx) = mpsc::channel(); thread::spawn(move || { tx.send("hello").unwrap(); }); // `try_recv`는 즉시 반환되므로 메시지를 제때 받지 못할 수 있습니다. let msg = rx.try_recv().unwrap(); println!("message is {}", msg); }
오류:
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Empty', ...
여러 값 보내기 및 수신기 대기 관찰
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)); } }); // `rx`에 대한 `for` 루프는 반복자로서 들어오는 값을 암시적으로 기다립니다. for received in rx { println!("Got: {}", received); } }
샘플 출력(라인 사이에 1초 일시 중지):
Got: hi
Got: from
Got: the
Got: thread
송신기를 복제하여 여러 생산자 만들기
use std::thread; use std::sync::mpsc; use std::time::Duration; fn main() { let (tx, rx) = mpsc::channel(); // 송신기 `tx`를 복제하여 두 번째 생산자를 만듭니다. 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)); } }); // tx와 tx1은 모두 동일한 수신기 rx로 값을 보냅니다. for received in rx { println!("Got: {}", received); } }
샘플 출력(예약으로 인해 시스템마다 다름):
Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you
공유 상태
공유 상태 또는 데이터는 여러 스레드가 동시에 동일한 메모리 위치에 액세스함을 의미합니다. Rust는 **Mutex(상호 배제 잠금)**를 사용하여 공유 메모리 동시성 기본 요소를 구현합니다.
뮤텍스는 한 번에 하나의 스레드만 데이터에 액세스할 수 있도록 합니다.
Mutex는 상호 배제의 약자로, 특정 데이터에 한 번에 하나의 스레드만 액세스할 수 있음을 의미합니다. 뮤텍스에서 데이터에 액세스하려면 스레드가 먼저 잠금을 획득해야 합니다. 잠금은 현재 누가 독점적 액세스 권한을 가지고 있는지 추적하는 데이터 구조입니다.
표준 라이브러리의 std::sync::Mutex
를 사용합니다.
use std::sync::Mutex; fn main() { let m = Mutex::new(5); { let mut num = m.lock().unwrap(); *num = 10; // 뮤텍스 내부의 값을 변경합니다. println!("num is {}", num); } println!("m is {:?}", m); }
출력:
num is 10
m is Mutex { data: 10 }
lock
메서드를 사용하여 뮤텍스의 데이터에 액세스합니다. 이 호출은 잠금을 획득할 때까지 현재 스레드를 차단합니다.
Mutex
는 스마트 포인터입니다. 특히 lock
은 기본 데이터를 가리키도록 Deref
를 구현하는 스마트 포인터인 MutexGuard
를 반환합니다. 또한 Drop
을 구현하므로 MutexGuard
가 범위를 벗어나면 잠금이 자동으로 해제됩니다.
스레드 간에 뮤텍스 공유
여러 소유자가 필요한 여러 스레드 간에 데이터를 공유할 때는 Arc
스마트 포인터를 사용하여 Mutex
를 래핑합니다. Arc
는 스레드 안전합니다. Rc
는 그렇지 않으며 멀티스레드 컨텍스트에서 안전하게 사용할 수 없습니다.
Arc
를 사용하여 Mutex
를 래핑하여 스레드 간에 공유 소유권을 허용하는 예는 다음과 같습니다.
use std::sync::{Mutex, Arc}; use std::thread; fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..10 { let counter = Arc::clone(&counter); // 스레드로 이동하기 전에 Arc를 복제합니다. 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()); }
출력:
Result: 10
요약하자면 다음과 같습니다.
Rc<T>
+RefCell<T>
는 일반적으로 단일 스레드 내부 가변성에 사용됩니다.Arc<T>
+Mutex<T>
는 멀티스레드 내부 가변성에 사용됩니다.
참고: Mutex
는 여전히 교착 상태를 유발할 수 있습니다. 이는 두 작업이 각각 두 개의 리소스를 잠글 필요가 있고 두 스레드가 각각 하나의 잠금을 보유하고 다른 잠금을 기다리는 경우(순환 대기 발생) 발생할 수 있습니다.
Send
및 Sync
에 기반한 스레드 안전
Rust에서 동시성 관련 도구는 언어 자체가 아니라 표준 라이브러리의 일부입니다. 그러나 언어에 임베딩된 두 가지 동시성 개념이 있습니다. std::marker
의 Send
및 Sync
트레이트입니다.
Send
및 Sync
의 목적
Send
및 Sync
는 Rust에서 안전한 동시성의 핵심입니다. 기술적으로 이들은 트레이트**(메서드를 정의하지 않는 트레이트)를 표시**하는 데 사용되며 동시성 동작에 대한 타입을 표시하는 데 사용됩니다.
- **
Send
를 구현하는 타입은 스레드 간에 소유권을 안전하게 이전할 수 있습니다. - **
Sync
를 구현하는 타입은 참조를 통해 스레드 간에 공유할 수 있습니다.
여기서 &T
가 Send
이면 T
가 Sync
라고 추론할 수 있습니다.
Send
및 Sync
를 구현하는 타입
Rust에서는 기본적으로 거의 모든 타입이 Send
및 Sync
를 구현합니다. 즉, 복합 타입(구조체와 같은)의 경우 모든 멤버가 Send
또는 Sync
이면 복합 타입은 자동으로 해당 트레이트를 상속합니다.
그러나 하나의 멤버라도 Send
또는 Sync
가 아니면 전체 타입이 아닙니다.
요약하자면 다음과 같습니다.
Send
를 구현하는 타입은 스레드 간에 소유권을 안전하게 이전할 수 있습니다.Sync
를 구현하는 타입은 스레드 간에 안전하게 공유할 수 있습니다 (참조별).- Rust의 대부분 타입은
Send
및Sync
입니다.
이러한 트레이트를 구현하지 않는 일반적인 타입은 다음과 같습니다.
- 원시 포인터
- Cell, RefCell
- Rc
자신의 타입에 대해 Send
및 Sync
를 수동으로 구현하는 것이 가능하지만:
unsafe
코드를 사용해야 합니다.- 스레드 안전을 수동으로 확인해야 합니다.
- 이는 거의 필요하지 않으며, 매우 주의해서 수행해야 합니다.
참고:
Cell
및RefCell
의 핵심 구현(UnsafeCell
)이Sync
가 아니므로Sync
가 아닙니다.Rc
는 내부 참조 카운터가 스레드 안전하지 않기 때문에Send
도Sync
도 아닙니다.- 원시 포인터는 안전 보장을 제공하지 않으므로 트레이트를 구현하지 않습니다.
요약
Rust는 async/await 및 멀티스레드 동시성 모델을 모두 제공합니다. 멀티스레드 모델을 효과적으로 사용하려면 다음을 포함하여 Rust 스레딩 기본 사항을 이해해야 합니다.
- 스레드 생성
- 스레드 동기화
- 스레드 안전
Rust는 다음을 지원합니다.
- 메시지 전달 동시성, 여기서
channel
은 스레드 간에 데이터를 전송하는 데 사용됩니다. - 공유 상태 동시성, 여기서
Mutex
및Arc
는 스레드 간에 데이터를 공유하고 안전하게 변경하는 데 사용됩니다.
타입 시스템과 빌려주기 검사기는 이러한 패턴에 데이터 경쟁과 매달린 참조가 없는지 확인합니다.
코드가 컴파일되면 다른 언어에서 볼 수 있는 찾기 어렵고 디버그하기 어려운 버그 없이 멀티스레드 환경에서 올바르게 실행될 것이라고 확신할 수 있습니다.
Send
및 Sync
트레이트는 스레드 간에 데이터를 안전하게 전송하거나 공유하기 위한 보장을 제공합니다.
요약하자면 다음과 같습니다.
- 스레딩 모델: 멀티스레드 프로그램은 경쟁 조건, 교착 상태 및 재현하기 어려운 버그를 처리해야 합니다.
- 메시지 전달: 채널을 사용하여 스레드 간에 데이터를 전송합니다.
- 공유 상태:
Mutex
+Arc
를 통해 여러 스레드가 동일한 데이터에 액세스하고 변경할 수 있습니다. - 스레드 안전:
Send
및Sync
트레이트는 멀티스레드 컨텍스트에서 데이터 전송 및 공유의 안전을 보장합니다.
Rust 프로젝트 호스팅에 가장 적합한 Leapcell입니다.
Leapcell은 웹 호스팅, 비동기 작업 및 Redis를 위한 차세대 서버리스 플랫폼입니다.
다국어 지원
- Node.js, Python, Go 또는 Rust로 개발하세요.
무제한 프로젝트를 무료로 배포하세요.
- 사용량에 대해서만 비용을 지불하세요. 요청도 없고 요금도 없습니다.
최고의 비용 효율성
- 유휴 요금 없이 사용한 만큼만 지불하세요.
- 예: $25는 평균 응답 시간 60ms에서 694만 건의 요청을 지원합니다.
간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI.
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합.
- 실행 가능한 통찰력을 위한 실시간 지표 및 로깅.
간편한 확장성 및 고성능
- 고도의 동시성을 쉽게 처리할 수 있도록 자동 확장됩니다.
- 운영 오버헤드가 전혀 없어 구축에만 집중할 수 있습니다.
설명서에서 자세히 알아보세요!
X에서 팔로우하세요: @LeapcellHQ