저수준 언어를 직접 다뤄보고 싶었다. 메모리를 런타임이 아닌 언어 규칙으로 관리하는 경험. Rust를 선택했고, Book Chapter 20의 멀티스레드 HTTP 서버를 따라 구현했다. 외부 크레이트 없이 std 라이브러리만으로 약 200줄.
메모리 관리
Rust에는 가비지 컬렉터가 없다. 대신 소유권 시스템이 메모리 해제 시점을 컴파일 타임에 결정한다.
모든 값에는 소유자가 하나뿐이다. 소유자가 스코프를 벗어나면 값은 자동으로 해제된다. 다른 변수에 값을 대입하면 소유권이 이동하고, 원래 변수는 더 이상 사용할 수 없다. 컴파일러가 이를 강제한다.
let s1 = String::from("hello");
let s2 = s1; // 소유권 이동
// s1은 여기서 사용 불가 — 컴파일 에러
소유권을 넘기지 않고 값을 빌려줄 수도 있다. 참조(&)를 통한 borrowing이다. 불변 참조는 여러 개 동시에 가능하지만, 가변 참조(&mut)는 한 번에 하나만 허용된다. 이 규칙이 데이터 경합을 컴파일 타임에 차단한다.
GC 기반 언어에서는 런타임이 알아서 메모리를 수거한다. Rust는 그 판단을 컴파일러에게 맡긴다. 런타임 비용 없이 메모리 안전을 보장하는 구조다.
Thread Pool
서버의 핵심은 thread pool이다. TCP 연결이 들어오면 워커 스레드에 작업을 분배한다.
작업 분배에는 채널을 사용했다. 하나의 sender가 작업을 보내고, 여러 워커가 receiver를 공유한다. 문제는 Rust의 Receiver가 Clone을 구현하지 않는다는 점이었다. 여러 스레드가 하나의 receiver를 공유하려면 다른 방법이 필요했다.
Arc<Mutex<Receiver<T>>>가 그 답이었다. Arc는 참조 카운트를 통해 여러 스레드가 같은 값을 소유할 수 있게 한다. Mutex는 한 번에 하나의 스레드만 receiver에 접근하도록 보장한다. 소유권 규칙이 단일 스레드에서 동시성 환경으로 자연스럽게 확장되는 지점이었다.
let (sender, receiver) = mpsc::channel();
let receiver = Arc::new(Mutex::new(receiver));
for id in 0..size {
let receiver = Arc::clone(&receiver);
// 각 워커가 receiver의 참조 카운트를 공유
}
Arc::clone()은 값을 복사하지 않는다. 참조 카운트만 증가시킨다. 이 구분을 타입 시스템이 명확히 드러낸다고 봤다.
Graceful Shutdown
Drop trait은 값이 스코프를 벗어날 때 자동으로 호출된다. pool이 소멸할 때 워커를 정리하는 데 사용했다.
순서가 중요했다. 먼저 모든 워커에게 Terminate 메시지를 보내고, 그다음 각 스레드를 join한다. 이 순서를 뒤집으면 교착 상태가 발생할 수 있다. 첫 번째 워커의 join에서 블로킹되는 동안 나머지 워커는 종료 신호를 받지 못하기 때문이다.
// 1. 종료 신호를 먼저 모두 전송
for _ in &self.workers {
self.sender.send(Message::Terminate)?;
}
// 2. 그다음 join
for worker in &mut self.workers {
if let Some(thread) = worker.thread.take() {
thread.join()?;
}
}
worker.thread를 Option<JoinHandle<()>>로 선언한 것도 Rust다운 패턴이었다. take()로 핸들을 꺼내면 원래 자리에 None이 남는다. 같은 스레드를 두 번 join하는 실수를 타입 수준에서 방지한다.
Trait Bounds
thread pool의 제네릭 타입 제약은 세 가지다.
Pool<T: FnOnce() + Send + 'static>
FnOnce는 클로저가 한 번만 호출된다는 의미다. 작업은 한 워커가 한 번 실행하면 끝이다. Send는 클로저를 다른 스레드로 안전하게 전달할 수 있다는 보장이다. 'static은 클로저가 참조하는 값의 수명이 프로그램 전체와 같다는 제약이다. 스레드가 언제 종료될지 모르니, 빌린 참조가 먼저 해제되는 상황을 원천 차단한다.
이 세 가지 중 하나라도 빠지면 컴파일되지 않는다. Go에서는 goroutine에 클로저를 넘길 때 이런 제약이 없다. 대신 race condition을 -race 플래그로 런타임에 감지한다. Rust는 그 검증을 컴파일러가 수행한다.
회고
저수준 언어를 경험하고 싶다는 동기로 시작한 프로젝트였다. 실제로 체감한 것은 “저수준"보다 “컴파일러가 강제하는 안전성"이었다.
Arc<Mutex<T>>를 조합하지 않으면 여러 스레드가 receiver를 공유할 수 없다. FnOnce + Send + 'static을 명시하지 않으면 클로저를 스레드에 넘길 수 없다. Option<JoinHandle>로 선언하지 않으면 take()를 쓸 수 없다. 컴파일러가 “왜 이 조합이 필요한지"를 에러 메시지로 알려주고, 해결하면 동시성 안전이 보장된다.
저수준 언어를 다뤄보고 싶어서 시작했다. 결과적으로 배운 것은 타입 시스템이 동시성 버그를 런타임 전에 잡아주는 경험이었다.