다른 언어와 비교한 Rust의 동시성(concurrency) 장,단점

Rust의 동시성(concurrency)은 안전성과 성능을 모두 고려한 설계로, 데이터 경쟁(data race)을 컴파일 타임에 방지하고, 성능 저하 없이 병렬 처리를 가능하게 합니다. C++, Java, Python, Go 등 타 언어와 비교해 장단점을 알아보겠습니다.

1. Rust의 동시성 개념

Rust는 동시성을 다음 세 가지 방식으로 지원합니다:

  1. 스레드 기반 동시성 (std::thread)
    • OS 스레드를 생성하여 병렬 작업 수행.
    • thread:spawn을 통해 새로운 스레드를 실행.
  2. 메시지 기반 통신 (std::sync::mpsc)
    • 채널을 통해 스레드 간 데이터 교환.
    • 데이터 소유권을 안전하게 이동.
  3. 비동기 프로그래밍 (async/await, tokio, async-std)
    • 효율적인 I/O 처리.
    • 싱글 스레드에서 수천 개의 작업을 동시에 처리할 수 있음.
    • Future를이용한 논블로킹 방식.

2. Rust 동시성의 핵심 특징

특징설명
데이터 레이스 방지컴파일 타임에 mut, &mut, Send, Sync 등을 통해 공유 자원에 대한 안전성 확보
제로 코스트 추상화고급 추상화를 사용해도 런타임 오버헤드 없음
fearless concurrency안전하게 동시성을 구현할 수 있어 “두려움 없는 동시성”이라고도 불림

3. 타 언어와의 비교

가. Rust vs C++

항목RustC++
안전성컴파일 타임 데이터 레이스 방지런타임에서 버그 발견 가능
메모리 모델소유권 시스템수동 메모리 관리
쓰레드 API안전하고 모던한 추상화복잡하고 안전하지 않은 경우 많음

🔹 Rust는 안전하고 버그 없는 병렬 처리를 제공
🔸 C++은 성능은 뛰어나지만 관리 책임이 개발자에게 있음 (예: 뮤텍스 실수 → 데이터 손상)

나. Rust vs Java

항목RustJava
런타임없음 (네이티브 실행)JVM 기반
동기화Mutex, RwLock, channel 등 명시적synchronized, volatile, ExecutorService 등
성능시스템 수준 고성능GC와 JVM 오버헤드 존재

🔹 Rust는 GC 없는 고성능 동시성
🔸 Java는 GC로 메모리 관리가 쉽지만 지연 가능성 존재

다. Rust vs Python

항목RustPython
성능매우 빠름느림 (인터프리터 기반)
GIL (Global Interpreter Lock)없음있음 (멀티 코어 병렬 처리 불가)
비동기 처리고성능 async/awaitasyncio로 가능하나 성능은 낮음

🔹 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 동시성 비교 요약

항목RustGo
동시성 모델명시적 스레드 + 채널 + 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 변수 캡처 문제 발생 가능)

다. 동시성 핵심 차이

항목RustGo
실행 단위OS 스레드 또는 async task고루틴 (경량 스레드)
병렬 처리 수단스레드, 채널, async/await고루틴, 채널
데이터 보호컴파일 타임 소유권 체크뮤텍스, 채널, race detector
동시성 철학“Fearless Concurrency” (두려움 없는 동시성)“Do not communicate by sharing memory…”
GC없음 (직접 메모리 관리)있음 (자동 정리되지만 성능 오버헤드 발생 가능)

라. 성능과 안전성 비교

항목RustGo
성능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 툴한계

바. 결론

항목RustGo
성능최상급좋음 (하지만 GC 존재)
안전성컴파일 타임 보장런타임 race 가능
개발 속도어렵고 장벽 높음빠르고 쉬움
확장성 (스케일)async 사용 시 매우 뛰어남고루틴 덕분에 뛰어남
유지보수성복잡간단하고 명확

사. 요약

  • Rust: 동시성을 정밀하게 제어해야 하거나, 성능과 안전이 최우선인 경우 유리
  • Go: 빠르게 개발하고, 다수의 작업을 단순하게 병렬 처리할 때 탁월

