ข้าม​ไป​ยัง​เนื้อหา

Concurrency — ThreadPool ให้​บริการ​หลาย client ด้วย Arc<Mutex>/RwLock

บท1 ปิด​ท้าย​ด้วย​หนี้​ก้อน​หนึ่ง​ที่​เรา​สัญญา​ไว้​ว่า​จะ​จ่าย​คืน: server for stream in listener.incoming() รับ client ที​ละ​ราย — ตราบ​ใด​ที่​ราย​หนึ่ง​ยัง​ไม่​วาง​สาย ที่​เหลือ​ต้อง​รอ client ที่​ช้า​เพียง​คน​เดียว block ทุก​คน บท​นี้​จ่าย​คืน​หนี้​นั้น: เปลี่ยน serve loop แบบ​เรียง​คิว​ให้​เป็น thread poolthread poolกลุ่ม worker thread คงที่​ที่​ดึง​งาน​จาก channel มาทำ ที่​กระจาย​การ​เชื่อม​ต่อ​ไป​ให้ worker หลาย​ตัว โดย​ทั้งหมด share store ก้อน​เดียว ผ่าน Arc<Mutex<…>> หรือ Arc<RwLock<…>>

ใน​แผนที่​แนวคิด storage engine นี่​คือ​ชั้น thread-per-request pool + concurrency control — จุด​ที่​ตำรา​ฐาน​ข้อมูล (Petrov) และ final project ของ​หนังสือ The Rust Programming Language บรรจบ​กัน โครง ThreadPool ที่​เรา​สร้าง​ใน​บท​นี้​คือ code จาก​หนังสือ บท 21 ตรงๆ ส่วน​วินัย​การ​ถือ lock ให้​แคบ​คือ​สิ่ง​ที่​ตำรา​ฐาน​ข้อมูล​เรียก​ว่า concurrency control #21 สอน Arc/Mutex/mpsc เบื้องต้น​มา​แล้ว — บท​นี้​จะ​ไป ลึก​กว่า​นั้น: RwLock, ขอบเขต​ของ lock, และ poisoning

📦 kaen-kvstore

code ลงมือ​ของ​คอร์ส​นี้​อยู่​ใน repo kaen-kvstore (code ตัวอย่าง​กำลัง​จัด​ทำ) — ตลอด 8 บท​เรา​สร้าง key-value store บน​เครือข่าย​ที่​กู้​คืน​จาก crash ได้ หนึ่ง​ตัว ด้วย Rust std ล้วน (ไม่มี async/tokio, ไม่มี serde) บท​นี้​แทนที่ serve loop แบบ single-threaded ของ​บท1 ด้วย ThreadPool (หนังสือ​บท 21) ที่ share store ผ่าน Arc<Mutex>/Arc<RwLock> — persistence/durability/compaction จาก​บท2–4 ยัง​อยู่​ครบ เรา​แค่​เพิ่ม​ความ​สามารถ​รับ​หลาย client พร้อม​กัน

ทาง​ที่​ง่าย​ที่สุด​ใน​การ​รับ​หลาย client คือ thread::spawn หนึ่ง​ตัว​ต่อ​หนึ่ง​การ​เชื่อม​ต่อ แต่​ถ้า​เปิดรับตรงๆ แบบ​นั้น client 10,000 ราย​ก็​คือ 10,000 thread — แต่ละ​ตัว​กิน stack เป็น MB บวก​ภาระ context switch เครื่อง​จะ​ล้ม​ด้วย​ภาระ​ของ​ตัว​มัน​เอง thread pool คือ​กลุ่ม worker thread จำนวน​คงที่ ที่​ดึง​งาน​จาก channel มา​ทำที​ละ​ชิ้น — เพดาน​ของ thread ถูก​กำหนด​ไว้​ล่วงหน้า ทำให้​ทรัพยากร​คาด​เดา​ได้ นี่​คือ​โครง​ที่​หนังสือ The Rust Programming Language บท 21 (§21.2) สร้าง​ขึ้น​เป็น final project และ​เรา​หยิบ​มาตรงๆ

