Rust 병렬 처리 마스터: Rayon과 함께 안전하고 빠른 동시성 코드를 작성하고 레이스 컨디션 제로

발행: (2025년 12월 25일 오전 07:31 GMT+9)
13 min read
원문: Dev.to

Source: Dev.to

📚 About the Author

베스트셀러 작가로서, 제 책들을 Amazon에서 살펴보시길 초대합니다.
Medium에서 저를 팔로우하고 응원하는 것을 잊지 마세요. 감사합니다! 여러분의 응원이 큰 힘이 됩니다.

🖥️ 컴퓨터를 더 열심히 작동시키기 (안전하게)

프로그램에 여러 작업을 동시에 수행하도록 하려고 하면 금방 복잡하고 오류가 발생하기 쉽다는 것을 경험해 보셨을 겁니다. 저는 안전하고 빠른 병렬 처리는 트레이드오프가 있다고 생각했었습니다—하나를 가질 수는 있지만 두 개를 동시에 가질 수는 없다고요. Rust가 제 생각을 바꿨습니다.

왜 Rust인가?

  • Zero‑cost abstractions – 런타임 비용 없이 병렬성을 얻을 수 있습니다.
  • Strong compile‑time guarantees – 프로그램이 컴파일되면 특정 동시성 버그(데이터 레이스, use‑after‑free 등)는 발생할 수 없습니다.
  • Ownership & borrowing – 컴파일러가 데이터가 스레드 간에 어떻게 이동하는지 검사하여 프로그램이 실행되기 전에도 문제를 잡아냅니다.

Analogy: 여러 명의 셰프가 있는 주방을 생각해 보세요. 많은 언어에서는 두 셰프가 동시에 같은 칼을 잡으려다 충돌이 발생할 수 있습니다. Rust에서는 주방 규칙이 각 도구를 한 번에 하나의 셰프만 사용하도록 하거나, 명확한 프로토콜 아래 안전하게 공유하도록 보장합니다. 아무도 속도를 늦추지 않으면서 혼란을 방지합니다.

Rayon 등장

Rust 표준 std::thread API를 사용할 수도 있지만, 많은 작업이 Rayon 크레이트를 사용하면 훨씬 간단해집니다. Rayon은 병렬 작업을 자동으로 조직해 주는 도구로, 일반적으로 순차적으로 수행하는 연산(예: 리스트 순회)을 최소한의 노력으로 모든 CPU 코어에 분산시킵니다.

  • Simple API switch:
    • 순차 이터레이터 → .iter()
    • 병렬 이터레이터 → .par_iter()

이 하나의 메서드 교체만으로도 순차 계산을 병렬 계산으로 바꿀 수 있는 경우가 많습니다.

🚀 빠른 시작: 제곱합

use rayon::prelude::*;

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    // Parallel iterator – note the `par_iter` call
    let sum_of_squares: i32 = numbers
        .par_iter()
        .map(|&n| n * n)
        .sum();

    println!("The sum of squares is: {}", sum_of_squares);
}

Rayon의 워크‑스틸링 스케줄러numbers를 청크로 나누고, 각 청크를 다른 코어에서 처리하며, 로드를 자동으로 균형 맞춥니다. 이것은 고전적인 fork‑join 모델로, 작업이 병렬 태스크로 포크된 뒤 다시 합쳐집니다. Rust의 소유권 모델은 각 태스크가 데이터의 슬라이스에 대해 독점적이고 일시적인 접근을 보장하므로 데이터 레이스를 방지합니다.

⚠️ 병렬 코드에서 오류 처리

병렬 코드는 여전히 실패를 우아하게 처리해야 합니다. Rayon은 try_for_eachtry_reduce와 같은 메서드를 제공하며, 첫 번째 오류가 발생하면 연산을 즉시 중단합니다.

예시: 문자열을 정수로 파싱하기

use rayon::prelude::*;

fn parse_all_strings(strings: Vec) -> Result, std::num::ParseIntError> {
    strings
        .par_iter()                     // 병렬로 처리
        .map(|s| s.parse::())           // Result 반환
        .collect()                      // 첫 번째 Err에서 중단
}