비동기 프로그래밍 (async/await)

Rust는 성능과 안전성을 동시에 추구하는 언어입니다. 이런 철학은 비동기 프로그래밍(asynchronous programming)에도 그대로 적용됩니다. Rust의 async/await 문법은 동시성(concurrency)을 효과적으로 다루기 위한 강력한 도구로, 네트워크 프로그래밍이나 고성능 IO 처리에 자주 사용됩니다.


1. 비동기 프로그래밍이란?

비동기 프로그래밍이란, 어떤 작업이 완료될 때까지 기다리는 대신 다른 작업을 먼저 수행하도록 코드를 구성하는 방식입니다. 예를 들어 웹 서버가 여러 클라이언트의 요청을 동시에 처리할 때, 각각의 요청마다 새로운 스레드를 만들기보다, 하나의 스레드에서 여러 요청을 비동기적으로 처리하면 더 적은 리소스로 높은 성능을 얻을 수 있습니다.


2. async/await 개념

Rust의 비동기 프로그래밍은 크게 세 가지로 나뉩니다:

  • async fn: 비동기 함수를 정의하는 키워드
  • await: 비동기 함수의 결과를 기다리는 키워드
  • Future: 아직 완료되지 않은 비동기 작업을 나타내는 타입
async fn say_hello() {
println!("Hello!");
}

이 함수는 호출해도 바로 실행되지 않고, Future를 반환합니다. 실제로 실행되려면 .await를 사용해야 합니다.

say_hello().await;

3. 비동기 실행을 위한 런타임 (tokio)

Rust 표준 라이브러리는 자체적인 비동기 런타임을 제공하지 않습니다. 따라서 일반적으로 tokio 같은 서드파티 런타임을 사용합니다. tokio는 가장 널리 사용되는 비동기 런타임이며, 다양한 네트워크, 타이머, 채널 등 유틸리티를 제공합니다.

[dependencies]
tokio = { version = "1", features = ["full"] }

Cargo.toml의 dependencies에 tokio를 추가하고 cargo run을 하면 관련 라이브러리들이 자동으로 설치됩니다.

비동기 main 함수를 사용하려면 다음과 같이 작성합니다:

#[tokio::main]
async fn main() {
say_hello().await;
}

Cargo.toml과 별도로 main.rs의 코드는 아래와 같습니다.

#[tokio::main]
async fn main() {
    say_hello().await; 
}

async fn say_hello() {
    println!("Hello!");
}

#[tokio::main] 어트리뷰트

이 부분은 Tokio 런타임을 자동으로 시작해주는 매크로 어트리뷰트입니다.

  • Rust에서 async fn main()을 그냥 실행할 수는 없습니다. 왜냐하면 Rust는 기본적으로 비동기 실행 환경(런타임)을 제공하지 않기 때문이에요.
  • [tokio::main]은 main 함수에 Tokio 런타임을 삽입해서 비동기 코드를 실행할 수 있게 만들어줍니다.

async fn main()

이 함수는 비동기 함수입니다.

  • 비동기 함수는 실행될 때 Future를 반환합니다.
  • 이 Future는 .await될 때까지는 실제로 실행되지 않습니다.
  • 하지만 #[tokio::main] 덕분에 main() 함수도 비동기 함수로 정의할 수 있게 되었고, 프로그램은 say_hello().await를 실행하면서 say_hello 함수의 Future를 기다립니다.

say_hello().await

  • .await를 사용하면 이 Future의 실행을 기다립니다.
  • 즉, say_hello() 함수의 본문이 실행될 때까지 main 함수는 멈춰서 기다립니다.

async fn say_hello()

비동기 함수이지만 내부에 특별한 비동기 작업은 없습니다.

async fn say_hello() {
println!("Hello!");
}
  • 이 함수는 단지 “Hello!”를 출력합니다.
  • 비록 내부에 await는 없지만, 비동기 함수로 작성된 이유는 비동기 구조의 연습 또는 나중에 비동기 작업 (예: 네트워크 요청) 을 넣기 위함입니다.

위 코드를 실행하면 Hello!가 출력되는데,

