Rust의 동시성(concurrency)은 안전성과 성능을 모두 고려한 설계로, 데이터 경쟁(data race)을 컴파일 타임에 방지하고, 성능 저하 없이 병렬 처리를 가능하게 합니다. C++, Java, Python, Go 등 타 언어와 비교해 장단점을 알아보겠습니다.
1. Rust의 동시성 개념
Rust는 동시성을 다음 세 가지 방식으로 지원합니다:
- 스레드 기반 동시성 (std::thread)
- OS 스레드를 생성하여 병렬 작업 수행.
- thread:spawn을 통해 새로운 스레드를 실행.
- 메시지 기반 통신 (std::sync::mpsc)
- 채널을 통해 스레드 간 데이터 교환.
- 데이터 소유권을 안전하게 이동.
- 비동기 프로그래밍 (async/await, tokio, async-std)
- 효율적인 I/O 처리.
- 싱글 스레드에서 수천 개의 작업을 동시에 처리할 수 있음.
- Future를이용한 논블로킹 방식.
2. Rust 동시성의 핵심 특징
특징 | 설명 |
---|---|
데이터 레이스 방지 | 컴파일 타임에 mut, &mut, Send, Sync 등을 통해 공유 자원에 대한 안전성 확보 |
제로 코스트 추상화 | 고급 추상화를 사용해도 런타임 오버헤드 없음 |
fearless concurrency | 안전하게 동시성을 구현할 수 있어 “두려움 없는 동시성”이라고도 불림 |
3. 타 언어와의 비교
가. Rust vs C++
항목 | Rust | C++ |
---|---|---|
안전성 | 컴파일 타임 데이터 레이스 방지 | 런타임에서 버그 발견 가능 |
메모리 모델 | 소유권 시스템 | 수동 메모리 관리 |
쓰레드 API | 안전하고 모던한 추상화 | 복잡하고 안전하지 않은 경우 많음 |
🔹 Rust는 안전하고 버그 없는 병렬 처리를 제공
🔸 C++은 성능은 뛰어나지만 관리 책임이 개발자에게 있음 (예: 뮤텍스 실수 → 데이터 손상)
나. Rust vs Java
항목 | Rust | Java |
---|---|---|
런타임 | 없음 (네이티브 실행) | JVM 기반 |
동기화 | Mutex, RwLock, channel 등 명시적 | synchronized, volatile, ExecutorService 등 |
성능 | 시스템 수준 고성능 | GC와 JVM 오버헤드 존재 |
🔹 Rust는 GC 없는 고성능 동시성
🔸 Java는 GC로 메모리 관리가 쉽지만 지연 가능성 존재
다. Rust vs Python
항목 | Rust | Python |
---|---|---|
성능 | 매우 빠름 | 느림 (인터프리터 기반) |
GIL (Global Interpreter Lock) | 없음 | 있음 (멀티 코어 병렬 처리 불가) |
비동기 처리 | 고성능 async/await | asyncio로 가능하나 성능은 낮음 |
🔹 Rust는 진짜 병렬 처리 가능
🔸 Python은 GIL 때문에 CPU 병렬처리에 약함 (I/O 병렬만 현실적)
라. 요약
구분 | Rust의 장점 | Rust의 단점 |
---|---|---|
성능 | 네이티브 수준의 성능 | 안전성을 위한 빌드 시간 증가 |
안전성 | 데이터 레이스를 컴파일 타임에 방지 | 초기 진입 장벽 (개념이 복잡함) |
표현력 | async/await, channel, Mutex 등 현대적 추상화 | 도구/라이브러리 생태계가 다른 언어보다 적은 편 |
병렬성 | GIL 없음, 진짜 병렬 처리 가능 | 쓰레드 디버깅이 어려울 수 있음 |
4. Rust 동시성이 특히 유리한 분야
- 고성능 웹 서버 (예: Actix, Axum)
- 실시간 시스템 (예: 게임, IoT)
- 병렬 데이터 처리 (예: 이미지/영상 처리)
- 시스템 프로그래밍 (드라이버, 임베디드)
5. 1부터 100만까지 숫자의 합을 4개 스레드로 나눠 병렬 계산 비교
가. Rust 버전
use std::thread;
fn main() {
let data: Vec<u64> = (1..=1_000_000).collect();
let chunk_size = data.len() / 4;
let mut handles = vec![];
for i in 0..4 {
let chunk = data[i * chunk_size..(i + 1) * chunk_size].to_vec();
let handle = thread::spawn(move || chunk.iter().sum::<u64>());
handles.push(handle);
}
let total: u64 = handles.into_iter().map(|h| h.join().unwrap()).sum();
println!("합계: {}", total);
}
✅ 설명:
use std::thread;
- Rust의 표준 라이브러리에서 thread 모듈을 가져옵니다. 병렬 처리를 위해 사용됩니다.
let data: Vec<u64> = (1..=1_000_000).collect();
- (1..=1_000_000)는 표현식이며 RangeInclusive 타입으로, 1부터 1_000_000까지 포함하는 이터레이터입니다.
- .collect()는 이터레이터(iterator)를 모아서 컬렉션(예: Vec, HashMap, String)으로 변환하는 메서드입니다.
- 명시적으로 Vec 타입을 선언했기 때문에, collect()는 모든 숫자를 벡터로 수집하게 됩니다.
let chunk_size = data.len() / 4;
- 데이터를 4개의 스레드로 나눌 것이기 때문에, 각 스레드가 처리할 데이터의 크기를 계산합니다.
- chunk_size = 250_000
let mut handles = vec![];
- 스레드 핸들(JoinHandle)들을 저장할 벡터.
- 각 스레드는 나중에 .join()으로 결과를 수집할 수 있습니다.
for i in 0..4 {
let chunk = data[i * chunk_size..(i + 1) * chunk_size].to_vec();
let handle = thread::spawn(move || chunk.iter().sum::<u64>());
handles.push(handle);
}
- 루프를 4번 돌며 벡터를 4등분합니다.
let chunk = data[i * chunk_size..(i + 1) * chunk_size].to_vec();
- data를 4등분하여 각 스레드에 넘길 복사본을 만든 후 chunk에 할당합니다.
- i가 0~3까지 반복되므로:
- i = 0 일 때는 data[0..250000]
- i = 1 일 때는 data[250000..500000]
- i = 2 일 때는 data[500000..750000]
- i = 3 일 때는 data[750000..1000000]
- 이렇게 전체 데이터를 4개의 슬라이스(slice)로 나눕니다. 하지만 슬라이스는 참조(&)이며, 여러 스레드가 같은 데이터를 공유할 때, 데이터 경합(data race)을 막기 위해 컴파일러가 참조의 안전성을 보장해야 하므로, .to_vec()을 사용하여 슬라이스의 복사본을 만들어 소유권을 가지는 새 벡터로 만듭니다. 이제 이 벡터는 독립적 소유권을 가지므로, move를 통해 클로저에 안전하게 넘길 수 있습니다
let handle = thread::spawn(move || chunk.iter().sum::<u64>());
- thread::spawn(…) → 새로운 스레드(thread)를 만들어서 주어진 작업을 실행합니다.
- move || … → 클로저(익명 함수)에서 외부 변수인 chunk의 소유권을 이동시켜 사용합니다.
- chunk.iter().sum::() → chunk의 모든 원소를 합산하여 u64값을 반환합니다.
- 반환된 handle은 JoinHandle 타입이고, 이걸 handles 벡터에 저장해 나중에 결과를 수집합니다.
handles.push(handle);
- thread::spawn(…)의 결과인 JoinHandle을 handles 벡터에 저장합니다.
- 이 handles는 모든 스레드 작업이 끝난 뒤 결과를 수집하는 데 사용됩니다.
let total: u64 = handles.into_iter().map(|h| h.join().unwrap()).sum();
- 스레드에서 계산한 4개의 부분합을 모아서 전체 합을 계산한다.
- handles.into_iter() .into_iter()는 handles 벡터의 소유권을 consuming iterator로 가져옵니다(copy가 아닌 move). 즉, 이후 handles는 더 이상 사용할 수 없습니다.
- .map(|h| h.join().unwrap())의각 h는 JoinHandle 이고, h.join()은 이 스레드가 끝날 때까지 기다리고, .unwrap()으로 에러 무시하고 강제 추출합니다. .map(…) 부분은 4개의 스레드를 기다리며 각각의 계산된 합을 모아 [u64; 4] 형태로 만듭니다
- .sum()은 [u64; 4]을 전부 더해서 최종 합계를 구해서, total에 할당합니다.
println!("합계: {}", total);
- “합계: 500000500000″을 출력합니다.
500000500000은 1000000 * 1000001 / 2 = 500000500000입니다.
나. Python (threading 사용, CPU 병렬 처리 실패 예)
import threading
data = list(range(1, 1_000_001))
results = [0] * 4
def worker(idx, chunk):
results[idx] = sum(chunk)
threads = []
chunk_size = len(data) // 4
for i in range(4):
t = threading.Thread(target=worker, args=(i, data[i*chunk_size:(i+1)*chunk_size]))
threads.append(t)
t.start()
for t in threads:
t.join()
print("합계:", sum(results))
🔸 설명:
- Python은 GIL(Global Interpreter Lock) 때문에 진짜 병렬 아님
- threading은 CPU 병렬 처리 불가 → 오히려 느림
- multiprocessing을 쓰면 병렬 가능하지만 복잡도 증가
다. Java 버전
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newFixedThreadPool(4);
int[] data = new int[1_000_000];
for (int i = 0; i < data.length; i++) data[i] = i + 1;
Future<Long>[] results = new Future[4];
int chunkSize = data.length / 4;
for (int i = 0; i < 4; i++) {
final int start = i * chunkSize;
final int end = (i + 1) * chunkSize;
results[i] = executor.submit(() -> {
long sum = 0;
for (int j = start; j < end; j++) sum += data[j];
return sum;
});
}
long total = 0;
for (Future<Long> result : results) {
total += result.get();
}
executor.shutdown();
System.out.println("합계: " + total);
}
}
🔸 설명:
- ExecutorService를 통해 병렬 처리
- 비교적 안전하지만, Future.get()이 blocking이며 예외 처리 필요
- GC가 있어 런타임 예측 어려움
라. C++ 버전 (C++11 이상)
#include <iostream>
#include <vector>
#include <thread>
#include <numeric>
int main() {
std::vector<uint64_t> data(1'000'000);
std::iota(data.begin(), data.end(), 1);
uint64_t partial_sums[4] = {};
std::vector<std::thread> threads;
size_t chunk_size = data.size() / 4;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([i, chunk_size, &data, &partial_sums]() {
partial_sums[i] = std::accumulate(
data.begin() + i * chunk_size,
data.begin() + (i + 1) * chunk_size,
0ULL
);
});
}
for (auto& t : threads) t.join();
uint64_t total = 0;
for (auto s : partial_sums) total += s;
std::cout << "합계: " << total << std::endl;
}
🔸 설명:
- 고성능이지만, &data, &partial_sums는 데이터 경쟁 가능성 존재
- 공유 자원 동기화에 실패하면 잘못된 결과 나올 수 있음
- 동기화 도구 사용 시 성능 저하 우려
마. 언어별 동시성 비교
언어 | 병렬 처리 성능 | 안전성 | 코드 복잡도 | 주의사항 |
---|---|---|---|---|
Rust | 매우 뛰어남 | 컴파일 타임 안전 보장 | 다소 복잡 | 소유권, 라이프타임 이해 필요 |
Python | 낮음 (GIL) | 안전하나 느림 | 간단 | multiprocessing 사용 시 복잡 |
Java | 중간 | 런타임 에러 가능 | 보통 | 예외 처리, GC |
C++ | 고성능 | 데이터 레이스 가능 | 복잡 | 직접 동기화 필요 |
6. Rust vs Go 동시성 비교 요약
항목 | Rust | Go |
---|---|---|
동시성 모델 | 명시적 스레드 + 채널 + async/await | 경량 고루틴(goroutine) + 채널(channel) |
메모리 관리 | 수동 + 소유권 시스템 (GC 없음) | GC 있음 (자동 메모리 관리) |
안전성 | 컴파일 타임에 데이터 경쟁 차단 | 런타임에 데이터 레이스 가능 (race detector 필요) |
런타임 | 없음 (zero-cost abstraction) | 있음 (스케줄러 + GC) |
학습 곡선 | 가파름 (소유권/라이프타임 개념 필요) | 비교적 완만 |
성능 | 매우 뛰어남 (GC 없음) | 빠르지만 GC 오버헤드 존재 |
7. 10개의 작업을 동시 실행 비교
가. Rust:
use std::thread;
fn main() {
let mut handles = vec![];
for i in 0..10 {
let handle = thread::spawn(move || {
println!("Rust 스레드 {} 실행", i);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
✅ 특징:
- std::thread::spawn으로 OS 스레드 생성
- 스레드 수 제한 없음 (하지만 무거움)
- 안전하게 소유권 이동 (move) → 데이터 경쟁 없음
나. Go: 고루틴 10개 실행
package main
import (
"fmt"
"time"
)
func main() {
for i := 0; i < 10; i++ {
go func(i int) {
fmt.Printf("Go 고루틴 %d 실행\n", i)
}(i)
}
time.Sleep(time.Second) // 고루틴이 끝날 시간 대기
}
✅ 특징:
- go 키워드 하나로 병렬 실행
- 고루틴은 스레드보다 훨씬 가볍고 수천 개 생성 가능
- 단, 공유 자원 접근 시 데이터 레이스 가능 (예: i 변수 캡처 문제 발생 가능)
다. 동시성 핵심 차이
항목 | Rust | Go |
---|---|---|
실행 단위 | OS 스레드 또는 async task | 고루틴 (경량 스레드) |
병렬 처리 수단 | 스레드, 채널, async/await | 고루틴, 채널 |
데이터 보호 | 컴파일 타임 소유권 체크 | 뮤텍스, 채널, race detector |
동시성 철학 | “Fearless Concurrency” (두려움 없는 동시성) | “Do not communicate by sharing memory…” |
GC | 없음 (직접 메모리 관리) | 있음 (자동 정리되지만 성능 오버헤드 발생 가능) |
라. 성능과 안전성 비교
항목 | Rust | Go |
---|---|---|
성능 | GC가 없어서 시스템 자원 최대 활용 | GC와 스케줄러의 오버헤드 존재 |
안전성 | 데이터 경쟁을 컴파일 타임에 방지 | 기본적으로 가능함 (race detector로 검사해야 함) |
스케일링 | 수천 개의 작업 처리 시 async 필요 | 수천 개 고루틴도 가볍게 처리 가능 |
디버깅 난이도 | 복잡 (라이프타임, borrow checker 등) | 비교적 단순 |
마. 상황별 언어 선택
상황 | Rust 추천 | Go 추천 |
---|---|---|
고성능 시스템 (e.g. 게임, 실시간 처리, WebAssembly) | √ | X |
빠른 개발, 유지보수 쉬운 서버 (e.g. 웹 API, 클라우드 백엔드) | 가능하지만 무겁고 복잡 | 매우 적합 |
메모리 제어 필요 (e.g. 임베디드, 드라이버) | √ | X |
초고성능 네트워크 서버 (e.g. Actix, Tokio 기반) | √ | GC로 한계 가능 |
간단한 병렬 작업, CLI 툴 | 한계 | √ |
바. 결론
항목 | Rust | Go |
---|---|---|
성능 | 최상급 | 좋음 (하지만 GC 존재) |
안전성 | 컴파일 타임 보장 | 런타임 race 가능 |
개발 속도 | 어렵고 장벽 높음 | 빠르고 쉬움 |
확장성 (스케일) | async 사용 시 매우 뛰어남 | 고루틴 덕분에 뛰어남 |
유지보수성 | 복잡 | 간단하고 명확 |
사. 요약
- Rust: 동시성을 정밀하게 제어해야 하거나, 성능과 안전이 최우선인 경우 유리
- Go: 빠르게 개발하고, 다수의 작업을 단순하게 병렬 처리할 때 탁월