테스트케이스: 맵-리듀스
Rust는 전통적으로 이러한 시도와 관련된 많은 골칫거리 없이 데이터 처리를 병렬화하는 것을 매우 쉽게 만들어 줍니다.
표준 라이브러리는 훌륭한 스레딩 프리미티브를 즉시 제공합니다. 이들은 Rust의 소유권 개념 및 에일리어싱 규칙과 결합하여 자동으로 데이터 경합(data races)을 방지합니다.
에일리어싱 규칙(하나의 쓰기 가능 참조 XOR 여러 개의 읽기 가능 참조)은 다른 스레드에 보이는 상태를 조작하는 것을 자동으로 방지합니다. (동기화가 필요한 곳에는 Mutex나 Channel과 같은 동기화 프리미티브가 있습니다.)
이 예제에서, 우리는 숫자 블록에 있는 모든 숫자의 합을 계산할 것입니다. 블록의 덩어리들을 서로 다른 스레드에 할당하여 이를 수행할 것입니다. 각 스레드는 자신의 작은 숫자 블록의 합을 구하고, 이어서 각 스레드에서 생성된 중간 합계들을 모두 더할 것입니다.
스레드 경계를 넘어 참조를 전달하고 있지만, Rust는 우리가 읽기 전용 참조만 전달하고 있음을 이해하며, 따라서 안전하지 않은 상황이나 데이터 경합이 발생할 수 없음을 알고 있습니다. 또한 우리가 전달하는 참조들이 'static 라이프타임을 가지고 있기 때문에, Rust는 이 스레드들이 실행되는 동안 데이터가 파괴되지 않을 것임을 이해합니다. (스레드 간에 static이 아닌 데이터를 공유해야 할 때는 Arc와 같은 스마트 포인터를 사용하여 데이터를 유지하고 static이 아닌 라이프타임 문제를 피할 수 있습니다.)
use std::thread;
// 메인 스레드입니다
fn main() {
// 처리할 데이터입니다.
// 스레드된 맵-리듀스(map-reduce) 알고리즘을 통해 모든 숫자의 합을 계산할 것입니다.
// 공백으로 구분된 각 덩어리는 서로 다른 스레드에서 처리될 것입니다.
//
// TODO: 공백을 삽입하면 출력에 어떤 변화가 생기는지 확인해 보세요!
let data = "86967897737416471853297327050364959
11861322575564723963297542624962850
70856234701860851907960690014725639
38397966707106094172783238747669219
52380795257888236525459303330302837
58495327135744041048897885734297812
69920216438980873548808413720956532
16278424637452589860345374828574668";
// 우리가 생성할 자식 스레드들을 담을 벡터를 만듭니다.
let mut children = vec![];
/*************************************************************************
* "Map" 단계
*
* 데이터를 세그먼트로 나누고, 초기 처리를 적용합니다
************************************************************************/
// 개별 계산을 위해 데이터를 세그먼트로 나눕니다.
// 각 덩어리는 실제 데이터에 대한 참조(&str)가 될 것입니다.
let chunked_data = data.split_whitespace();
// 데이터 세그먼트들을 순회합니다.
// `.enumerate()`는 순회하는 대상에 현재 루프 인덱스를 추가합니다.
// 결과 튜플 "(index, element)"는 "구조 분해 할당"
// 을 통해 즉시 두 변수 "i"와 "data_segment"로 "구조 분해"됩니다.
for (i, data_segment) in chunked_data.enumerate() {
println!("데이터 세그먼트 {}는 \"{}\"입니다", i, data_segment);
// 각 데이터 세그먼트를 별도의 스레드에서 처리합니다
//
// spawn()은 새 스레드에 대한 핸들을 반환하며,
// 반환된 값에 접근하려면 이 핸들을 반드시 보관해야 합니다
//
// 'move || -> u32'는 다음과 같은 클로저 문법입니다:
// * 인자를 취하지 않음 ('||')
// * 캡처한 변수의 소유권을 가져옴 ('move')
// * 부호 없는 32비트 정수를 반환함 ('-> u32')
//
// Rust는 클로저 자체에서 '-> u32'를 추론할 수 있을 만큼 영리하므로
// 이를 생략할 수도 있었습니다.
//
// TODO: 'move'를 제거해 보고 어떤 일이 일어나는지 확인해 보세요
children.push(thread::spawn(move || -> u32 {
// 이 세그먼트의 중간 합계를 계산합니다:
let result = data_segment
// 세그먼트의 문자들을 순회합니다..
.chars()
// .. 텍스트 문자를 숫자 값으로 변환합니다..
.map(|c| c.to_digit(10).expect("숫자여야 합니다"))
// .. 그리고 결과 숫지 이터레이터의 합을 구합니다
.sum();
// println!은 표준 출력을 잠그므로, 텍스트가 서로 섞이지 않습니다
println!("세그먼트 {} 처리됨, 결과={}", i, result);
// Rust는 "표현식 언어"이므로 "return"이 필요하지 않습니다.
// 각 블록에서 마지막으로 평가된 표현식이 자동으로 그 값이 됩니다.
result
}));
}
/*************************************************************************
* "Reduce" 단계
*
* 중간 결과들을 수집하여 최종 결과로 결합합니다
************************************************************************/
// 각 스레드의 중간 결과들을 하나의 최종 합계로 결합합니다.
//
// `sum()`에 타입 힌트를 제공하기 위해 "터보피쉬(turbofish)" `::<>`를 사용합니다.
//
// TODO: 터보피쉬 대신 `final_result`의 타입을 명시적으로 지정하여 시도해 보세요
let final_result = children.into_iter().map(|c| c.join().unwrap()).sum::<u32>();
println!("최종 합계 결과: {}", final_result);
}
과제
스레드 개수가 사용자 입력 데이터에 의존하게 하는 것은 현명하지 않습니다. 사용자가 많은 공백을 삽입하기로 한다면 어떨까요? 정말로 2,000개의 스레드를 생성하고 싶으신가요? 프로그램 시작 부분에 정의된 정적 상수에 따라 데이터가 항상 제한된 개수의 덩어리로 나뉘도록 프로그램을 수정해 보세요.
참고:
- 스레드
- 벡터와 이터레이터
- 클로저, 이동(move) 의미론 및
move클로저 - 구조 분해(destructuring) 할당
- 타입 추론을 돕기 위한 터보피쉬 표기법
- unwrap vs. expect
- enumerate