아래와 같이 main과 say_hello함수의 순서를 바꾸고 실행하니 에러가 발생합니다.


4. 비동기 예제: 타이머

다음은 두 개의 비동기 작업을 동시에 실행하는 예제입니다:

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
let task1 = async {
sleep(Duration::from_secs(2)).await;
println!("2초 작업 완료");
};

let task2 = async {
println!("즉시 실행");
};

tokio::join!(task1, task2);
}

위 코드에서 sleep은 비동기적으로 대기하는 함수입니다. tokio::join! 매크로는 두 작업을 동시에 실행하고, 둘 다 완료될 때까지 기다립니다.

위 코드를 실행하면 “즉시 실행”을 먼저 출력하고, 2초 후에 “2초 작업 완료”를 출력합니다.


5. Future의 본질

Rust의 비동기 함수는 내부적으로 Future 트레잇을 구현하는 구조체를 반환합니다. 이 구조체는 “언제 실행될지 모르는 작업”을 표현하며, .await가 호출되었을 때에만 실행이 시작됩니다.

간단한 예시:

use std::future::Future;

async fn return_num() -> u32 {
10
}

fn main() {
let fut = return_num(); // 실행되지 않음
// fut는 Future 타입, 여기선 실행되지 않음
}

async 블록이나 함수는 Future를 만들기 위한 “공장(factory)” 역할만 하며, 실제 실행은 .await 또는 런타임에 의해 수행됩니다.

  • async fn return_num() -> u32:
    이 함수는 u32를 비동기로 반환하는 함수입니다.
    하지만 실제로는 u32인 10을 바로 리턴하는 단순한 함수입니다. Rust의 async fn은 항상 Future를 반환합니다.
  • let fut = return_num();
    여기서 return_num()을 호출했지만 실제 10을 반환하지 않습니다.
    대신 미래에 10을 반환할 수 있는 준비 상태의 Future 객체만 생성했을 뿐입니다.
  • fut는 실행 가능한 비동기 작업을 담고 있는 Future 타입입니다.
    하지만 실제로 실행(await)하지 않았기 때문에 아무 일도 일어나지 않습니다.
    실제 실행하려면 아래와 같이 .await를 사용해서 실행해야 합니다.
#[tokio::main]
async fn main() {
    let fut = return_num(); // fut: Future
    let result = fut.await; // 이제 실행됨!
    println!("결과: {}", result); // 출력: 결과: 10
}

6. 동시성(concurrency) vs 병렬성(parallelism)

Rust에서 async는 동시성을 위한 기능입니다. 하나의 스레드에서 여러 작업을 번갈아가며 처리하는 구조입니다. 반면, 병렬성은 여러 CPU 코어에서 동시에 작업을 수행하는 것으로, std::thread 등을 통해 구현합니다. 둘은 목적이 다르지만 상호 보완적으로 사용될 수 있습니다.


7. 실전 예제: HTTP 요청

비동기의 진가는 네트워크 작업에서 드러납니다. 예를 들어, reqwest 라이브러리를 이용한 HTTP GET 요청은 다음과 같습니다.

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let resp = reqwest::get("https://httpbin.org/get").await?;
let body = resp.text().await?;
println!("응답 본문:\n{}", body);
Ok(())
}
async fn main() -> Result<(), Box<dyn std::error::Error>>
  • async fn main(): main 함수 자체가 비동기 함수로 정의되어 있습니다.
  • Result<(), Box<dyn std::error::Error>> :
    ? 연산자를 사용하기 위해 오류 처리를 Result 타입으로 합니다.
    다양한 종류의 오류(reqwest::Error, std::io::Error, 등)를 포괄하기 위해 Box<dyn std::error::Error>>를 사용합니다.

