우아한 종료와 정리
앞 절의 요청 처리를 유지하면서 새 작업 전송을 끝내고, ThreadPool의 Drop에서 남은 작업과 Worker 스레드 종료를 기다립니다.
앞 절의 채널과 execute 구현에서는 메인 스레드가 TCP 연결을 받고, pool.execute가 연결 처리 작업을 채널로 넘겼습니다. 워커는 그 작업을 자신의 OS 스레드에서 실행합니다.
요청을 읽는 handle_connection은 예제 19-10의 BufReader와 세 가지 match 분기를 그대로 사용합니다. 이번에는 요청 처리 대신 서버가 끝나는 순서를 바꾸겠습니다.
현재 코드는 워커의 종료를 기다리지 않습니다. ctrl-c로 프로세스를 중지하면 처리 중인 응답을 마칠 때까지 기다리는 절차도 없습니다.
여기서는 메인 스레드가 새 연결을 더 받지 않게 한 뒤, 채널의 Sender를 버려 새 작업 전송을 끝냅니다. 워커는 이미 받은 작업과 대기열에 남은 작업을 처리하고 종료합니다. Drop에서 각 워커의 join을 호출하면 그 종료를 기다릴 수 있습니다.
정리 순서를 살펴볼 수 있도록 연결 두 개의 작업을 넘긴 뒤 수신 루프를 끝내는 데모를 만들겠습니다. 연결 수와 HTTP 응답의 성공 횟수는 아래에서 구분합니다.
ThreadPool에 대한 Drop 트레이트 구현하기
스레드 풀에 대해 Drop을 구현하는 것부터 시작해보겠습니다.
풀이 버려질 때 각 워커 스레드가 끝날 때까지 기다리도록 하겠습니다.
예제 19-22는 Drop 구현의 첫 번째 시도를 보여줍니다; 이 코드는 아직 제대로 작동하지 않습니다.
impl Drop for ThreadPool {
fn drop(&mut self) {
for worker in &mut self.workers {
println!("Shutting down worker {}", worker.id);
worker.thread.join().unwrap();
}
}
}
먼저 스레드 풀 workers의 각각에 대한 반복을 수행합니다.
이를 위해 &mut을 사용하는데, self는 가변 참조자고 worker도 변경할 수 있어야 하기 때문입니다.
각 워커에 대해서 지금의 특정한 워커가 종료된다는 메시지를 출력한 다음, 해당 워커의 스레드에 대해 join을 호출합니다.
join 호출이 실패하면 unwrap을 사용하여 러스트가 패닉 상태에 빠지게 하고 우아하지 않은 종료로 들어갑니다.
아래는 위 코드를 컴파일했을 때 나오는 에러입니다.
$ cargo check
Checking hello v0.1.0 (file:///projects/hello)
error[E0507]: cannot move out of `worker.thread` which is behind a mutable reference
--> src/lib.rs:52:13
|
52 | worker.thread.join().unwrap();
| ^^^^^^^^^^^^^ ------ `worker.thread` moved due to this method call
| |
| move occurs because `worker.thread` has type `JoinHandle<()>`, which does not implement the `Copy` trait
|
note: this function takes ownership of the receiver `self`, which moves `worker.thread`
--> /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483/library/std/src/thread/mod.rs:1581:17
For more information about this error, try `rustc --explain E0507`.
error: could not compile `hello` due to previous error
이 에러는 각 worker의 가변 대여만 있고 join이 인수의 소유권을 가져가기 때문에 join을 호출할 수 없음을 알려줍니다.
이 문제를 해결하려면 thread를 소유한 Worker 인스턴스로부터 스레드를 밖으로 옮겨서 join이 스레드를 써버릴 수 있도록 할 필요가 있습니다.
예제 16-15에서 이 작업을 해봤었지요.
Worker가 대신 Option<thread::JoinHandle<()>>을 가지고 있다면, Option의 take 메서드를 호출하여 Some 배리언트에서 값을 빼내고 그 자리에는 None 배리언트를 남길 수 있습니다.
바꿔 말하면, 새 Worker는 thread에 Some(handle)을 갖도록 하고, 정리할 때 핸들을 꺼내 필드에는 None을 남깁니다. None이 되었다고 스레드가 멈춘 것은 아닙니다. 꺼낸 핸들로 join을 호출해야 종료를 기다릴 수 있습니다.
join이 핸들의 소유권을 필요로 하므로, Option::take는 필드 안의 값을
안전하게 꺼내는 작은 이동 통로 역할을 합니다.
스레드 풀 우아한 종료 순서
Drop에서 Sender를 take·drop하고 각 JoinHandle을 take·join하는 메인 흐름과 워커의 잔여 Job 처리→recv Err→break가 겹친다. 채널 닫기와 강제 중단, join 대기와 종료 신호를 혼동하지 않는 상태 추적 가치가 높다.
RUST · 두 스레드의 종료 흐름
채널을 닫고, 남은 작업이 끝날 때까지 join으로 기다린다
메인 스레드의 정리와 워커의 마지막 작업 처리는 함께 진행됩니다. join은 종료 신호를 보내는 연산이 아니라 종료를 기다리는 연산입니다.
메인의 join 대기와 다른 워커의 잔여 작업 실행은 겹친다. 로그 출력 순서는 달라질 수 있다.
코드·조건·비교표와 보충 설명
메인 스레드 · ThreadPool::drop
Sender를 먼저 꺼내 버린다
drop(self.sender.take()). 이 풀의 Sender가 사라져 새 작업 전송이 끝납니다. 다른 Sender 복제본도 모두 사라져야 채널이 닫힙니다.
워커 핸들의 소유권을 꺼낸다
worker.thread.take()는 Some(handle)을 반환하고 필드에는 None을 남깁니다.
join으로 종료를 기다린다
handle.join()은 핸들을 소비하며 해당 워커가 끝날 때까지 기다립니다. 남은 워커도 차례대로 기다립니다.
워커 스레드 · 수신 루프
이미 보낸 Job은 계속 받는다
recv()가 Ok(job)을 반환하면 수신 잠금을 해제하고 job()을 실행합니다.
모든 Sender와 대기 작업이 사라진다
이미 실행 중인 작업은 완료합니다. 닫힌 채널의 버퍼가 비면 다음 recv()는 오류를 반환합니다.
Err 갈래에서 break한다
루프를 벗어나 워커 스레드가 끝납니다. 해당 워커를 기다리던 join()도 반환할 수 있습니다.
겹쳐 진행되는 부분
메인 스레드가 첫 워커의 join()에서 기다리는 동안, 다른 워커는 대기 작업을 받거나 실행할 수 있습니다. 로그 순서는 실행마다 달라질 수 있습니다. 모든 join().unwrap()이 성공하면 풀의 Drop이 완료됩니다.
Sender를 버렸다고 워커가 즉시 중단되지는 않습니다. 종료 순서는 채널 닫기 → 남은 작업 완료 → 각 워커 종료 확인입니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 송신 닫기 pool Drop이 sender.take 값을 drop한다. |
|
|
| 잔여 작업·join 메인이 handle을 take·join하는 동안 worker가 남은 job을 처리한다. |
|
|
| 채널 소진·완료 버퍼가 비어 recv가 Err가 되면 worker가 break하고 join이 반환한다. |
|
|
스레드 풀 우아한 종료 순서
Drop에서 Sender를 take·drop하고 각 JoinHandle을 take·join하는 메인 흐름과 워커의 잔여 Job 처리→recv Err→break가 겹친다. 채널 닫기와 강제 중단, join 대기와 종료 신호를 혼동하지 않는 상태 추적 가치가 높다.
RUST · 두 스레드의 종료 흐름
채널을 닫고, 남은 작업이 끝날 때까지 join으로 기다린다
메인 스레드의 정리와 워커의 마지막 작업 처리는 함께 진행됩니다. join은 종료 신호를 보내는 연산이 아니라 종료를 기다리는 연산입니다.
메인의 join 대기와 다른 워커의 잔여 작업 실행은 겹친다. 로그 출력 순서는 달라질 수 있다.
코드·조건·비교표와 보충 설명
메인 스레드 · ThreadPool::drop
Sender를 먼저 꺼내 버린다
drop(self.sender.take()). 이 풀의 Sender가 사라져 새 작업 전송이 끝납니다. 다른 Sender 복제본도 모두 사라져야 채널이 닫힙니다.
워커 핸들의 소유권을 꺼낸다
worker.thread.take()는 Some(handle)을 반환하고 필드에는 None을 남깁니다.
join으로 종료를 기다린다
handle.join()은 핸들을 소비하며 해당 워커가 끝날 때까지 기다립니다. 남은 워커도 차례대로 기다립니다.
워커 스레드 · 수신 루프
이미 보낸 Job은 계속 받는다
recv()가 Ok(job)을 반환하면 수신 잠금을 해제하고 job()을 실행합니다.
모든 Sender와 대기 작업이 사라진다
이미 실행 중인 작업은 완료합니다. 닫힌 채널의 버퍼가 비면 다음 recv()는 오류를 반환합니다.
Err 갈래에서 break한다
루프를 벗어나 워커 스레드가 끝납니다. 해당 워커를 기다리던 join()도 반환할 수 있습니다.
겹쳐 진행되는 부분
메인 스레드가 첫 워커의 join()에서 기다리는 동안, 다른 워커는 대기 작업을 받거나 실행할 수 있습니다. 로그 순서는 실행마다 달라질 수 있습니다. 모든 join().unwrap()이 성공하면 풀의 Drop이 완료됩니다.
Sender를 버렸다고 워커가 즉시 중단되지는 않습니다. 종료 순서는 채널 닫기 → 남은 작업 완료 → 각 워커 종료 확인입니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 송신 닫기 pool Drop이 sender.take 값을 drop한다. |
|
|
| 잔여 작업·join 메인이 handle을 take·join하는 동안 worker가 남은 job을 처리한다. |
|
|
| 채널 소진·완료 버퍼가 비어 recv가 Err가 되면 worker가 break하고 join이 반환한다. |
|
|
따라서 Worker의 정의를 다음과 같이 업데이트하고 싶습니다.
struct Worker {
id: usize,
thread: Option<thread::JoinHandle<()>>,
}
이제 컴파일러를 활용하여 변경이 필요한 다른 위치를 찾아봅시다.
이 코드를 검사하면 두 가지 오류를 얻습니다.
$ cargo check
Checking hello v0.1.0 (file:///projects/hello)
error[E0599]: no method named `join` found for enum `Option` in the current scope
--> src/lib.rs:52:27
|
52 | worker.thread.join().unwrap();
| ^^^^ method not found in `Option<JoinHandle<()>>`
|
note: the method `join` exists on the type `JoinHandle<()>`
--> /rustc/d5a82bbd26e1ad8b7401f6a718a9c57c96905483/library/std/src/thread/mod.rs:1581:5
help: consider using `Option::expect` to unwrap the `JoinHandle<()>` value, panicking if the value is an `Option::None`
|
52 | worker.thread.expect("REASON").join().unwrap();
| +++++++++++++++++
error[E0308]: mismatched types
--> src/lib.rs:72:22
|
72 | Worker { id, thread }
| ^^^^^^ expected enum `Option`, found struct `JoinHandle`
|
= note: expected enum `Option<JoinHandle<()>>`
found struct `JoinHandle<_>`
help: try wrapping the expression in `Some`
|
72 | Worker { id, thread: Some(thread) }
| +++++++++++++ +
Some errors have detailed explanations: E0308, E0599.
For more information about an error, try `rustc --explain E0308`.
error: could not compile `hello` due to 2 previous errors
Worker::new 끝에 있는 코드를 지적하는 두 번째 에러를 해결해 보겠습니다; 새 Worker를 생성할 때 thread 값을 Some으로 감싸야 합니다.
이 에러를 해결하려면 다음과 같이 변경하세요.
impl Worker {
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
// --생략--
Worker {
id,
thread: Some(thread),
}
}
}
첫 번째 에러는 Drop 구현체 안에 있습니다.
앞서 Option 값의 take를 호출하여 thread를 worker 밖으로 빼내려고 했음을 언급했습니다.
다음과 같이 변경하면 그렇게 됩니다.
impl Drop for ThreadPool {
fn drop(&mut self) {
for worker in &mut self.workers {
println!("Shutting down worker {}", worker.id);
if let Some(thread) = worker.thread.take() {
thread.join().unwrap();
}
}
}
}
16장에서 논의한 것처럼 Option의 take 메서드는 Some 배리언트를 제거하고 그 자리에 None을 남깁니다.
여기서는 if let을 사용해 Some을 해체하고 스레드를 가져옵니다; 그런 다음 그 스레드에서 join을 호출합니다.
필드가 이미 None이면 이 Worker에서 꺼낼 핸들이 없으므로 해당 분기를 건너뜁니다.
작업을 기다리는 스레드에게 정지 신호 보내기
모든 변경 사항을 적용하면 경고 없이 코드가 컴파일됩니다.
하지만 나쁜 소식이 있는데, 그것은 이 코드가 아직 우리가 원하는 방식으로 작동하지 않는다는 것입니다.
핵심은 Worker 인스턴스의 스레드에 의해 실행되는 클로저의 로직입니다.
현재로서는 join이 호출되지만 스레드가 작업을 찾기 위해 계속 loop를 돌기 때문에 스레드는 종료되지 않습니다.
현재 구현된 drop으로 ThreadPool을 버리려고 하면, 메인 스레드는 첫 번째 스레드가 완료될 때까지 영원히 블록됩니다.
이 문제를 해결하기 위해서 ThreadPool drop 구현을 변경한 다음
Worker 루프를 변경할 필요가 있습니다.
먼저 스레드가 완료될 때까지 기다리기 전에 명시적으로 sender를 버리도록 ThreadPool drop 구현을 변경하겠습니다.
예제 19-23은 명시적으로 sender를 버리도록 ThreadPool을 변경한 내용을 보여줍니다.
스레드에서 했던 것과 동일한 Option 및 take 기법을 사용하여 ThreadPool로부터 sender를 빼낼 수 있습니다.
sender 버리기
pub struct ThreadPool {
workers: Vec<Worker>,
sender: Option<mpsc::Sender<Job>>,
}
// --생략--
impl ThreadPool {
pub fn new(size: usize) -> ThreadPool {
// --생략--
ThreadPool {
workers,
sender: Some(sender),
}
}
pub fn execute<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
let job = Box::new(f);
self.sender.as_ref().unwrap().send(job).unwrap();
}
}
impl Drop for ThreadPool {
fn drop(&mut self) {
drop(self.sender.take());
for worker in &mut self.workers {
println!("Shutting down worker {}", worker.id);
if let Some(thread) = worker.thread.take() {
thread.join().unwrap();
}
}
}
}
Drop에서 이 풀의 sender를 꺼내 버리면 새 작업 전송이 끝납니다. 다른 Sender 복제본이 있다면 그것도 모두 사라져야 채널이 닫힙니다.
이미 전송된 Job은 계속 수신할 수 있습니다. 모든 Sender가 사라지고 대기열도 비면 recv가 에러를 반환합니다.
예제 19-24에서는 그런 경우 Worker 루프가 정상적으로 종료되도록 변경하여 ThreadPool drop 구현이 join을 호출할 때 스레드가 종료되도록 합니다.
recv가 에러를 반환한 경우
명시적으로 루프 벗어나기
impl Worker {
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
let thread = thread::spawn(move || loop {
let message = receiver.lock().unwrap().recv();
match message {
Ok(job) => {
println!("Worker {id} got a job; executing.");
job();
}
Err(_) => {
println!("Worker {id} disconnected; shutting down.");
break;
}
}
});
Worker {
id,
thread: Some(thread),
}
}
}
let message = receiver.lock().unwrap().recv();의 세미콜론에서 수신에 사용한 임시 잠금이 해제됩니다. 따라서 match 안의 job()은 잠금 밖에서 실행됩니다. 작업이 끝난 워커가 다시 recv를 호출하고, 닫힌 채널의 대기열도 비어 있으면 Err 갈래로 나갑니다.
이제 예제 19-25처럼 main의 수신 루프에 .take(2)를 붙여 정리를 시작할 지점을 만들겠습니다.
예제 19-25: 두 연결의 작업을 넘긴 뒤 정리 시작하기
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
let pool = ThreadPool::new(4);
for stream in listener.incoming().take(2) {
let stream = stream.unwrap();
pool.execute(|| {
handle_connection(stream);
});
}
println!("Shutting down.");
}
incoming()은 연결 수락 시도의 Result<TcpStream, io::Error>를 내는 반복자입니다. .take(2)는 그 항목을 최대 두 개 가져옵니다. 수락과 작업 전송에 오류가 없는 정상 흐름에서는 연결 두 개를 작업으로 넘깁니다. 수락 결과가 Err이면 unwrap에서 패닉합니다. HTTP 요청이 유효했거나 응답이 성공했는지를 세는 코드는 아닙니다.
브라우저는 한 번 페이지를 열어도 아이콘 요청, 재시도, 미리 연 연결을 만들 수 있습니다. 따라서 페이지를 두 번 방문해야 종료된다거나, 방문 두 번에 정확히 응답 두 개가 성공한다고 예상하지 마세요.
두 작업을 넘기고 루프를 벗어나면 Shutting down.을 출력합니다. 이 메시지는 새 연결을 더 받아 작업으로 넘기지 않겠다는 지점입니다. 워커의 응답이 모두 끝났다는 뜻은 아닙니다. 그 뒤 pool이 스코프를 벗어나며 Drop의 정리가 시작됩니다.
listener는 pool보다 먼저 선언되어 나중에 버려지므로, 풀을 정리하는 동안에는 리스너 소켓이 남아 있습니다. 이때 추가 TCP 연결이 성립하더라도 메인 스레드는 더 이상 accept하지 않아 handler에 전달하지 않습니다. .take(2)가 세 번째 연결을 즉시 거절하는 기능은 아닙니다.
cargo run으로 서버를 시작한 뒤 연결을 보내며 작업과 종료 메시지를 살펴보세요. /sleep 작업이 포함되면 그 워커는 5초 동안 기다린 뒤 응답하고, 메인 스레드는 join에서 기다릴 수 있습니다. 워커 번호와 서로 다른 스레드의 출력 순서는 고정하지 않습니다. 아래는 정상 경로에서 가능한 예상 출력의 한 예입니다.
$ cargo run
Compiling hello v0.1.0 (file:///projects/hello)
Finished dev [unoptimized + debuginfo] target(s) in 1.0s
Running `target/debug/hello`
Worker 0 got a job; executing.
Shutting down.
Shutting down worker 0
Worker 3 got a job; executing.
Worker 1 disconnected; shutting down.
Worker 2 disconnected; shutting down.
Worker 3 disconnected; shutting down.
Worker 0 disconnected; shutting down.
Shutting down worker 1
Shutting down worker 2
Shutting down worker 3이 예시에서는 워커 0과 3이 작업을 받습니다. 메인 스레드가 워커 0의 join을 기다리는 동안 워커 3이 대기 중인 작업을 시작할 수 있습니다. 워커 번호와 시작·완료 순서는 달라져도, 정리 과정은 다음과 같습니다.
drop(self.sender.take())로 새 작업 전송을 끝냅니다.- 워커는 이미 실행 중이거나 대기열에 남은
Job을 처리합니다. - 모든
Sender가 사라지고 대기열도 비면recv가Err를 반환해 워커 루프가 끝납니다. - 메인 스레드는 핸들을
take하여join하고, 각 워커가 종료할 때까지 기다립니다. 앞 워커를 기다리는 동안 다른 워커도 계속 처리할 수 있습니다.
위 코드에서 메인 스레드의 Shutting down worker 출력은 벡터 순서인 0~3을 따릅니다. 각 워커의 got a job과 disconnected 출력 순서까지 같은 순서가 되는 것은 아닙니다. 응답을 받았는지도 워커 번호가 아니라 요청한 경로의 상태와 본문으로 확인하세요.
| 요청 첫 줄 | 기대하는 응답 |
|---|---|
GET / HTTP/1.1 | 200, hello.html 내용 |
GET /sleep HTTP/1.1 | 5초 대기 뒤 200, hello.html 내용 |
| 그 밖의 첫 줄 | 404, 404.html 내용 |
5초는 sleep에 지정한 대기 시간입니다. 실제 응답까지는 스레드 스케줄링과 파일·소켓 I/O 시간도 더해질 수 있습니다.
서버가 정상 종료해 터미널 프롬프트가 돌아온 뒤 같은 주소로 새 요청을 보내면 이 서버에서는 응답받을 수 없습니다. 종료 중인지 이미 끝났는지부터 구분하고, 오류 문구는 운영체제와 클라이언트에 따라 달라질 수 있음을 기억하세요. 응답 전에 워커가 패닉했다면 파일의 위치와 읽기·쓰기의 unwrap을 먼저 확인하세요. Shutting down. 이후에도 끝나지 않는다면 워커가 요청 줄이나 쓰기 완료를 기다리는지 확인해야 합니다.
아래는 앞 절부터 이어 온 hello 패키지의 전체 코드입니다. src/main.rs는 서버 실행 코드, src/lib.rs는 그 서버가 사용하는 풀의 구현입니다. 프로젝트 루트의 hello.html과 404.html도 앞 절에서 만든 파일을 그대로 사용합니다.
| 파일·위치 | 이번 절의 누적 변경 |
|---|---|
src/main.rs의 main | 수신 반복에 .take(2)를 붙이고 루프 뒤 정리를 시작 |
src/main.rs의 handle_connection | 예제 19-10의 BufReader 첫 줄 읽기와 세 match 분기 유지 |
src/lib.rs | Sender·핸들을 Option으로 보관, Sender 먼저 drop, recv 오류로 루프 종료, 핸들을 take하여 join |
use hello::ThreadPool;
use std::fs;
use std::io::{prelude::*, BufReader};
use std::net::TcpListener;
use std::net::TcpStream;
use std::thread;
use std::time::Duration;
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
let pool = ThreadPool::new(4);
for stream in listener.incoming().take(2) {
let stream = stream.unwrap();
pool.execute(|| {
handle_connection(stream);
});
}
println!("Shutting down.");
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&mut stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
let (status_line, filename) = match &request_line[..] {
"GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
"GET /sleep HTTP/1.1" => {
thread::sleep(Duration::from_secs(5));
("HTTP/1.1 200 OK", "hello.html")
}
_ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
};
let contents = fs::read_to_string(filename).unwrap();
let response = format!(
"{}\r\nContent-Length: {}\r\n\r\n{}",
status_line,
contents.len(),
contents
);
stream.write_all(response.as_bytes()).unwrap();
stream.flush().unwrap();
}
use std::{
sync::{mpsc, Arc, Mutex},
thread,
};
pub struct ThreadPool {
workers: Vec<Worker>,
sender: Option<mpsc::Sender<Job>>,
}
type Job = Box<dyn FnOnce() + Send + 'static>;
impl ThreadPool {
/// Create a new ThreadPool.
///
/// The size is the number of threads in the pool.
///
/// # Panics
///
/// The `new` function will panic if the size is zero.
pub fn new(size: usize) -> ThreadPool {
assert!(size > 0);
let (sender, receiver) = mpsc::channel();
let receiver = Arc::new(Mutex::new(receiver));
let mut workers = Vec::with_capacity(size);
for id in 0..size {
workers.push(Worker::new(id, Arc::clone(&receiver)));
}
ThreadPool {
workers,
sender: Some(sender),
}
}
pub fn execute<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
let job = Box::new(f);
self.sender.as_ref().unwrap().send(job).unwrap();
}
}
impl Drop for ThreadPool {
fn drop(&mut self) {
drop(self.sender.take());
for worker in &mut self.workers {
println!("Shutting down worker {}", worker.id);
if let Some(thread) = worker.thread.take() {
thread.join().unwrap();
}
}
}
}
struct Worker {
id: usize,
thread: Option<thread::JoinHandle<()>>,
}
impl Worker {
fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
let thread = thread::spawn(move || loop {
let message = receiver.lock().unwrap().recv();
match message {
Ok(job) => {
println!("Worker {id} got a job; executing.");
job();
}
Err(_) => {
println!("Worker {id} disconnected; shutting down.");
break;
}
}
});
Worker {
id,
thread: Some(thread),
}
}
}
이 handler는 앞 절처럼 첫 요청 줄만 판단합니다. lines()가 줄 끝의 LF나 CRLF를 제거하므로 match에는 \r\n 없는 문자열을 씁니다. 첫 줄 없이 연결이 끝나면 첫 unwrap, 읽기나 UTF-8 변환에 실패하면 두 번째 unwrap에서 패닉합니다. contents.len()은 응답 본문의 UTF-8 바이트 수이며, 응답 전체를 write_all로 쓰는 흐름은 유지했습니다.
헤더·요청 본문·keep-alive나 HTTP 규격 전체를 처리하는 서버는 아닙니다. BufReader가 첫 줄 뒤의 바이트를 미리 읽어도 남은 버퍼는 버릴 때 사라집니다. 따라서 같은 연결의 다음 요청을 이어 읽는 구현으로 사용하지 마세요. 요청 줄의 길이 제한, 읽기·쓰기 timeout, 작업 대기열의 상한도 아직 없습니다.
Sender를 버리는 것은 대기 중인 소켓 읽기를 취소하지 않습니다. 클라이언트가 줄 끝을 보내지 않고 연결을 유지하면 워커가 계속 읽기를 기다려 join도 끝나지 않을 수 있습니다. 워커가 패닉하면 join().unwrap()도 패닉하므로 모든 응답의 성공이나 복구를 보장하지 않습니다. 여기서 다룬 것은 작업이 정상적으로 끝날 때 남은 작업과 스레드를 정리하는 흐름이며, 운영용 종료 신호·취소·시간 제한은 후속 설계가 필요합니다.
Tokio 런타임으로 확장하기
Tokio 확장은 선택 사항입니다. 지금까지는 고정된 OS 워커가 동기 I/O 작업을 처리했습니다. async 태스크의 실행·대기 흐름은 15장 4절의 첫 Tokio 예제에서 먼저 확인할 수 있습니다.
아래 코드는 비동기 소켓과 spawn의 모양을 보여 주는 별도 발췌입니다. 요청을 한 번 읽고 OK만 쓰며 읽은 길이와 I/O 오류를 처리하지 않습니다. 앞의 200/404 파일 응답, 두 연결 데모, 남은 작업을 기다리는 종료를 그대로 구현한 대체 코드는 아닙니다. 독립적으로 확장하려면 해당 기능과 Tokio 의존성·feature 준비를 따로 설계해야 합니다.
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:7878").await?;
loop {
let (mut socket, _addr) = listener.accept().await?;
tokio::spawn(async move {
let mut buf = [0; 1024];
let _ = socket.read(&mut buf).await;
let _ = socket
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK")
.await;
});
}
}- CPU 중심 작업:
std::thread,rayon같은 스레드/풀 모델이 유리할 수 있습니다. - I/O 중심 작업: Tokio 태스크 + 비동기 소켓/채널이 효율적입니다.
- 락이 필요한 공유 상태:
.await를 넘길 가능성이 있으면tokio::sync::Mutex를 우선 검토합니다. - 메시지 전달: 동기 코드 경계면은
std::sync::mpsc, 비동기 파이프라인은tokio::sync::mpsc를 주로 사용합니다.
Send/Sync 개념 자체는 15장 4절의 규칙이 그대로 적용되며,
최종 프로젝트 설계에서는 19장 2절의 스레드 풀 흐름과 비교해
어떤 모델이 요구사항에 맞는지 판단하면 됩니다.
여기에 더 많은 작업을 할 수도 있습니다!
이 프로젝트를 계속 개선하고 싶으시다면, 몇 가지 아이디어를 소개합니다.
ThreadPool과 그 공개 메서드에 문서를 더 추가해보세요.- 라이브러리 기능에 대한 테스트를 추가해보세요.
unwrap호출을 좀 더 견고한 에러 처리 형태로 바꿔보세요.ThreadPool을 사용하여 웹 요청을 처리하는 것 말고 다른 작업을 수행해보세요.- crates.io에서 스레드 풀 크레이트를 찾아보고 이 크레이트를 사용하여 비슷한 웹 서버를 구현해보세요. 그런 다음 이 API와 견고함을 우리가 구현한 스레드 풀과 비교해보세요.
최종 정리
마지막까지 읽었다면 Rust 프로젝트를 직접 구현하고 다른 사람의 Rust 코드도 더 수월하게 이해할 수 있습니다.
이후에는 공식 문서, 크레이트 문서, 커뮤니티 예제를 함께 보며 필요한 주제를 보완하면 됩니다.
마지막으로, 스레드 풀 모델에서 Tokio 런타임으로 확장할 때 무엇이 달라지는지 정리해 보세요.
스레드 풀과 async 런타임 책임 경계
Future의 poll→Pending→실행 양보→wake→다시 poll→Ready라는 대기 상태 변화가 blocking job의 워커 점유와 대비된다. std/tokio Mutex 선택 표는 전체 관계도와 표로 보존하고 async도 OS 스레드 위에서 실행한다는 조건을 유지한다.
RUST · 실행 자원과 대기
스레드는 작업을 실행하고, 런타임은 준비된 태스크를 poll한다
스레드 풀의 blocking 작업과 Tokio의 async 태스크는 대기할 때 실행 자원을 쓰는 방식이 다릅니다.
async 안의 blocking 호출도 OS 스레드를 막는다. std Mutex는 await 전 해제하고, tokio Mutex의 async 잠금과는 별도로 선택한다.
코드·조건·비교표와 보충 설명
OS 스레드 풀
작업 대기열 → 워커
고정 워커가 blocking job을 하나씩 받아 실행합니다.
작업이 끝날 때까지 스레드 사용
동기 I/O로 막히거나 sleep하면 그 워커도 기다립니다. 워커 수가 실행 중인 작업 수를 제한합니다.
Async 런타임
준비된 태스크 → poll
스케줄러가 준비된 태스크의 Future를 poll합니다. 태스크는 런타임의 OS 스레드 위에서 실행됩니다.
Pending → 양보 → wake → 다시 poll
await한 Future가 Pending이면 실행을 양보합니다. 준비되면 wake로 다시 poll 대상이 됩니다. Ready면 await에서 계속 진행할 수 있습니다.
짧은 데이터 임계 구역
std::sync::Mutex를 검토합니다. async 코드에서도 await 전에 잠금을 풀고 짧게 데이터를 다룬다면 사용할 수 있습니다.
await 너머 잠금이 필요한 I/O
tokio::sync::Mutex를 검토합니다. lock 획득은 async이며 guard를 await 동안 유지하도록 설계되어 있습니다.
async 태스크 안에서도 blocking 호출은 런타임 스레드를 막습니다. 태스크 수를 늘리는 것과 OS 워커 수를 늘리는 것은 같은 변화가 아닙니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| poll 호출 준비된 task의 Future를 poll한다. |
|
|
| Pending 양보 준비되지 않은 Future가 Pending을 돌려준다. |
|
|
| wake·재 poll 깨움 뒤 다시 poll해 Ready이면 await 이후로 간다. |
|
|
스레드 풀과 async 런타임 책임 경계
Future의 poll→Pending→실행 양보→wake→다시 poll→Ready라는 대기 상태 변화가 blocking job의 워커 점유와 대비된다. std/tokio Mutex 선택 표는 전체 관계도와 표로 보존하고 async도 OS 스레드 위에서 실행한다는 조건을 유지한다.
RUST · 실행 자원과 대기
스레드는 작업을 실행하고, 런타임은 준비된 태스크를 poll한다
스레드 풀의 blocking 작업과 Tokio의 async 태스크는 대기할 때 실행 자원을 쓰는 방식이 다릅니다.
async 안의 blocking 호출도 OS 스레드를 막는다. std Mutex는 await 전 해제하고, tokio Mutex의 async 잠금과는 별도로 선택한다.
코드·조건·비교표와 보충 설명
OS 스레드 풀
작업 대기열 → 워커
고정 워커가 blocking job을 하나씩 받아 실행합니다.
작업이 끝날 때까지 스레드 사용
동기 I/O로 막히거나 sleep하면 그 워커도 기다립니다. 워커 수가 실행 중인 작업 수를 제한합니다.
Async 런타임
준비된 태스크 → poll
스케줄러가 준비된 태스크의 Future를 poll합니다. 태스크는 런타임의 OS 스레드 위에서 실행됩니다.
Pending → 양보 → wake → 다시 poll
await한 Future가 Pending이면 실행을 양보합니다. 준비되면 wake로 다시 poll 대상이 됩니다. Ready면 await에서 계속 진행할 수 있습니다.
짧은 데이터 임계 구역
std::sync::Mutex를 검토합니다. async 코드에서도 await 전에 잠금을 풀고 짧게 데이터를 다룬다면 사용할 수 있습니다.
await 너머 잠금이 필요한 I/O
tokio::sync::Mutex를 검토합니다. lock 획득은 async이며 guard를 await 동안 유지하도록 설계되어 있습니다.
async 태스크 안에서도 blocking 호출은 런타임 스레드를 막습니다. 태스크 수를 늘리는 것과 OS 워커 수를 늘리는 것은 같은 변화가 아닙니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| poll 호출 준비된 task의 Future를 poll한다. |
|
|
| Pending 양보 준비되지 않은 Future가 Pending을 돌려준다. |
|
|
| wake·재 poll 깨움 뒤 다시 poll해 Ready이면 await 이후로 간다. |
|
|
우아한 종료와 정리 학습 판단 기준표
이 다이어그램은 우아한 종료와 정리 내용을 예제 코드에 적용할 때 먼저 볼 조건과 나중에 검증할 조건을 나눕니다.