หัวใจ​มี​สาม​ชิ้น: งาน​หนึ่ง​ชิ้น​คือ closure ที่ boxed ไว้ — type Job = Box<dyn FnOnce() + Send + 'static> (FnOnce เพราะ​รัน​ครั้ง​เดียว, Send เพราะ​ข้าม thread, 'static เพราะ worker อาจ​รัน​มัน​เมื่อไร​ก็ได้); Vec<Worker> เก็บ handle ของ thread; และ mpsc::Receiver ตัว​เดียว ที่ worker ทุก​ตัว​ต้อง​แย่ง​กัน​ดึง​งาน จึง​ต้อง​ห่อ​ด้วย Arc<Mutex<Receiver>> (หลาย​เจ้าของ + กัน​เข้าถึง​พร้อม​กัน)

use std::sync::{Arc, Mutex, mpsc};
use std::thread::{self, JoinHandle};
type Job = Box<dyn FnOnce() + Send + 'static>;
pub struct ThreadPool {
workers: Vec<Worker>,
sender: Option<mpsc::Sender<Job>>, // Option เพื่อ take() ตอน shutdown
}
impl ThreadPool {
pub fn new(size: usize) -> ThreadPool {
assert!(size > 0);
let (sender, receiver) = mpsc::channel();
let receiver = Arc::new(Mutex::new(receiver)); // Receiver ตัวเดียว share ทุก worker
let mut workers = Vec::with_capacity(size);
for _ in 0..size {
workers.push(Worker::new(Arc::clone(&receiver)));
}
ThreadPool { workers, sender: Some(sender) }
}
pub fn execute<F>(&self, f: F)
where
F: FnOnce() + Send + 'static,
{
self.sender.as_ref().unwrap().send(Box::new(f)).unwrap();
}
}

worker แต่ละ​ตัว​วน loop ดึง​งาน​จาก channel ที่ share กัน — และ​ตรง​นี้​มี​กับดัก​ที่​หนังสือ​บท 21 เตือน​ไว้​เป็น​พาด​หัว code ที่ ถูก ต้อง​ให้ receiver.lock().unwrap().recv() เป็น expression เดียว:

struct Worker {
thread: Option<JoinHandle<()>>,
}
impl Worker {
fn new(receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
let thread = thread::spawn(move || loop {
// expression เดียว: MutexGuard ถูก drop ที่ `;` -> ปล่อย lock ก่อน job() รัน
let message = receiver.lock().unwrap().recv();
match message {
Ok(job) => job(),
Err(_) => break, // sender ถูก drop -> ช่องปิด -> ออกจาก loop (graceful shutdown)
}
});
Worker { thread: Some(thread) }
}
}

เหตุ​ที่​มัน​ต้อง​เป็น​บรรทัด​เดียว​คือ​เรื่อง​ของ จังหวะ​ที่ MutexGuard ถูก drop เมื่อ receiver.lock().unwrap().recv() จบ​เป็น statement ที่ ; ตัว temporary guard ที่​ค้ำ .lock() ไว้​จะ​ถูก drop ทันที — lock ถูก​ปล่อย​คืน ก่อน ที่ job() จะ​เริ่ม​ทำงาน worker ตัว​นี้​จึง​ถือ lock แค่​ชั่ว​วินาที​ที่​ดึง​งาน​ออก​มา แล้ว worker ตัว​อื่น​ก็​ดึง​งาน​ถัด​ไป​ได้​ขนาน​กัน​ทันที

ถ้า​เผลอ​เขียน​เป็น while let Ok(job) = receiver.lock().unwrap().recv() { job(); } แทน — code compile ผ่าน​สวยงาม แต่​กฎ scope ของ while let ทำให้ guard มี​ชีวิต ตลอด​ทั้ง body ของ loop รวม​ถึง​ระหว่าง job() รัน​ด้วย ผล​คือ worker ตัว​ที่​ถือ lock อยู่​จะ​กัน worker อื่น​ทุก​ตัว​ไว้​จน job() เสร็จ — มี worker เดียว​เท่านั้น​ที่​ทำงาน​จริง​ตลอด​เวลา thread pool กลาย​เป็น single-threaded อย่าง​เงียบๆ นี่​คือ​กับดัก​คลาสสิก​ที่ compiler ไม่​เตือน เพราะ​มัน​ไม่ใช่ error ด้าน memory safety

