Rust 스레드 생성과 관리 (18)
1
여러분, 이번에는 Rust에서 스레드를 생성하고 관리하는 방법에 대해 알아보겠습니다. 멀티스레딩은 프로그램의 성능을 크게 높여줄 수 있는 강력한 기술입니다. Rust는 스레드 안전성을 보장하므로 멀티스레드 프로그래밍이 상대적으로 안전합니다. 스레드 관리 기술을 익히면 보다 효율적이고 응답성 높은 프로그램을 만들 수 있습니다.
스레드(Thread)의 기초
스레드는 프로세스 내에서 실행되는 작은 단위입니다. 하나의 프로세스는 여러 개의 스레드를 가질 수 있으며, 각 스레드는 독립적으로 실행됩니다. 스레드를 사용하면 프로그램의 병렬 처리 능력을 높일 수 있습니다.
스레드가 왜 필요할까요? 여러 가지 이유가 있지만 가장 중요한 이유는 응답성과 속도 향상입니다. 예를 들어, 웹 서버에서 한 요청을 처리하는 동안 다른 요청을 기다리게 하지 않고 병렬로 처리할 수 있습니다. 이렇게 하면 전체적인 응답 시간이 크게 단축됩니다.
그렇다면 스레드를 적절히 사용하려면 어떤 원칙을 지켜야 할까요?
- 독립성: 각 스레드는 서로 영향을 주지 않고 독립적으로 실행되어야 합니다.
- 균형: 작업량을 적절히 분배하여 스레드 간 부하를 균형있게 분산시켜야 합니다.
- 안전성: 스레드 간 데이터 경합과 같은 문제를 방지하기 위해 적절한 동기화 기술을 사용해야 합니다.
- 효율성: 스레드 생성 및 관리 오버헤드를 최소화하여 전체 성능을 극대화해야 합니다.
스레드 생성과 join
Rust에서 스레드를 생성하려면 std::thread 모듈의 thread::spawn 함수를 사용합니다. 이 함수는 새로운 스레드를 생성하고 해당 스레드에서 실행할 클로저를 인자로 받습니다.
use std::thread;
fn main() {
let handle = thread::spawn(|| {
// 새로운 스레드에서 실행할 코드
println!("새로운 스레드에서 실행 중입니다!");
});
// 메인 스레드에서 실행할 코드
println!("메인 스레드에서 실행 중입니다!");
handle.join().unwrap();
}
위 예제에서는 thread::spawn을 호출하여 새로운 스레드를 생성합니다. 이때 반환되는 JoinHandle을 통해 해당 스레드가 종료될 때까지 기다릴 수 있습니다. join 메서드를 호출하면 새로운 스레드가 종료될 때까지 블록킹됩니다.
스레드를 생성할 때는 다음과 같은 주의사항이 있습니다:
- 데이터 레이스(Data Race) 방지: 여러 스레드가 동시에 같은 데이터에 접근하여 발생하는 문제입니다. Rust의 소유권과 참조자 규칙으로 대부분의 데이터 레이스를 방지할 수 있습니다.
- 스레드 안전(Thread-Safe) 코드 작성: 코드가 여러 스레드에서 안전하게 실행될 수 있도록 작성해야 합니다. 이를 위해 동기화 기술을 사용해야 합니다.
- 리소스 누수(Resource Leak) 방지: 스레드가 종료될 때 모든 리소스를 제대로 해제해야 합니다. JoinHandle을 사용하면 스레드가 종료될 때까지 기다려 리소스 누수를 방지할 수 있습니다.
메시지 전달과 채널(Channel)
여러 스레드 간에 데이터를 주고받기 위해서는 채널(Channel) 을 사용합니다. 채널은 스레드 간 메시지 전달 방식으로, 데이터를 안전하게 전송할 수 있습니다.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
let handle = thread::spawn(move || {
tx.send(42).unwrap();
});
let received = rx.recv().unwrap();
println!("새로운 스레드로부터 받은 값: {}", received);
handle.join().unwrap();
}
위 예제에서는 mpsc::channel()을 사용하여 채널을 생성합니다. 이때 (tx, rx) 튜플이 반환되는데, tx는 송신 끝(transmitting end), rx는 수신 끝(receiving end)입니다. 새로운 스레드에서 tx.send(42)로 42라는 값을 보내고, 메인 스레드에서 rx.recv()로 해당 값을 받습니다.
채널을 사용할 때는 다음과 같은 주의사항이 있습니다:
- 데이터 전송 방향: 송신 끝에서는 데이터를 보내기만 하고, 수신 끝에서는 데이터를 받기만 합니다. 즉, 단방향 통신만 가능합니다.
- 소유권 이전: 데이터를 전송할 때 소유권이 송신 끝에서 수신 끝으로 이전됩니다. 따라서 수신 끝에서 데이터를 소유하게 됩니다.
- 버퍼링: 채널은 내부적으로 버퍼링을 지원하므로, 송신 끝에서 보낸 데이터가 즉시 수신되지 않아도 안전합니다.
- 다중 생산자/소비자: 단일 생산자-단일 소비자 채널과 다중 생산자-단일 소비자 채널, 그리고 다중 생산자-다중 소비자 채널이 있습니다.
채널은 스레드 간 데이터 전송을 안전하게 해주는 매우 유용한 도구입니다. 적절히 활용하면 복잡한 스레드 프로그래밍에서 데이터 레이스와 같은 문제를 방지할 수 있습니다.
뮤텍스(Mutex)와 세마포어(Semaphore)
스레드 간 공유 데이터에 접근할 때는 뮤텍스(Mutex) 와 세마포어(Semaphore) 를 사용하여 동기화를 해야 합니다. 이를 통해 데이터 경합 문제를 막을 수 있습니다.
뮤텍스는 상호 배제(Mutual Exclusion) 를 구현하는 동기화 기술입니다. 한 번에 하나의 스레드만 뮤텍스를 획득할 수 있으므로, 해당 스레드만 공유 데이터에 접근할 수 있습니다.
use std::sync::{Arc, Mutex};
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);
let handle = thread::spawn(move || {
let mut value = counter.lock().unwrap();
*value += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("최종 카운터 값: {}", *counter.lock().unwrap());
}
위 예제에서는 Mutex를 사용하여 여러 스레드가 동시에 접근하는 counter 변수를 보호합니다. lock 메서드를 호출하여 뮤텍스를 획득한 후, 해당 스레드만 counter에 접근할 수 있습니다. Arc를 사용하여 Mutex를 스레드 안전한 방식으로 공유할 수 있습니다.
세마포어는 자원 제한을 구현하는 동기화 기술입니다. 특정 자원에 대한 접근 횟수를 제한할 수 있습니다. 세마포어는 주로 풀링(pooling) 기법에 사용됩니다.
use std::sync::{Arc, Semaphore, SemaphorePermit};
use std::thread;
fn main() {
let semaphore = Arc::new(Semaphore::new(2));
let mut handles = vec![];
for _ in 0..5 {
let semaphore = Arc::clone(&semaphore);
let handle = thread::spawn(move || {
let _permit = semaphore.acquire().unwrap();
// 제한된 리소스에 대한 작업 수행
println!("스레드가 리소스에 접근했습니다.");
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
위 예제에서는 Semaphore를 사용하여 최대 2개의 스레드만 동시에 리소스에 접근할 수 있도록 제한합니다. acquire 메서드를 호출하여 세마포어 허가를 얻고, 작업을 수행한 후 세마포어 허가를 반환합니다.
뮤텍스와 세마포어는 스레드 안전성을 보장하는 데 매우 중요한 역할을 합니다. 상황에 맞게 적절히 사용하면 데이터 경합 등의 문제를 예방할 수 있습니다.
스레드 풀(Thread Pool)과 작업 스케줄링
스레드 풀은 미리 생성된 스레드 집합으로, 작업이 들어오면 이 스레드들에 분배하여 실행합니다. 스레드 풀을 사용하면 스레드 생성 및 제거 오버헤드를 줄일 수 있어 성능이 향상됩니다.
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
struct ThreadPool {
workers: Vec<Worker>,
sender: mpsc::Sender<Job>,
}
type Job = Box<dyn FnOnce() + Send + 'static>;
impl ThreadPool {
fn new(size: usize) -> ThreadPool {
let (sender, receiver) = mpsc::channel();
let receiver = Arc::new(Mutex::new(receiver));
let mut workers = Vec::with_capacity(size);
for id in 0..size {
workers.push(Worker::new(id, Arc::clone(&receiver)));
}
ThreadPool { workers, sender }
}
fn execute<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
let job = Box::new(f);
self.sender.send(job).unwrap();
}
}
struct Worker {
id: usize,
thread: thread::JoinHandle<()>,
}
impl Worker {
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
let thread = thread::spawn(move || loop {
let job = receiver.lock().unwrap().recv().unwrap();
job();
});
Worker { id, thread }
}
}
위 예제에서는 ThreadPool 구조체와 Worker 구조체를 정의하여 스레드 풀을 구현합니다. ThreadPool::new를 호출하면 지정된 수만큼의 Worker 스레드를 미리 생성합니다. execute 메서드를 사용하여 작업을 전달하면, 이 작업이 사용 가능한 Worker 스레드에 분배되어 실행됩니다.
스레드 풀은 다음과 같은 장점이 있습니다:
- 스레드 생성 오버헤드 감소: 미리 생성된 스레드를 재사용하므로 스레드 생성 오버헤드가 줄어듭니다.
- 리소스 제한: 최대 스레드 수를 제한하여 시스템 리소스를 보호할 수 있습니다.
- 작업 분배: 작업을 여러 스레드에 분배하여 병렬 처리를 수행할 수 있습니다.
- 작업 대기열: 대기 중인 작업을 채널에 넣어 관리할 수 있습니다.
스레드 풀(Thread Pool)과 작업 스케줄링
스레드 풀은 웹 서버, 데이터베이스 시스템, 병렬 컴퓨팅 등 다양한 분야에서 활용됩니다. 특히 I/O 바운드(I/O bound) 작업이나 CPU 바운드(CPU bound) 작업을 효율적으로 처리할 수 있습니다.
I/O 바운드 작업이란 네트워크 요청, 디스크 I/O 등 외부 리소스에 의해 지연되는 작업을 말합니다. 이런 작업에서는 블로킹(blocking)이 발생하므로 스레드 풀을 사용하면 응답성을 크게 높일 수 있습니다.
CPU 바운드 작업이란 계산 집약적인 작업으로, CPU 리소스를 많이 소모합니다. 이런 작업에서는 스레드 풀을 통해 작업을 여러 CPU 코어에 분배하여 병렬 처리할 수 있습니다.
스레드 풀 구현 시 고려해야 할 부분은 다음과 같습니다:
- 스레드 수 결정: 너무 적으면 작업이 제대로 분배되지 않고, 너무 많으면 오버헤드가 증가합니다. 일반적으로 CPU 코어 수의 몇 배 정도로 설정합니다.
- 작업 분배 전략: 작업을 스레드에 어떻게 할당할지 결정해야 합니다. 간단한 방법은 라운드 로빈(round-robin) 방식입니다.
- 작업 대기열 관리: 처리할 작업이 많으면 대기열에 넣어 관리해야 합니다. 대기열 크기를 제한하여 과부하를 방지할 수 있습니다.
- 스레드 종료 처리: 모든 작업이 완료되면 스레드를 안전하게 종료해야 합니다. 이때 작업 중인 스레드가 없도록 주의해야 합니다.
위 예제에서는 mpsc 채널을 사용하여 작업을 전달하고 대기열을 관리합니다. 실제로는 더 복잡한 전략을 사용할 수 있습니다. 예를 들어, 작업의 우선순위를 고려하거나 작업 유형에 따라 다른 스레드 풀을 사용할 수 있습니다.
Rust에서는 rayon 크레이트를 사용하면 편리하게 스레드 풀을 활용할 수 있습니다. rayon은 데이터 병렬화를 위한 높은 수준의 API를 제공합니다.
use rayon::prelude::*;
fn main() {
let nums: Vec<i32> = (0..1000).collect();
let sum: i32 = nums.par_iter().sum();
println!("Sum: {}", sum);
}
위 예제에서 par_iter()는 nums 벡터의 요소들을 병렬로 반복합니다. sum()은 이 요소들을 모두 더하는 작업을 수행합니다. rayon은 내부적으로 스레드 풀을 사용하여 이 작업을 병렬로 처리합니다.
추천 서적:
- "Rust in Action" by Tim Macnama와 Jared Bodah (Rust 스레드 풀 구현 예제 포함)
- "Programming Rust" by Jim Blandy와 Jason Orendorff (Rust 동시성 프로그래밍 심화)
스레드 풀은 Rust에서 효과적인 동시성 프로그래밍을 하는 데 매우 유용한 도구입니다. 스레드 생성 오버헤드를 줄이고, 시스템 리소스를 보호하며, 작업을 효율적으로 분배할 수 있습니다. 또한 작업 유형에 따라 여러 개의 스레드 풀을 사용하는 등 다양한 전략을 적용할 수 있습니다.
Async와 스레드 풀
비동기 프로그래밍은 동시성 프로그래밍과 밀접한 관련이 있습니다. 이 둘은 서로 다른 개념이지만, 종종 함께 사용되곤 합니다.
비동기 프로그래밍은 I/O 작업과 같이 오래 걸리는 작업을 기다리지 않고 다른 작업을 수행할 수 있게 해줍니다. 반면 스레드 풀은 CPU 바운드 작업을 병렬로 처리하는 데 유용합니다.
때로는 비동기 코드에서도 CPU 바운드 작업이 필요할 수 있습니다. 예를 들어, 웹 서버에서 요청을 처리할 때 일부 계산 작업이 필요할 수 있죠. 이런 경우 스레드 풀을 사용하면 효율성을 높일 수 있습니다.
use tokio::task;
#[tokio::main]
async fn main() {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(4)
.build()
.unwrap();
let future1 = async { cpu_bound_task() };
let future2 = async { cpu_bound_task() };
rt.spawn(future1);
rt.spawn(future2);
// 다른 비동기 작업 수행...
}
async fn cpu_bound_task() {
// CPU 바운드 작업 수행
}
위 예제에서는 tokio 런타임을 사용하여 4개의 워커 스레드를 가진 스레드 풀을 생성합니다. 그리고 cpu_bound_task라는 CPU 바운드 작업을 비동기적으로 실행합니다.
tokio 런타임은 작업을 적절한 워커 스레드에 분배하여 병렬로 처리합니다. 이렇게 하면 CPU 바운드 작업의 효율성을 높일 수 있습니다.
주의할 점:
- 스레드 풀 크기를 너무 크게 설정하면 오버헤드가 증가할 수 있습니다.
- CPU 바운드 작업과 I/O 바운드 작업을 분리하여 서로 다른 스레드 풀에서 실행하는 것이 좋습니다.
- 비동기 코드에서는 스레드 안전성을 주의해야 합니다. 공유 데이터에 접근할 때는 동기화 기술을 사용해야 합니다.
비동기 프로그래밍과 스레드 풀을 결합하면 성능과 응답성이 뛰어난 시스템을 구축할 수 있습니다. I/O 작업은 비동기적으로 처리하고, CPU 바운드 작업은 스레드 풀에서 병렬로 처리하는 방식입니다. Rust는 이러한 동시성 프로그래밍을 안전하고 생산적으로 할 수 있는 훌륭한 언어입니다.
Send와 Sync 트레이트
스레드에 안전하게 데이터를 전송하려면 Send 트레이트와 Sync 트레이트를 잘 이해해야 합니다.
Send 트레이트는 소유권이 한 스레드에서 다른 스레드로 이전 가능한 타입에 적용됩니다. 예를 들어, Box<T>, Vec<T>, String 등의 타입은 Send 트레이트를 구현하고 있습니다.
use std::thread;
fn main() {
let x = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("벡터의 크기: {}", x.len());
});
handle.join().unwrap();
}
위 예제에서 x는 Vec<i32> 타입이므로 Send 트레이트를 구현합니다. 따라서 x의 소유권을 새로운 스레드로 이전할 수 있습니다.
Sync 트레이트는 여러 스레드에서 공유 가능한 타입에 적용됩니다. 이 트레이트를 구현한 타입은 &T와 같이 참조자로 여러 스레드에서 공유될 수 있습니다.
use std::sync::Arc;
use std::thread;
fn main() {
let x = Arc::new(vec![1, 2, 3]);
let x1 = Arc::clone(&x);
let x2 = Arc::clone(&x);
let handle1 = thread::spawn(move || {
println!("첫 번째 스레드: {:?}", x1);
});
let handle2 = thread::spawn(move || {
println!("두 번째 스레드: {:?}", x2);
});
handle1.join().unwrap();
handle2.join().unwrap();
}
위 예제에서 Arc<Vec<i32>>는 Sync 트레이트를 구현하므로 여러 스레드에서 안전하게 공유될 수 있습니다. Arc::clone을 사용하여 참조 카운트를 증가시킨 후, 각 스레드에 참조자를 전달합니다.
이처럼 Send와 Sync 트레이트는 스레드 안전성을 보장하는 데 매우 중요한 역할을 합니다. Send 트레이트는 데이터를 스레드 간에 이동시킬 수 있게 해주고, Sync 트레이트는 데이터를 스레드 간에 공유할 수 있게 해줍니다.
또한, Rust 컴파일러는 이 두 트레이트를 자동으로 검사하여 스레드 안전성을 보장합니다. 예를 들어, Rc<T>는 Send 트레이트를 구현하지 않으므로 소유권을 다른 스레드로 이전할 수 없습니다. 이렇게 컴파일러가 미리 검사하므로 런타임 오류를 방지할 수 있습니다.
Send와 Sync 트레이트는 안전한 동시성 프로그래밍을 위해 반드시 이해해야 하는 개념입니다. 이를 잘 활용하면 데이터 경합 등의 문제를 방지하고 견고한 멀티스레드 프로그램을 작성할 수 있습니다.
참고 자료
- The Rust Programming Language Book의 "Concurrency" 장: https://doc.rust-lang.org/book/ch16-00-concurrency.html
- Rust by Example의 "Threads" 장: https://doc.rust-lang.org/rust-by-example/threads.html
- Rust 공식 문서의 "std::thread" 모듈: https://doc.rust-lang.org/std/thread/index.html
- "Programming Rust" 서적 (Jim Blandy와 Jason Orendorff 저)
- "Rust in Action" 서적 (Tim Macnama와 Jared Bodah 저)
한 고대 문서 이야기
여기 한 고대 문서가 있습니다. 이 문서는 B.C. 1,500년 부터 A.D 100년까지 약 1,600 여 년 동안 기록되었습니다. 이 문서의 저자는 약 40 명입니다. 이 문서의 고대 사본은 25,000 개가 넘으나, 사본간 오
gospel79.tistory.com
유튜브 프리미엄 월 1만원 할인받고 월 4000원에 이용하는 방법
올해 5월부터 월 8000원 정도이던 유튜브 프리미엄 요금이 15000원 정도로 인상됩니다. 각종 OTT 서비스, ChatGPT 같은 서비스들이 늘어나다보니 이런 거 몇 개만 이용하더라도 월 이용요금이 5만원을
stock79.tistory.com
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다."