fn main() {
    let good_data = vec!["1", "2", "3", "4"];
    let bad_data  = vec!["1", "two", "3", "4"];

    println!("Good data: {:?}", parse_all_strings(good_data));
    println!("Bad data: {:?}", parse_all_strings(bad_data));
}

collect는 “스마트”합니다: Result들을 수집할 때 첫 번째 Err에서 중단하고 그 오류를 전파하여, 병렬 환경에서 안전한 오류 처리를 제공합니다.

🔐 공유 상태: 단어‑카운트 예제

단순한 map‑reduce 로 해결되지 않는 문제도 있습니다. 때때로 공유 가능한 가변 상태가 필요합니다—버그의 고전적인 원천이죠. Rust는 일반적으로 Mutex와 같은 동기화 프리미티브나 동시성 자료구조를 사용하도록 안전한 패턴을 강제합니다.

Mutex를 사용한 단어 빈도수 계산

use rayon::prelude::*;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

fn count_words(lines: Vec) -> HashMap {
    // Shared, thread‑safe HashMap
    let word_counts = Arc::new(Mutex::new(HashMap::new()));

    lines.par_iter().for_each(|line| {
        for word in line.split_whitespace() {
            let key = word.to_lowercase().to_string();
            // Acquire the lock, update the map, then release
            let mut counts = word_counts.lock().unwrap();
            *counts.entry(key).or_insert(0) += 1;
        }
    });

    // Unwrap the Arc/Mutex to get the final HashMap
    Arc::try_unwrap(word_counts)
        .expect("Threads still hold Arc")
        .into_inner()
        .expect("Mutex cannot be poisoned")
}

fn main() {
    let text_chunks = vec![
        "hello world from rust",
        "concurrent rust is safe",
        "hello safe world",
    ];

    let counts = count_words(text_chunks);

    for (word, count) in counts {
        println!("{}: {}", word, count);
    }
}

핵심 포인트

  • Arc(Atomic Reference Counted)는 여러 스레드가 Mutex에 대한 소유권을 공유하도록 해줍니다.
  • Mutex는 스레드가 잠금을 획득할 때 배타적인 가변 접근을 보장합니다.
  • 모든 병렬 작업이 끝난 뒤, Arc::try_unwrap을 사용해 내부 HashMap을 추출합니다.

🎯 핵심 요점

  1. Rust + Rayon = 안전하고 고성능의 병렬 처리를 거의 보일러플레이트 없이 구현할 수 있습니다.
  2. 순차 처리에서 병렬 처리로 전환할 때는 메서드 이름 하나만 바꾸면 됩니다 (.iter().par_iter()).
  3. 오류는 Result‑인식 콤비네이터(collect, try_for_each, …)를 통해 깔끔하게 처리됩니다.
  4. 공유 가변 상태가 불가피할 경우, Arc<…>(또는 다른 동시성 프리미티브)를 사용해 데이터 레이스 없이 유지하세요.

다음 Rust 프로젝트에서 Rayon을 한 번 사용해 보세요—CPU 코어가 고마워하고, 컴파일러가 여러분을 정직하게 만들어 줍니다. 즐거운 코딩 되세요!

Note: lock().unwrap() 호출은 스레드가 락을 잡은 채 패닉하면 뮤텍스를 오염시킬 수 있습니다. 또한, 한 스레드가 “the”라는 단어를 추가하기 위해 락을 잡고 있으면, 다른 스레드가 “cat”을 추가하려 할 때도 모두 대기해야 합니다. 이는 병렬성을 제한할 수 있습니다.

동시 카운터의 경우, 더 나은 도구는 dashmap 크레이트이며, 이는 더 세밀한 락을 제공하는 동시 접근 전용 해시맵을 제공합니다.

use dashmap::DashMap;
use rayon::prelude::*;

fn count_words_faster(lines: Vec) -> DashMap {
    let word_counts = DashMap::new();

    lines.par_iter().for_each(|line| {
        for word in line.split_whitespace() {
            let key = word.to_lowercase().to_string();
            *word_counts.entry(key).or_insert(0) += 1;
        }
    });

    word_counts
}

