Rust 실전 강좌 #5 rayon 병렬화: 데이터 레이스 없는 map-reduce

4 분 소요

4편에서 release 빌드로 한 코어의 성능을 끝까지 썼습니다. 그런데 요즘 기계에는 코어가 8개, 16개씩 있습니다. 이번 편은 그 코어들에 집계를 나누는 이야기이고, 동시에 기초 4편에서 예고만 했던 문장, “빌림 규칙이 데이터 레이스를 컴파일 타임에 차단한다"를 실제로 목격하는 편입니다.

병렬화가 이득인 조건부터 #

무조건 나누면 빨라지는 것이 아닙니다. 판단 기준은 병목의 위치입니다.

  • IO 바운드(디스크 읽기가 병목): 코어를 늘려도 디스크는 하나입니다. 병렬화 이득이 거의 없습니다.
  • CPU 바운드(파싱·집계 연산이 병목): 코어 수만큼의 이득 여지가 있습니다.

loglens의 작업은 줄마다 문자열 분해와 숫자 변환을 하는 파싱이 무겁고, 최신 SSD의 순차 읽기는 그보다 훨씬 빠릅니다. 즉 CPU 바운드 쪽이라 병렬화의 후보입니다. 전략도 하나 바꿉니다. 3편에서는 메모리를 아끼려 스트리밍을 골랐지만, 병렬 처리는 데이터를 잘라 나눠야 하므로 파일을 통째로 읽어 놓고 조각을 스레드에 배분하는 쪽이 단순하고 빠릅니다. “기본은 스트리밍, 병렬화가 필요한 커맨드만 통째 읽기"로 커맨드별 전략을 달리 가져가는 것이 이 도구의 결정입니다.

rayon: 이터레이터를 병렬로 #

설치
cargo add rayon

rayon의 핵심은 “쓰던 이터레이터 체인의 병렬판"이라는 점입니다.

src/main.rs
use rayon::prelude::*;

fn cmd_stats_parallel(path: &Path) -> anyhow::Result<()> {
    let text = std::fs::read_to_string(path)
        .with_context(|| format!("로그 파일을 읽을 수 없습니다: {}", path.display()))?;

    let stats = text
        .par_lines()                       // 병렬 줄 이터레이터
        .fold(Stats::default, |mut acc, line| {
            acc.feed(line);                // 스레드별 부분 집계
            acc
        })
        .reduce(Stats::default, Stats::merge); // 부분 집계 병합

    print_stats(&stats);
    Ok(())
}

lines()par_lines()로 바꾸면 rayon이 줄들을 코어 수에 맞춰 조각내고, 스레드 풀에 배분하고, 결과를 모읍니다. 스레드 생성도, 조각 크기 계산도 코드에 없습니다. 구조는 map-reduce 그대로입니다. fold스레드별로 Stats를 하나씩 만들어 자기 몫의 줄을 집계하고, reduce가 부분 집계들을 하나로 병합합니다. 병합 함수만 새로 필요합니다.

src/main.rs
impl Stats {
    fn merge(mut self, other: Stats) -> Stats {
        self.total += other.total;
        self.parsed += other.parsed;
        self.failed += other.failed;
        self.bytes_sum += other.bytes_sum;
        for (status, count) in other.by_status {
            *self.by_status.entry(status).or_insert(0) += count;
        }
        self
    }
}

수백만 줄 로그라면 코어 수에 근접한 배율을 기대할 수 있습니다. 반대로 수천 줄짜리 파일에서는 스레드 배분 오버헤드가 이득을 잡아먹어 오히려 느려질 수 있습니다. 4편의 스톱워치로 자기 데이터에서 재 보는 것이 항상 결론입니다.

왜 Mutex 공유가 아니라 fold·reduce인가 #

다른 언어 경험자가 먼저 떠올리는 설계는 “HashMap 하나를 만들고 락으로 보호하며 모두가 갱신"입니다. Rust로도 가능합니다(Mutex로 감싸면 됩니다). 하지만 이 설계는 줄마다 락을 잡는 구조라서, 스레드들이 락 앞에 줄을 서는 락 경합이 병렬화 이득을 도로 반납합니다. 코어를 8개 썼는데 1코어보다 느려지는 결과도 드물지 않습니다. fold·reduce는 집계 중에 공유가 아예 없고, 병합은 조각 수만큼만 일어납니다. “공유하고 잠그기"보다 “나눠서 합치기"가 병렬 집계의 기본형이라는 것이 이 편에서 가져갈 설계 감각입니다.

컴파일러가 잡는 데이터 레이스 #

이 편의 하이라이트는 사실 실수하는 장면입니다. fold·reduce가 번거로워서 바깥의 HashMap을 클로저에서 직접 갱신하면 어떻게 될까요?

src/main.rs
let mut by_status: HashMap<u16, u64> = HashMap::new();
text.par_lines().for_each(|line| {
    if let Ok(entry) = parse_line(line) {
        *by_status.entry(entry.status).or_insert(0) += 1; // 컴파일 에러
    }
});
컴파일 에러
error[E0596]: cannot borrow `by_status` as mutable, as it is a
              captured variable in a `Fn` closure

여러 스레드가 동시에 실행할 클로저가 하나의 값을 가변으로 빌리려는 순간, 기초 4편의 빌림 규칙(“가변 참조는 하나만”)이 그대로 발동합니다. 다른 언어라면 컴파일은 통과하고, 운 나쁜 날 카운트가 슬쩍 어긋나는 재현 불가능한 버그로 나타났을 코드입니다. Rust에서 데이터 레이스는 디버깅 대상이 아니라 컴파일 에러 목록입니다. 소유권과 빌림이라는 기초 강좌의 투자가 병렬 코드에서 이자로 돌아오는 순간입니다.

정리 #

  • 병렬화 판단은 병목 위치부터입니다. 파싱·집계 같은 CPU 바운드가 후보이고, IO 바운드는 코어를 늘려도 소용없습니다.
  • 병렬 커맨드는 통째 읽기로 전략을 바꿉니다. 스트리밍(기본)과 통째 읽기(병렬)를 커맨드별로 달리 가져가는 것이 이 도구의 결정입니다.
  • rayon은 lines()par_lines()로 바꾸는 것이 시작입니다. 스레드별 fold와 병합 reduce의 map-reduce가 병렬 집계의 기본형입니다.
  • Mutex로 맵 하나를 공유하는 설계는 줄마다 락을 잡아 경합으로 이득을 반납합니다. “나눠서 합치기"가 정석입니다.
  • 공유 가변 상태 실수는 빌림 규칙이 컴파일 에러로 잡습니다. 데이터 레이스가 재현 불가능한 버그가 아니라 빌드 실패가 되는 것이 Rust 병렬화의 결정적 이점입니다.
  • 다음 편은 테스트입니다. 파서의 단위 테스트부터 assert_cmd로 도구 전체를 실행해 보는 통합 테스트까지, 지금까지 만든 것에 안전망을 두르겠습니다.
X