ทีนี้ worker หลาย​ตัว​ต้อง​แตะ store ก้อน​เดียวกัน ทาง​เลือก​มี​สอง แบบ​แรก​คือ Arc<Mutex<HashMap<…>>> — ตรง​ไป​ตรง​มา ทุก​การ​เข้าถึง (อ่าน​หรือ​เขียน) ผูกขาด lock ที​ละ​คน แบบ​ที่​สอง​คือ RwLockRwLocklock ที่​ให้​ผู้​อ่าน​หลาย​คน​พร้อม​กัน แต่​ผู้​เขียน​ต้อง​ผูกขาด — lock ที่​แยก​ผู้​อ่าน​กับ​ผู้​เขียน​ออก​จาก​กัน: .read() ให้ ผู้​อ่าน​หลาย​คน​ถือ lock พร้อม​กัน​ได้ ตราบ​ใด​ที่​ไม่มี​ใคร​เขียน ส่วน .write() ต้อง​ผูกขาด​เดี่ยวๆ สำหรับ key-value store ที่ อ่าน​มากกว่า​เขียน — ซึ่ง​เป็น​ภาระ​งาน​ที่​พบ​บ่อย​ที่สุด — RwLock ให้​ผู้​อ่าน​ขนาน​กัน​ได้​จริง เป็น​กำไร​ด้าน throughput ที่ Mutex ให้​ไม่​ได้ ส่วน Mutex ยัง​คง​เป็น​ค่า​ปริยาย​ที่​ง่าย​กว่า​เมื่อ​สัดส่วน​อ่าน/เขียนพอๆ กัน

โครง handler ต่อ​หนึ่ง​การ​เชื่อม​ต่อ​จึง​เป็น​แบบ​นี้ (store: Store ถูก Arc::clone เข้า​มา​ต่อ connection ก่อน​ส่ง​เข้า pool.execute(move || …)):

use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::sync::{Arc, RwLock};
type Store = Arc<RwLock<HashMap<String, String>>>;
fn handle_connection(mut stream: TcpStream, store: Store) {
let mut op = [0u8; 1];
while stream.read_exact(&mut op).is_ok() {
let key = read_lp(&mut stream); // blocking I/O — ยังไม่ถือ lock
let reply = match op[0] {
0 => store.read().unwrap().get(&key).cloned(), // อ่านแบบ share clone ค่าออกมา
1 => { let val = read_lp(&mut stream); // parse ให้เสร็จ "ก่อน" ถือ lock
store.write().unwrap().insert(key, val); None } // เขียนผูกขาด scope แคบสุด
_ => { store.write().unwrap().remove(&key); None }
}; // guard ทุกตัวถูก drop ตรงนี้ ก่อนจะเขียนตอบกลับ socket ข้างล่าง
match reply {
Some(v) => { let _ = stream.write_all(v.as_bytes()); } // เขียน socket โดยไม่ถือ lock
None => { let _ = stream.write_all(b"\0"); }
}
}
}

สังเกต​วินัย​ใน handler ข้าง​บน​ให้​ดี — มัน​คือ​หัวใจ​ของ​บท​นี้: อ่าน​และ parse ทุก​อย่าง​ที่ block ได้ นอก lock, ถือ lock แค่​ตอน​แตะ map ใน scope ที่​แคบ​ที่สุด, แล้ว clone ค่า​ออก​มา เพื่อ​ให้ guard ถูก drop ก่อน​ที่​เรา​จะ​เขียน​ตอบ​กลับ socket

เหตุผล​อยู่​ที่ hook ที่​ต้อง​จำ: code ที่​ถือ guard คร่อม socket I/O compile ผ่าน​สะอาด borrow checker ไม่มี​ทาง​ช่วย​คุณ เพราะ​มัน​ไม่ใช่ data race — มัน​คือ bug เชิง​ประสิทธิภาพ นี่​คือ ❌ version ดิบ​ที่​ต้อง​หลีก​เลี่ยง:

// ❌ version ดิบ: ถือ write guard คร่อมการเขียน socket ที่ block
fn handle_bad(mut stream: TcpStream, store: Store) {
let mut op = [0u8; 1];
while stream.read_exact(&mut op).is_ok() {
let mut map = store.write().unwrap(); // ถือ lock ผูกขาด...
let value = map.entry("k".to_string()).or_default().clone();
// ...แล้วยังถืออยู่ตลอดเวลาที่ block รอ socket ซึ่งอาจนานแค่ไหนก็ได้:
let _ = stream.write_all(value.as_bytes());
} // guard เพิ่งถูก drop ตรงนี้ — ระหว่างนั้น worker อื่นทุกตัวถูกกันไว้หมด
}