fn main() {
    let text_chunks = vec![
        "hello world from rust",
        "concurrent rust is safe",
        "hello safe world",
    ];

    let counts = count_words_faster(text_chunks);

    for entry in counts {
        println!("{}: {}", entry.key(), entry.value());
    }
}

DashMap은 내부 락을 자동으로 처리해 주어 이러한 작업에서 훨씬 높은 처리량을 제공합니다. 이제 함수는 이미 스마트하고 공유 가능한 컨테이너인 DashMap을 직접 반환합니다.

거친 입자 작업을 위한 청킹

작업 단위가 아주 작을 경우(예: 열 개의 숫자를 제곱하는 경우), 병렬 작업을 생성하는 오버헤드가 이점을 능가할 수 있습니다. 더 큰 청크를 사용하세요:

use rayon::prelude::*;

fn process_large_image_buffer(pixels: &mut [f32], gain: f32) {
    // 픽셀을 병렬로 처리하지만, 한 번에 1024픽셀씩 청크로 나눕니다.
    pixels.par_chunks_mut(1024).for_each(|chunk| {
        for pixel in chunk {
            *pixel *= gain; // 이득(gain) 적용
        }
    });
}

적절한 청크 크기를 찾는 일은 대부분 여러분의 애플리케이션을 프로파일링해 보는 것이 좋습니다.

ndarray를 이용한 병렬 행렬 곱셈

use ndarray::Array2;
use rayon::prelude::*;

fn parallel_matrix_multiply(a: &Array2, b: &Array2) -> Array2 {
    // 차원 검증 로직이 여기 들어갑니다...
    let ((m, n), (_n2, p)) = (a.dim(), b.dim());

    // 빈 출력 행렬을 생성합니다.
    let mut c = Array2::zeros((m, p));

    // 출력 행렬의 행(row) 단위로 병렬화합니다.
    c.rows_mut()
        .into_par_iter()
        .enumerate()
        .for_each(|(i, mut row)| {
            for j in 0..p {
                let mut sum = 0.0;
                for k in 0..n {
                    sum += a[(i, k)] * b[(k, j)];
                }
                row[j] = sum;
            }
        });

    c
}

여기서는 행(row) 단위로 병렬화하는 것이 데이터‑병렬 워크로드에 전형적인 패턴입니다.

crossbeam을 이용한 스코프 스레드

use crossbeam::thread;

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

    thread::scope(|s| {
        for num in &numbers {
            // `num`을 빌려오는 스레드를 생성합니다.
            // 스코프가 끝나기 전에 모든 스레드가 조인되므로 안전합니다.
            s.spawn(move |_| {
                println!("Processing number: {}", num * 10);
            });
        }
    })
    .unwrap(); // 여기서 모든 스레드가 완료되었음이 보장됩니다.

    // 여기서도 `numbers`를 계속 사용할 수 있습니다.
    println!("Original vector: {:?}", numbers);
}

스코프 스레드를 사용하면 std::thread::spawn의 복잡한 수명 관리 없이도 스택 데이터를 안전하게 빌려올 수 있습니다.

📘 최신 전자책 확인

무료 전자책 미리보기를 YouTube에서 시청하세요.
채널을 좋아요, 공유, 댓글, 그리고 구독 해 주세요!

101 Books

101 Books는 AI 기반 출판 회사로 공동 설립되었습니다 … (내용 계속)

About the Author

Aarav Joshi – 첨단 AI 기술을 활용하여 출판 비용을 매우 낮게 유지하고 있습니다—일부 책은 $4에 판매되며—고품질 지식을 모두에게 제공하고자 합니다.

아마존에서 우리 책 **Golang Clean Code**을 확인해 보세요.
Aarav Joshi를 검색하면 더 많은 책을 찾을 수 있으며 특별 할인을 누릴 수 있습니다!

우리의 창작물

우리는 Medium에 있습니다

Back to Blog

관련 글

더 보기 »