let resp = reqwest::get(“https://httpbin.org/get”).await?;
  • reqwest::get(…): reqwest 라이브러리로 GET 요청을 보냅니다.
  • .await: 비동기 요청이 완료될 때까지 기다립니다.
  • ?: 요청 중 오류가 발생하면 main 함수에서 곧바로 리턴됩니다.
  • resp는 reqwest::Response 타입입니다.

let body = resp.text().await?;
  • 응답 객체인 resp에서 본문을 텍스트로 변환합니다.
  • text()는 Future를 반환하므로 .await로 기다립니다.
  • ?로 오류 처리합니다.
  • 결과는 String 타입입니다.

println!(“응답 본문:\n{}”, body);
  • 응답 본문 전체를 콘솔에 출력합니다.

Ok(())

함수가 정상적으로 끝났다는 것을 알리는 반환값입니다.reqwest::get()과 resp.text()는 모두 Future를 반환하므로 .await를 사용해야 합니다.


실행했더니 reqwest에서 에러가 발생해서 Cargo.toml에 reqwest = “0.12.20”을 추가해야 합니다.

[dependencies]
reqwest = "0.12.20"
tokio = { version = "1", features = ["full"] }

그리고, 실행하면 관련 라이브러리들을 설치하고, 컴파일하고 run을 하고, get한 결과를 출력합니다.

8. async와 에러 처리

비동기 함수에서도 Result 타입을 반환하여 에러를 처리할 수 있습니다.

#[tokio::main]
async fn main() {
match fetch_data().await {
Ok(body) => println!("응답 본문:\n{}", body),
Err(e) => eprintln!("요청 중 오류 발생: {}", e),
}
}

async fn fetch_data() -> Result<String, reqwest::Error> {
let resp = reqwest::get("https://example.com").await?;
let text = resp.text().await?;
Ok(text)
}
fetch_data().await
  • fetch_data는 비동기 함수이므로 실행하려면 .await를 붙여야 실제로 실행됩니다.
  • Result를 반환합니다.

이줄에서 fetch_data()의 실행이 끝날 때까지 기다리고, 그 결과에 따라 아래 match가 분기됩니다.

성공한 경우 (Ok)
  • HTTP 요청이 성공하면 응답 본문이 String 타입으로 body에 담깁니다.
  • 그것을 println!으로 콘솔에 출력합니다.
실패한 경우 (Err)
  • 요청 도중 네트워크 오류, 타임아웃 등 문제가 발생하면 reqwest::Error가 e에 들어옵니다.
  • println!는 표준 에러 스트림에 메시지를 출력합니다 (보통 터미널에서 빨간 글씨로 나옴).
async fn fetch_data() -> Result <String, reqwest::Error> {
  • async fn: 비동기 함수입니다. 호출 시 바로 실행되지 않고, Future를 반환합니다.
  • Result<String, reqwest::Error>:
    Ok(String): 요청이 성공하면 응답 본문을 String으로 감싸서 반환합니다.
    Err(reqwest::Error): 네트워크 오류, HTTP 오류 등이 발생하면 오류를 반환합니다.

let resp = reqwest::get(“https://example.com”).await?;
  • reqwest::get(…): GET 방식으로 HTTP 요청을 보냅니다.
  • .await: 이 작업이 완료될 때까지 기다립니다. 이때 다른 작업은 블로킹되지 않으며, tokio 런타임이 비동기적으로 대기합니다.
  • ?: 요청 도중 에러가 발생하면 바로 Err를 반환하며, 함수가 종료됩니다.

이 줄의 결과는 resp 변수에 저장되며, 이는 응답(Response) 객체입니다.


let text = resp.text().await?;
  • resp.text(): 응답 본문을 String으로 변환하는 Future를 반환합니다.
  • .await: 이 본문을 받아올 때까지 대기합니다.
  • ?: 변환 과정에서 에러가 나면 역시 Err를 반환하며 함수가 종료됩니다.

Ok(text)
  • 모든 작업이 성공적으로 끝나면 text 값을 Result의 Ok로 감싸서 반환합니다.

성공할 경우의 실행 결과는 아래와 같고,

사이트 주소에서 e를 제거해서 exampl.com으로 수정하고 실행하니 아래와 같이 “요청 중 오류 발생: error sending request” 에러 메시지가 표시됩니다.


9. 정리

개념설명
async fn비동기 함수 정의
.awaitFuture의 완료를 기다림
Future완료되지 않은 작업
tokio비동기 런타임 라이브러리
join!여러 Future를 동시에 실행
sleep()비동기 대기 함수

10. 마무리

Rust의 비동기 프로그래밍은 고성능 서버나 네트워크 애플리케이션을 만들 때 매우 강력합니다. 하지만 컴파일러가 엄격하게 검사하므로 안전한 동시성 코드를 작성할 수 있습니다.

동시성(Concurrency)과 스레드(Thread)

1. 동시성(Concurrency)이란?

동시성(Concurrency)은 프로그램이 여러 작업을 “동시에” 처리하는 능력을 말합니다. 꼭 물리적으로 동시에 실행되는 것(병렬성)이 아니라, 여러 작업을 논리적으로 동시에 실행하는 것입니다. 즉, 동시성은 스레드보다 더 큰 개념이며, 스레드는 그 구현 수단 중 하나입니다.

Rust에서 동시성은 다음과 같은 방식으로 구현할 수 있습니다:

  • 스레드(Thread)
  • 메시지 전달(Channel)
  • 공유 메모리(Arc<Mutex<T>>)
  • 비동기 프로그래밍(async/await)

2. Rust의 동시성 철학

Rust의 동시성은 다음 원칙을 따릅니다:

  1. 안전성 (Safety): 동시성 버그(데이터 경쟁, 데드락 등)를 컴파일 타임에 방지
  2. 명시성 (Explicitness): 공유 자원, 락, 메시지 전달 등이 명확히 드러남
  3. 추론 가능성: 컴파일러가 Send, Sync 트레잇으로 스레드 간 이동 가능 여부를 추론

C++/Java는 런타임 오류가 나야 알 수 있는 동시성 문제를, Rust는 컴파일러가 사전에 차단합니다.


3. 스레드를 이용한 동시성

use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        println!("스레드에서 실행 중");
    });

    println!("메인 스레드 실행 중");
    handle.join().unwrap();
}