code นี้ compile ผ่าน exit 0 — เรา​ตรวจ​แล้ว compiler เห็น​ว่า​ไม่มี​การ​เข้าถึง​หน่วย​ความ​จำ​ที่​ไม่​ปลอดภัย จึง​เงียบ แต่​ผลลัพธ์​ตอน runtime คือ​หายนะ: ถ้า client ราย​หนึ่ง​เน็ต​ช้า​หรือ​หยุด​อ่าน write_all จะ block ค้าง และ​เพราะ write guard ยัง​ถูก​ถือ​อยู่ worker ทุก​ตัว ที่​ต้องการ​แตะ store ก็​ค้าง​ตาม​ไป​ด้วย — thread pool ทั้ง​กอง​กลาย​เป็น​เรียง​คิว​หลัง client ช้า​เพียง​ราย​เดียว ซึ่ง​เป็น​ปัญหา เป๊ะ กับ​ที่​บท1 บอกว่า​จะ​แก้ compiler พิสูจน์​ความ​ปลอดภัย​เชิง memory/thread ให้​ได้ แต่ ไม่​พิสูจน์​ความ​ถูกต้อง​เชิง concurrency ระดับ​สถาปัตยกรรม ให้ — วินัย​การ​ถือ lock ให้​แคบ​เป็น​ความ​รับผิดชอบ​ของ​เรา ไม่ใช่​ของ borrow checker

ถ้า worker ตัว1 panic ระหว่าง​ถือ lock อยู่ Rust จะ​ทำ​เครื่องหมาย lock นั้น​ว่า poisoned — เพราะ​ข้อมูล​ข้าง​ใน​อาจ​ถูก​แก้​ค้างไว้ครึ่งๆ กลางๆ ครั้ง​ต่อ​ไป​ที่​ใคร​เรียก .lock()/.write() จะ​ได้ Err(PoisonError) กลับ​มา จุด​ที่​ต้อง​รู้​คือ ความ​ไม่​สมมาตร​ระหว่าง Mutex กับ RwLock: Mutex poison เมื่อ​มี panic ขณะ​ถือ lock แบบ​ใด​ก็ตาม ส่วน RwLock poison เฉพาะ​เมื่อ panic เกิด​ขณะ​ถือ write lock เท่านั้น — panic ใน​ฝั่ง​ผู้​อ่าน​ไม่​ทำให้ lock เป็น​พิษ (ผู้​อ่าน​ไม่​ได้​แก้​ข้อมูล จึง​ไม่​ทิ้ง​สถานะครึ่งๆ กลางๆ)

สำหรับ server ที่​ต้อง​อยู่​รอด เรา​มัก​ไม่​อยาก​ให้ worker ตัว​เดียว panic แล้ว​ล้ม​ทั้ง​ระบบ​ตาม — invariant ของ HashMap ที่​เป็น store ธรรมดา​มัก​รอด panic ได้​อยู่​แล้ว เรา​จึง กู้ lock ที่​เป็น​พิษ​ด้วย unwrap_or_else(|e| e.into_inner()) ซึ่ง​ดึง​ค่า​ข้าง​ใน​กลับ​มา​ใช้​ต่อ แทนที่​จะ .unwrap() ที่​จะ panic ซ้ำ​แล้ว​ลาม:

use std::sync::{Arc, Mutex};
use std::collections::HashMap;
// getter ที่ทน poison: ให้บริการต่อได้แม้ worker ตัวก่อนหน้าจะ panic
fn get(store: &Arc<Mutex<HashMap<String, String>>>, key: &str) -> Option<String> {
let map = store.lock().unwrap_or_else(|e| e.into_inner()); // กู้ lock ที่เป็นพิษ
map.get(key).cloned() // clone ออก guard drop หลังจากนั้น
}
RwLock ไม่ใช่​ยา​วิเศษ — ระวัง reader reacquire และ​ลำดับ​ความ​สำคัญ

RwLock ให้​ผู้​อ่าน​ขนาน​กัน​ได้​ก็​จริง แต่​มี​มุมมืด​สอง​ข้อ​จาก​เอกสาร std ที่​ต้อง​รู้: (1) ลำดับ​ความ​สำคัญ reader/writer ขึ้น​กับ​ระบบ​ปฏิบัติการ — มาตรฐาน​ไม่​รับประกัน​ว่า writer ที่​รอ​อยู่​จะ​ได้​คิว​ก่อน reader ที่มา​ทีหลัง ถ้า reader หลั่งไหล​ไม่​หยุด writer อาจอด​ตาย (writer starvation) บน​บาง platform (2) การ​ขอ read lock ตัว​ที่​สอง​บน thread เดิม​ขณะ​ที่ writer กำลัง​รอ​คิว​อยู่ อาจ deadlock ได้ (เอกสาร std ยก​ตัวอย่าง​ไว้​เอง) — อย่า​ถือ read guard ค้าง​แล้ว​ขอ read เพิ่ม​บน thread เดียวกัน วินัย​เดิม​ยัง​ใช้ได้: ถือ lock ใน scope ที่​แคบ​ที่สุด แล้ว​ปล่อย