thread:spawn을 통해 새로운 스레드를 생성하고, join()을 통해 스레드 종료를 기다립니다.
이 방식은 동시에 여러 작업을 실행하려는 목적, 즉 동시성을 달성하는 가장 기본적인 방식입니다.

use std::thread;
  • Rust 표준 라이브러리의 thread 모듈을 가져옵니다(import).
  • 이 모듈은 스레드 생성 및 제어에 관련된 기능을 제공합니다.

let handle = thread::spawn(|| { … });
  • thread::spawn은 새로운 스레드를 생성하고 그 안에서 클로저(익명 함수)를 실행합니다.
  • 이 예제에서 생성된 스레드는 println!(“스레드에서 실행 중”);을 실행합니다.
  • spawn은 JoinHandle이라는 객체를 반환하며, 이를 handle 변수에 저장합니다.
  • 이 handle은 이후 스레드가 언제 종료됐는지 기다릴 수 있게(join) 도와줍니다.

println!(“메인 스레드 실행 중”);
  • 메인 스레드는 새로 만든 스레드와 동시에 실행됩니다.
  • 따라서 이 줄과 스레드에서 실행 중 메시지 중 어느 것이 먼저 출력될지는 실행 시마다 달라질 수 있습니다.
  • 이는 Rust의 동시성(Concurrency) 특징입니다.

handle.join().unwrap();
  • join()은 새로 생성한 스레드가 끝날 때까지 기다리는 함수입니다.
  • 스레드 실행이 끝나면 Result<T, E>를 반환하고, 성공하면 Ok(()), 실패하면 Err(e)가 반환됩니다.
  • 여기서는 unwrap()을 사용해에러가 발생하면 패닉(panic)하도록 합니다.

4. 메시지 전달로 동시성 구현 (채널)

Rust는 공유 메모리 대신 메시지 전달(Message Passing) 모델을 권장합니다. std::sync::mpsc 모듈을 통해 채널을 만들 수 있습니다.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        tx.send("데이터").unwrap();
    });

    let received = rx.recv().unwrap();
    println!("수신: {}", received);
}

위 코드는 멀티 스레드 환경에서 채널(channel)을 이용한 스레드 간 통신을 보여줍니다.


use std::sync::mpsc;
  • mpsc는 “multiple producer, single consumer”의 약자로, 여러 개의 생산자(보내는 쪽)가 하나의 소비자(받는 쪽)를 지원하는, 다시 말해 여러 생산자(스레드)가 하나의 소비자(스레드)에게 데이터를 보낼 수 있는 채널(channel) 시스템입니다.

let (tx, rx) = mpsc::channel();
  • channel() 함수는 전송자(Sender)수신자(Receiver) 한 쌍을 반환합니다.
    • tx: 데이터 전송에 사용 (transmitter)
    • rx: 데이터 수신에 사용 (receiver)

thread::spawn(move || { … })
  • 새로운 스레드를 생성합니다.
  • move 키워드는 클로저 내부로 tx를 소유권 이동(move)시키기 위해 사용됩니다.
  • tx를 다른 스레드에서 사용할 수 있게 하려면 반드시 move가 필요합니다.

tx.send(“데이터”).unwrap();
  • 생성된 스레드에서 “데이터”라는 문자열을 메인 스레드로 전송합니다.
  • 전송 결과가 Result이기 때문에, .unwrap()을 호출하여 에러가 나면 패닉하게 합니다.

let received = rx.recv().unwrap();
  • 메인 스레드에서 recv()로 데이터가 도착할 때까지 블로킹(기다림)합니다.
  • 다른 스레드에서 “데이터”를 보냈기 때문에, 수신이 성공하면 received에 저장됩니다.

println!(“수신: {}”, received);
  • 받은 데이터를 출력합니다.

출력 결과는 “수신: 데이터”입니다.


공유된 메모리에 락을 걸 필요 없이, 스레드 간 메시지를 전달함으로써 동시성을 구현합니다.

이 방식은 Go 언어의 고루틴 + 채널과 비슷하며, 병렬성이 아닌 동시성 컨트롤에 적합합니다.


5. Arc<Mutex<T>>를 통한 공유 메모리 동시성

다수의 스레드가 데이터를 공유해야 한다면, Arc로 참조를 공유하고 Mutex로 락을 걸어야 합니다.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let data = Arc::new(Mutex::new(0));

    let mut handles = vec![];
    for _ in 0..5 {
        let data = Arc::clone(&data);
        handles.push(thread::spawn(move || {
            let mut num = data.lock().unwrap();
            *num += 1;
        }));
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("최종 값: {}", *data.lock().unwrap());
}

위 코드는 여러 스레드가 안전하게 하나의 데이터를 공유하고 수정하는 예제입니다. 여기서는 Mutex와 Arc를 조합하여 스레드 간 공유 데이터의 동기화를 구현하고 있습니다.


가. 주요 개념 정리

(1) Mutex<T>: 상호 배제
  • 여러 스레드가 동시에 데이터에 접근하지 못하도록 막아주는 락(lock).
  • .lock().unwrap()을 호출하면 락을 얻고, 반환된 객체(MutexGuard)를 통해 데이터를 읽고 쓸 수 있음.
  • 락을 가진 객체는 스코프를 벗어나면 자동으로 해제됨.
(2) Arc<T>: 원자적 참조 카운트
  • 여러 스레드에서 읽기 전용 또는 Mutex와 결합한 공유 사용이 가능.
  • Rc는 단일 스레드용이고, Arc는 멀티스레드에서도 안전하게 참조를 공유할 수 있도록 설계됨.
  • Arc::clone(&data)는 참조 카운트를 늘릴 뿐, 실제 데이터를 복사하지 않음.

나. 코드 분석

let data = Arc::new(Mutex::new(0));
  • 0이라는 값을 Mutex로 감싸고, 이를 다시 Arc로 감쌈.
  • 이렇게 하면 여러 스레드가 이 데이터를 공유하면서도 동시에 수정하지 못하도록 보장할 수 있음.

let mut handles = vec![];