ตอน​ปิด server เรา​อยาก​ให้ worker ทุก​ตัว​ทำงาน​ที่​ค้าง​อยู่​ให้​จบ​ก่อน​จาก​ไป ไม่ใช่​โดน​ตัด​กลางคัน กลไก​คือ impl Drop for ThreadPool: ปิด channel ก่อน แล้ว​ค่อย joindrop(self.sender.take()) ทำให้ Sender หาย​ไป ช่อง​ส่ง​งาน​ปิด ทุก recv() ที่ worker รอ​อยู่​จึง​คืน Err แล้ว​ออก​จาก loop; จาก​นั้น​วน join() ทีละ worker ให้ thread จบ​สนิท (ทั้ง sender และ thread เป็น Option เพื่อ take() ค่า​ออก​มา​ได้​ระหว่าง drop):

impl Drop for ThreadPool {
fn drop(&mut self) {
drop(self.sender.take()); // ปิด channel -> ทุก recv() คืน Err
for worker in &mut self.workers {
if let Some(thread) = worker.thread.take() {
thread.join().unwrap(); // รอ worker ทำงานค้างให้จบแล้วเก็บ thread
}
}
}
}

รัน​จริง​บน musl: สร้าง pool 4 worker share Arc<Mutex<HashMap<String, String>>> ป้อน​งาน insert 12 ชิ้น แล้ว​ปล่อย​ให้ pool drop (graceful shutdown) ก่อน​อ่าน map:

OK: 12 keys across 4 workers, graceful shutdown clean
first="key-0" last="key-9"

ครบ 12 key ไม่มี​ตกหล่น (ไม่มี lost update) และ pool.join คืน​ค่า​โดย​ไม่​ค้าง — worker ทั้ง​สี่​แบ่ง​งาน​กัน​ทำ​จริง แล้ว​ปิด​ตัว​สะอาด​เมื่อ channel ถูก​ปิด

flowchart LR
    L["TcpListener :incoming()"] -->|"execute(job)"| CH["mpsc channel<br/>(Arc Mutex Receiver)"]
    CH --> W1["Worker 1"]
    CH --> W2["Worker 2"]
    CH --> W3["Worker N"]
    W1 --> S["store ที่ share<br/>Arc RwLock HashMap"]
    W2 --> S
    W3 --> S

คำ​บรรยาย​ภาพ: ThreadPool — listener ป้อน​งาน​ผ่าน channel เดียว (Receiver ห่อ​ด้วย Arc<Mutex<…>>) ให้ worker จำนวน​คงที่​แย่ง​กัน​ดึง​ไป​ทำ แต่ละ worker แตะ store ก้อน​เดียวกัน​ที่ share ผ่าน Arc<RwLock<HashMap>> โดย​ถือ lock ใน scope ที่​แคบ​ที่สุด

thread pool คือ​คำ​ตอบ​ที่​ถูก — จนกว่า​จำนวน connection จะ​เป็น​คอ​ขวด

model thread-per-request + ThreadPool + Arc<Mutex/RwLock> นี้ ถูกต้อง​และ​ง่าย — มัน​คือ final project ของ​หนังสือ​บท 21 ตรงๆ มัน​หยุด​สเกล​ตอน​ชน​กำแพง C10k (connection ที่​ส่วน​ใหญ่ idle นับ​หมื่น) เพราะ​แต่ละ OS thread กิน stack เป็น MB บวก​ภาระ context switch หลัก​คิด​คือ “thread เป็น​ค่า​ปริยาย​ที่​ถูกต้อง​จนกว่า​จำนวน connection — ไม่ใช่ CPU — จะ​กลาย​เป็น​คอ​ขวด”