for _ in 0..5 {
let data = Arc::clone(&data);
handles.push(thread::spawn(move || {
let mut num = data.lock().unwrap();
*num += 1;
}));
}
  • 5개의 스레드를 생성.
  • 각 스레드는 Arc::clone(&data)를 통해 공유 데이터를 가리키는 참조를 가짐.
  • data.lock().unwrap()을 통해 락을 획득하고 *num += 1로 값을 증가시킴.
  • unwrap()은 락 획득 실패(예: 패닉) 시 프로그램을 종료시키는 간단한 오류 처리 방식.

for handle in handles {
handle.join().unwrap();
}
  • 각 스레드가 종료될 때까지 대기 (join()).
  • 이렇게 해야 모든 스레드가 작업을 마치고 최종 값을 출력함.

println!("최종 값: {}", *data.lock().unwrap());
  • 메인 스레드에서 최종 값을 읽음.
  • 이때도 lock()을 사용하여 락을 획득한 뒤 출력.

다. 결과

  • 각 스레드는 0부터시작한 값을 1씩 증가시킴.
  • 5개의 스레드가 있으므로 최종 값은 5가 됨.

6. 비동기(async/await) 기반 동시성

Rust는 스레드 외에도 async fn, Future, await 등을 이용한 비동기 동시성 모델도 지원합니다. 이 모델은 싱글 스레드에서도 여러 작업을 동시에 처리하는 방식으로, 특히 IO 작업에 적합합니다.

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    let f1 = async {
        sleep(Duration::from_secs(1)).await;
        println!("1초 작업 완료");
    };

    let f2 = async {
        println!("즉시 실행");
    };

    tokio::join!(f1, f2);
}
use tokio::time::{sleep, Duration};

tokio 라이브러리의 time 모듈에서 sleep함수와 Duration 타입을 불러옵니다(import).

가. 코드 분석

#[tokio::main]
  • 이 어트리뷰트는 비동기 함수 main을 실행할 수 있도록 tokio 런타임을 자동으로 생성해줍니다.
  • main() 함수가 async fn이므로 일반적으로는 실행할 수 없는데, tokio가 런타임을 초기화하고 실행을 담당합니다.

let f1 = async { … }
  • 비동기 블록입니다.
  • 내부에서sleep(Duration::from_secs(1)).await를 사용하여 1초간 대기한 후, “1초 작업 완료”를 출력합니다.
  • sleep은 스레드를 멈추는 것이 아니라, 해당 Future를 잠시 중단(suspend) 시켜 다른 작업을 실행할 수 있게 합니다.

let f2 = async { … }

  • 이 비동기 블록은 바로 “즉시 실행”을 출력합니다.
  • await나 지연 없이 즉시 완료되는 작업입니다.

tokio::join!(f1, f2);

  • 이 매크로는 f1과 f2를 동시에 실행시킵니다.
  • 두 작업이 모두 끝날 때까지 기다립니다.
  • 즉, f2는 바로 실행되고 “즉시 실행”이 출력됩니다.
  • f1은 1초 후 “1초 작업 완료”를 출력합니다.

나. 실행 결과

실제로 프로그램이 출력하는 순서는 다음과 같습니다:

즉시 실행
1초 작업 완료

두 작업은 병렬이 아닌 동시에 스케줄되는 병행(concurrent) 실행입니다.

비동기 동시성은 Tokio 같은런타임이 필요합니다. 이는 경량 스레드처럼 동작하여 수천 개의 작업을 동시 처리할 수 있습니다.


7. 정리

구분특징사용 예
스레드OS 레벨 병렬 실행CPU 작업 처리
채널메시지 기반 동시성안전한 데이터 전달
Arc<Mutex<T>>메모리 공유 및 제어공유 자원 카운터 등
async/await경량 동시성, 비차단네트워크, 파일 IO

8. 마무리

Rust의 동시성은 단순히 스레드를 사용하는 것에 그치지 않고, 메시지 전달 모델, 공유 메모리 모델, 비동기 실행 모델 등 다양한 방식으로 구현될 수 있습니다. Rust는 이러한 모델을 안전하고 명시적으로 구현할 수 있도록 돕는 언어이며, 컴파일 타임에 동시성 버그를 방지하는 강력한 도구입니다.

Rust에서 동시성은 단지 기술이 아니라, 신뢰성과 안전성을 보장하는 언어적 철학입니다.