อีก​จุด​ที่​ต้อง​พูด​ตรง: mpsc::channel() เป็น channel แบบ ไม่​จำกัด​ความ​จุ (unbounded) — ไม่มี backpressure ถ้า​งาน​เข้า​เร็ว​กว่า​ที่ worker ทำได้ คิว​จะ​บวม​กิน​หน่วย​ความ​จำ​ไป​เรื่อยๆ channel เดียว​ใน std ที่​มี​ขอบเขต​คือ sync_channel(n) ซึ่ง​จะ block ฝั่ง​ส่ง เมื่อ​คิว​เต็ม (นั่น​คือ​คือ backpressure) ส่วน backpressure แบบ async ที่แท้​จริง​เป็น​เรื่อง​ของ runtime แยก — เลื่อน​ไป​คอร์ส #23 การ ตั้ง​ชื่อ​เส้น​แบ่ง นี้​ให้​ชัด​คือ​บทเรียน ไม่ใช่​ช่อง​โหว่

บท​นี้​จ่าย​คืน​หนี้​จาก​บท1: เรา​สร้าง ThreadPool ของ​หนังสือ​บท 21 (Job = Box<dyn FnOnce() + Send + 'static>, Vec<Worker>, Receiver เดียว​ห่อ Arc<Mutex<…>>) โดย​ระวัง​กับดัก​ว่า receiver.lock().unwrap().recv() ต้อง​เป็น expression เดียว​เพื่อ​ให้ guard drop ก่อน job() รัน; share store ด้วย Arc<Mutex> (ค่า​ปริยาย​ที่​ง่าย) หรือ Arc<RwLock> (ผู้​อ่าน​หลาย​คน​พร้อม​กัน​เมื่อ​อ่าน​มากกว่า​เขียน); ยึด​วินัย lock scope — อ่าน/parse นอก lock แล้ว mutate ใต้ lock ใน scope แคบ​สุด เพราะ hook สำคัญ​คือ​การ​ถือ guard คร่อม socket I/O compile ผ่าน​แต่​ทำให้​ทุก worker ต่อ​คิว compiler ไม่​ช่วย; กู้ lock ที่​เป็น​พิษ​ด้วย into_inner() (จำ​ความ​ไม่​สมมาตร: RwLock poison เฉพาะ writer panic); และ​ปิด​สะอาด​ผ่าน Drop ที่ drop sender ก่อน​แล้ว join ทุก worker ทุก snippet compile และ​รัน​ได้​จริง​บน Rust 1.97.1 / edition 2024 / std ล้วน

บท6 เรา​พิสูจน์​การ​กู้​คืน​จาก​การ​ล่ม: ตอน​นี้ store รับ​หลาย client และ durable แล้ว (บท3) — บท​หน้า​เรา​จะ​เปลี่ยน​คำ​กล่าว​อ้าง​เรื่อง durability ให้​เป็น การ​ทดสอบ​อัตโนมัติ ด้วย cargo test จำลอง crash จริง​ด้วย SIGKILL (ที่​ไม่​รัน destructor เลย) แล้ว reopen, replay, ยืนยัน​ว่า​ได้​สถานะ​เดิม​เป๊ะ


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

  • The Rust Programming Language — §21.2 “Turning Our Single-Threaded Server into a Multithreaded Server” (เข้าถึง 2026-07-24) — โครง ThreadPool (Job = Box<dyn FnOnce()…>, Vec<Worker>, Receiver ห่อ Arc<Mutex<…>>) และ​คำ​เตือน​พาด​หัว​ว่า lock/recv ต้อง​เป็น expression เดียว
  • The Rust Programming Language — §21.3 “Graceful Shutdown and Cleanup” (เข้าถึง 2026-07-24) — Option<Sender> + Option<JoinHandle>, Drop ที่ drop sender ก่อน​แล้ว join ทุก worker
  • std sync::RwLock (เข้าถึง 2026-07-24) — ผู้​อ่าน​หลาย​คน/ผู้​เขียน​เดี่ยว, poison เฉพาะ writer panic, ลำดับ​ความ​สำคัญ​ขึ้น​กับ OS, ตัวอย่าง reader-reacquire deadlock
  • std sync::Mutex (เข้าถึง 2026-07-24) — poisoning และ PoisonError::into_inner เพื่อ​กู้ lock ที่​เป็น​พิษ
  • std sync::mpsc (เข้าถึง 2026-07-24) — channel() แบบ unbounded (ไม่มี backpressure) เทียบ​กับ sync_channel(n) ที่ block ฝั่ง​ส่ง

เช็กความเข้าใจ — บทที่ 5

ข้อ 1 / 3

ทำไม receiver.lock().unwrap().recv() ในตัว worker ต้องเป็น expression เดียว (ไม่ใช่ while let Ok(job) = receiver.lock().unwrap().recv())?