Fearless concurrency — thread, Arc<Mutex<T>>, channel และการปิด project
ถ้าคุณมาจากสาย C#/.NET การเขียน concurrent code ของคุณคือ reflex เดียว: “await มันสิ” — Task, async/await, threadpool ในตัว และ lock(obj) { … } เวลาต้อง share ข้อมูล คุณเขียนได้เร็ว แต่ ไม่มีใครกันคุณจากการลืมใส่ lock แล้วเจอ data race ตอน production Rust เดินคนละทาง: มันเรียกสิ่งนี้ว่า fearless concurrency — ไม่ใช่เพราะมันเขียนง่ายกว่า แต่เพราะ compiler พิสูจน์ ว่าไม่มี data race ตั้งแต่ตอน compile ถ้า code คุณจะแข่งกันแก้ข้อมูล มันจะ ไม่ยอม compile ให้เลย
บทนี้เป็นบทปิดคอร์ส เราจะเอา ownership/borrowing จากบท2 มาต่อยอดข้าม thread แล้วปิด taskcli ด้วยตัวประมวลผลแบบขนานจริงๆ ทั้งหมดนี้ด้วย std::thread + std::sync ล้วนๆ ตามคำสัญญาที่เราให้ไว้ตั้งแต่บท1 — ไม่แตะ async ทั้งกอง และตอนท้ายเราจะอธิบายตรงๆ ว่าทำไม
code ลงมือของบทนี้อยู่ใน repo kaen-taskcli (code ตัวอย่างกำลังจัดทำ) — บทนี้ต่อ taskcli จากบท7 (clap + serde + module split) ด้วย feature สุดท้าย: ตัวนับความถี่คำแบบ ขนาน ที่กระจายงานลงหลาย thread แล้วรวมผลใต้ล็อกเดียว concurrency ไม่เพิ่ม crate ใหม่แม้แต่ตัวเดียว — Cargo.toml ยังเป็นชุดเดิมของบท7 ทุกอย่างในบทนี้อยู่ใน std::thread + std::sync
จาก thread เดียวสู่ข้าม thread: Rc→Arc, RefCell→Mutex
หัวข้อที่มีชื่อว่า “จาก thread เดียวสู่ข้าม thread: Rc→Arc, RefCell→Mutex”ก่อนจะ spawn thread แรก ต้องเข้าใจ สะพาน สองคู่ก่อน ในโลก thread เดียว Rust มี smart pointer สองตัวที่คุณอาจเคยเห็นผ่านๆ:
Rc<T>— reference-counted pointer ให้ค่าก้อนเดียว มีเจ้าของหลายคน ได้ (นับ reference แล้ว drop เมื่อนับถึงศูนย์) แต่ตัวนับเป็นแบบ ไม่ atomic — เร็ว แต่ไม่ปลอดภัยข้าม threadRefCell<T>— ให้ interior mutability คือแก้ข้อมูลผ่าน shared reference ได้ โดยเลื่อนการตรวจ borrow rule ไปเป็น ตอน runtime แทน compile time
พอข้าม thread สองตัวนี้ถูกแทนด้วยคู่แฝดที่ปลอดภัยกว่า:
Rc<T>→ ArcArcตัวนับอ้างอิงแบบ atomic share ความเป็นเจ้าของข้าม thread ได้ (Send+Sync ต่างจาก `Rc`)<T>— Atomically Reference Counted ตัวนับใช้ atomic operation จึง share ความเป็นเจ้าของข้าม thread ได้จริงRefCell<T>→ MutexMutexล็อกที่ *เป็นเจ้าของ* ข้อมูล เข้าถึงได้ผ่าน guard เท่านั้น; ปลดล็อกอัตโนมัติเมื่อ drop<T>— บังคับ “ผู้เข้าถึงได้ทีละคน” ด้วยล็อกจริง แทนการนับ borrow ใน RefCell
นี่คือที่มาของสำนวนที่คุณจะเห็นตลอดบทนี้: Arc<Mutex<T>> — 2 wrapper ซ้อนกัน Arc ตอบคำถาม “ใครเป็นเจ้าของ” (หลาย thread ร่วมกัน) ส่วน Mutex ตอบคำถาม “ใครแก้ได้ตอนนี้” (ทีละ thread) นี่แหละคือ ต้นทุนของการไม่มี GC ที่มองเห็นได้ชัดที่สุด — ใน C# ที่ GC จัดการ lifetime ให้ คุณแค่ share object reference แล้วใส่ lock แต่ Rust ไม่มี GC มาต่ออายุค่าให้ คุณจึงต้องบอก compiler ให้ชัดทั้งสองแกน: การเป็นเจ้าของร่วม (Arc) และ การแก้ไขแบบกันชน (Mutex) แยกกันคนละชั้น
reflex ของ C# คือ lock(obj) { obj.Field = x; } — แต่ obj ยัง เข้าถึงได้ โดยไม่ต้องถือ lock ใครลืม lock ก็แก้ข้อมูลชนกันได้ ไม่มีอะไรกัน ส่วน Mutex<T> ของ Rust เป็นเจ้าของ ข้อมูลนั้นจริงๆ — ข้อมูลอยู่ ข้างใน Mutex และคุณ เข้าไม่ถึงเลย จนกว่าจะเรียก lock() สำเร็จ การลืมล็อกจึงไม่ใช่ “bug ที่ต้องระวัง” แต่เป็นสิ่งที่ เขียนออกมาไม่ได้ ตั้งแต่แรก
thread::spawn: closure ต้อง move เพราะ bound ‘static
หัวข้อที่มีชื่อว่า “thread::spawn: closure ต้อง move เพราะ bound ‘static”สร้าง thread ใหม่ด้วย thread::spawnthread::spawnสร้าง OS thread ใหม่; closure ต้อง `move` เป็นเจ้าของทุกอย่างที่แตะ (bound `'static`) ซึ่งรับ closureclosurefunction นิรนามที่จับตัวแปรรอบข้าง; ชนิดเป็น `Fn`/`FnMut`/`FnOnce` ตามการใช้ค่า ไปรันบน OS thread ใหม่ กติกาสำคัญ: closure ที่ส่งให้ spawn ถูก bound ด้วย 'static — แปลว่ามัน ต้องเป็นเจ้าของทุกอย่างที่แตะ เพราะ thread ใหม่อาจอยู่นานกว่า scope ที่สร้างมัน compiler จึงไม่ยอมให้ closure ยืม (&) ตัวแปรที่อาจหายไปก่อน วิธีบอกว่า “ยกความเป็นเจ้าของเข้าไปใน thread เลย” คือ keyword movemoveการย้ายความเป็นเจ้าของ; ตัวแปรเดิมใช้ต่อไม่ได้ (ต่างจากการ copy อ้างอิงใน C#) หน้า closure
ทีนี้ปัญหาโผล่ทันที: ถ้าหลาย thread ต้องใช้ค่าเดียวกัน แต่ move ยกความเป็นเจ้าของให้ thread แรกไปแล้ว thread ที่สองจะเอาที่ไหน? คำตอบคือ Arc — clone มันหนึ่งอันต่อ thread (Arc::clone แค่เพิ่มตัวนับ ไม่ได้ copy ข้อมูลจริง) นี่คือสำนวนบัญญัติของ Arc<Mutex<T>> — 10 thread ช่วยกันบวกตัวนับทีละหนึ่ง:
use std::sync::{Arc, Mutex};use std::thread;
fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = Vec::new(); for _ in 0..10 { let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { let mut num = counter.lock().unwrap(); *num += 1; })); } for handle in handles { handle.join().unwrap(); } println!("Result: {}", *counter.lock().unwrap());}รันแล้วได้ Result: 10 ทุกครั้ง ไม่มีการสุ่มผลจาก race สังเกตสามจุด: (1) Arc::clone(&counter) เขียน ชัดๆ แบบนี้เสมอ ไม่ใช่ counter.clone() — เพื่อให้คนอ่านรู้ว่านี่คือการเพิ่มตัวนับ ราคาถูก ไม่ใช่ deep copy; (2) move ยก Arc ที่ clone แล้วเข้าไปในแต่ละ thread; (3) handle.join() คืน Result เพราะ thread อาจ panic!panic!error ที่กู้ไม่ได้ (bug/invariant พัง) ทำให้ thread unwind; ไม่ใช่ทางไหลของ error ปกติ ได้ — .unwrap() ตรงนี้แปลว่า “ถ้า thread ลูก panic ให้ thread หลัก panic ตาม”
lambda ของ C# ที่ส่งให้ Task.Run จับตัวแปรแบบ by reference และ GC จะต่ออายุให้เอง — คุณไม่ต้องเขียน move เพราะไม่มีใครบังคับเรื่องเจ้าของ Rust ไม่มี GC มาต่ออายุ closure ที่ข้าม thread จึงต้อง move เป็นเจ้าของ (หรือใช้ thread::scope ที่เราจะเจอตอนท้าย) — นี่คือ ownership จากบท2 ที่ตามมาหลอกหลอนถึง thread
data race เป็น compile error: Rc ข้าม thread คือ E0277
หัวข้อที่มีชื่อว่า “data race เป็น compile error: Rc ข้าม thread คือ E0277”ทำไมต้อง Arc ในเมื่อ Rc ก็นับ reference เหมือนกัน? เพราะตัวนับของ Rc ไม่ใช่ atomic — ถ้า2 thread เพิ่ม/ลดตัวนับพร้อมกัน ตัวนับพัง แล้วนำไปสู่ double-free หรือ use-after-free ทันที นี่คือ data race แบบคลาสสิก และ Rust จับมันได้ ตอน compile ลองเอา Rc ข้าม thread ดู — นี่คือ ❌ version ดิบที่ compile ไม่ผ่าน:
use std::rc::Rc;use std::thread;fn main() { let data = Rc::new(5); let d = Rc::clone(&data); thread::spawn(move || { println!("{}", d); // ❌ Rc ข้าม thread ไม่ได้ });}compiler โยน error กลับมาชัดๆ ว่า:
error[E0277]: `Rc<i32>` cannot be sent between threads safely |6 | thread::spawn(move || { | ------------- ^------ | | | | within this `{closure@...}` | required by a bound introduced by this call | = help: within `{closure@...}`, the trait `Send` is not implemented for `Rc<i32>`note: required by a bound in `spawn`E0277 = trait bound ไม่ครบ ในที่นี้คือ Rc<i32> ไม่ implement trait Send — ซึ่งเป็นเงื่อนไขที่ thread::spawn ต้องการ นี่คือ compiler-ครูในภาคปฏิบัติที่ชัดที่สุดในทั้งคอร์ส: bug data race ที่ใน C# จะไปโผล่เอาตอน production (หรือไม่โผล่จนกว่าจะโหลดหนัก) ถูก Rust ปฏิเสธตั้งแต่ยังไม่ทันรัน วิธีแก้คือสลับ Rc เป็น Arc — เพราะ Arc implement Send (และ Sync) code ก็ผ่านทันที นี่คือ ✅ version แก้:
use std::sync::Arc;use std::thread;fn main() { let data = Arc::new(5); let d = Arc::clone(&data); thread::spawn(move || { println!("{d}"); // ✅ Arc เป็น Send — ข้าม thread ได้ });}Send และ Sync: marker trait ที่ทำให้ compiler พิสูจน์ได้
หัวข้อที่มีชื่อว่า “Send และ Sync: marker trait ที่ทำให้ compiler พิสูจน์ได้”เบื้องหลัง E0277 คือ marker trait สองตัวที่ไม่มี method ใดๆ — มันแค่ ติดป้าย ให้ compiler ตรวจสอบ:
Send— ชนิดที่ ย้าย ความเป็นเจ้าของข้าม thread ได้อย่างปลอดภัยSync— ชนิดที่ share reference (&T) ข้าม thread ได้อย่างปลอดภัย
Send + SyncSend + Syncmarker trait ที่ทำให้ compiler พิสูจน์ความปลอดภัยข้าม thread — data race กลายเป็น compile error ถูก compiler ใส่ให้อัตโนมัติกับชนิดที่ประกอบจากชิ้นส่วนที่ปลอดภัยทั้งหมด — คุณแทบไม่เคย implement มันเอง Rc จงใจ ไม่ เป็น Send (ตัวนับไม่ atomic) ส่วน Arc เป็น นี่คือกลไกที่ทำให้ประโยค “data race เป็น compile error” เป็นจริง ไม่ใช่คำโฆษณา — ไม่มีป้าย Send/Sync ครบ ก็ข้าม thread ไม่ได้ จบตั้งแต่ตอน compile
ใน C# คุณส่ง object อะไรก็ได้ข้าม thread compiler ไม่ห้าม — ความปลอดภัยเป็นความรับผิดชอบของคุณล้วนๆ และ race จะโผล่ตอน runtime Rust ยกภาระนี้ไปให้ type system: Send/Sync คือ ส่วนหนึ่งของชนิด ที่ compiler ตรวจให้ฟรีทุกครั้ง
Mutex เป็นเจ้าของข้อมูล — lock() คืน Result เพราะ poisoning
หัวข้อที่มีชื่อว่า “Mutex เป็นเจ้าของข้อมูล — lock() คืน Result เพราะ poisoning”กลับมาที่ Mutex<T> ให้ละเอียด Mutex เป็นเจ้าของ ข้อมูลข้างใน คุณเข้าถึงมันไม่ได้เลยจนกว่าจะเรียก lock() ซึ่งคืน MutexGuard — smart pointer ที่ deref ไปยังข้อมูลจริง และ ปลดล็อกอัตโนมัติเมื่อ guard หมด scope (RAII เหมือน Drop ในบท2 — ไม่มี unlock() ให้ลืมเรียก เหมือน using/IDisposable แต่บังคับใช้เสมอ)
แต่ทำไม lock() ถึงคืน Result ไม่ใช่ guard ตรงๆ? เพราะ lock poisoning: ถ้า thread 1 panic ขณะกำลังถือล็อก ข้อมูลข้างในอาจอยู่ในสภาพครึ่งๆ กลางๆ ที่เชื่อถือไม่ได้ Rust จึงทำเครื่องหมายว่า mutex นั้น “เป็นพิษ” (poisoned) และ lock() ครั้งถัดไปจะคืน Err เพื่อ เตือน ว่าข้อมูลอาจไม่ครบ นี่คือแนวคิดที่ C# ไม่มีเลย — lock(obj) ของ C# ถ้าเกิด exception ขณะถือล็อก มันแค่ปลดล็อกแล้วปล่อยผ่าน ไม่มีสัญญาณว่าข้อมูลอาจเสีย
ใน code ตัวอย่างเราเขียน .unwrap() หลัง lock() ซึ่งแปลว่า “ถ้า mutex เป็นพิษ ให้ panic ตาม” — ในโปรแกรมสั้นๆ นี่โอเค แต่จงเรียกมันด้วยชื่อของมัน อย่ามองข้ามว่ามันเป็นแค่พิธีกรรม unwrap เฉยๆ มันคือจุดที่คุณ ตัดสินใจ ว่าจะทำอย่างไรเมื่อเจอ mutex เป็นพิษ
mpsc: channel ที่ต้อง drop(tx) ไม่งั้นค้าง
หัวข้อที่มีชื่อว่า “mpsc: channel ที่ต้อง drop(tx) ไม่งั้นค้าง”Arc<Mutex<T>> คือการ share state แต่บ่อยครั้ง concurrency ที่สะอาดกว่าคือการ ส่งข้อความ ไม่ share memory เลย — แต่ละ thread ทำงานของตัวเองแล้วส่งผลกลับมาทาง channel std::sync::mpsc = multiple producer, single consumer: clone ตัวส่ง (Sender) ได้หลายตัวต่อ1 thread แต่ตัวรับ (Receiver) มีตัวเดียว
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel(); for id in 0..4 { let tx = tx.clone(); thread::spawn(move || { tx.send((id, id * id)).unwrap(); }); } drop(tx); // ปิดตัวส่งต้นฉบับ ไม่งั้น loop rx จะไม่มีวันจบ let mut total = 0; for (id, result) in rx { println!("worker {id} -> {result}"); total += result; } println!("total = {total}");}หัวใจอยู่ที่ drop(tx) — จำเป็นเสมอ loop for … in rx จะจบก็ต่อเมื่อ Sender ทุกตัว ถูก drop หมด ใน code นี้เรา clone tx ให้ทุก thread (แต่ละอันจะ drop เองเมื่อ thread จบ) แต่ยังเหลือ tx ต้นฉบับใน thread หลักที่ ไม่มีวัน drop เอง จนจบ main — ถ้าไม่เรียก drop(tx) ด้วยมือ rx จะรอ Sender ตัวสุดท้ายตลอดกาล แล้วโปรแกรม ค้างนิ่ง นี่คือ gotcha ที่มือใหม่โดนบ่อยที่สุดของ channel
mpsc คล้าย System.Threading.Channels หรือ BlockingCollection<T> ของ C# แต่ design ให้มี consumer เดียวเสมอ (จึงไม่ต้องกังวลว่าใครจะแย่งอ่าน) เงื่อนไข “ปิด channel เมื่อ producer หมด” ที่ C# ทำผ่าน Writer.Complete() ฝั่ง Rust ทำโดย การ drop Sender ตัวสุดท้าย — ซึ่งเป็นเหตุผลที่ drop(tx) สำคัญ
thread::scope: ยืม stack ได้โดยไม่ต้อง Arc
หัวข้อที่มีชื่อว่า “thread::scope: ยืม stack ได้โดยไม่ต้อง Arc”thread::spawn บังคับ 'static เพราะ thread อาจอยู่นานเกินตัวแปรที่มันยืม แต่ถ้าเรา รับประกัน ได้ว่า thread จะจบก่อน scope ปิดล่ะ? นั่นคือหน้าที่ของ thread::scope (stable ตั้งแต่ Rust 1.63.0) — มัน join ทุก thread ให้อัตโนมัติเมื่อจบ scope ดังนั้น thread ข้างในจึง ยืม ข้อมูล local บน stack ได้ตรงๆ ไม่ต้อง Arc ไม่ต้อง 'static:
use std::thread;fn main() { let mut data = vec![1, 2, 3, 4, 5]; let sum = thread::scope(|s| { let a = s.spawn(|| data.iter().take(2).sum::<i32>()); let b = s.spawn(|| data.iter().skip(2).sum::<i32>()); a.join().unwrap() + b.join().unwrap() }); data.push(sum); println!("{data:?}");}สังเกตว่า thread a และ b ยืม &data ได้เลย (2 shared borrow พร้อมกัน — ถูกกติกา borrow จากบท2 เพราะไม่มีใครแก้) และสำนวนสำคัญคือ คืนค่าที่ join แล้วออกมา เป็นค่าของ scope — thread::scope ทั้ง block คืน sum ออกมาให้ใช้ต่อ อย่าไปพยายาม assign เข้าตัวแปร let ที่ประกาศไว้ นอก scope แล้วอ่านทีหลัง (แบบนั้น borrow checker จะไม่ยอม เพราะขัดกับ ownership) — ให้ปล่อยค่าไหลออกมาจาก block ตรงๆ อย่างในตัวอย่าง แล้วค่อยเอาไปใช้ (data.push(sum))
เส้นแบ่ง std กับ async — พูดกันตรงๆ
หัวข้อที่มีชื่อว่า “เส้นแบ่ง std กับ async — พูดกันตรงๆ”ตลอดคอร์สเราสัญญาว่าจะไม่แตะ async แล้วปิดท้ายด้วยเหตุผลที่ตรงไปตรงมา นี่ไม่ใช่ช่องโหว่ แต่เป็น design:
std::thread= OS thread แบบ 1:1 ไม่มี runtime พิเศษ ไม่มี “function coloring” (async fnที่ลามไปทั้ง call stack) เหมาะกับงานที่ มีขอบเขต — งาน CPU หนัก หรือ blocking I/O จำนวนพอประมาณ นับได้ อย่าง CLI ของเรา- async (tokio ฯลฯ) = multiplex งาน I/O นับหมื่นงานบน thread ไม่กี่ตัว เกิดมาเพื่อ concurrency ระดับมหาศาลอย่าง web server ที่ถือ connection พร้อมกันเป็นแสน — และต้องพึ่ง runtime ภายนอกที่ต้อง opt-in แยกต่างหาก
reflex “await มันสิ” ของ C# จริงๆ แล้ว map มาที่ std::thread ไม่ใช่ Rust async — เพราะ .NET รวม threadpool กับ async ไว้ใน runtime เดียว แต่ Rust แยกสองโลกนี้ออกจากกันชัดเจน สำหรับงานที่มีขอบเขตอย่าง taskcli thread คือคำตอบที่ถูกต้อง ไม่ใช่การประนีประนอม — และนี่ตรงกับ final project ของหนังสือ The Rust Programming Language เองที่เป็น web server แบบ multithread ไม่ใช่ async
ปิด project: ตัวนับความถี่คำแบบขนาน
หัวข้อที่มีชื่อว่า “ปิด project: ตัวนับความถี่คำแบบขนาน”ถึงเวลาปิด taskcli ด้วย feature สุดท้าย — นับความถี่คำจากหลาย chunk พร้อมกัน กลยุทธ์สำคัญคือ นับใน thread ของตัวเองก่อน แล้วรวมทีเดียวใต้ล็อก ไม่ใช่ให้ทุก thread แย่งล็อก HashMap กลางทีละคำ (ซึ่งจะทำให้ล็อกกลายเป็นคอขวด thread เข้าคิวรอกันจนขนานไม่ได้จริง):
use std::collections::HashMap;use std::sync::{Arc, Mutex};use std::thread;
fn main() { let chunks = vec![ "the quick brown fox".to_string(), "the lazy dog the fox".to_string(), "quick quick brown".to_string(), ]; let counts: Arc<Mutex<HashMap<String, usize>>> = Arc::new(Mutex::new(HashMap::new())); let mut handles = Vec::new(); for chunk in chunks { let counts = Arc::clone(&counts); handles.push(thread::spawn(move || { let mut local: HashMap<String, usize> = HashMap::new(); for word in chunk.split_whitespace() { *local.entry(word.to_string()).or_insert(0) += 1; } let mut shared = counts.lock().unwrap(); for (word, n) in local { *shared.entry(word).or_insert(0) += n; } })); } for h in handles { h.join().unwrap(); } let final_counts = Arc::into_inner(counts).unwrap().into_inner().unwrap(); let mut sorted: Vec<_> = final_counts.into_iter().collect(); sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); for (word, n) in sorted { println!("{n:>3} {word}"); }}รันแล้วได้ผลเรียงตามความถี่ (สูงไปต่ำ แล้วเรียงตามตัวอักษรเมื่อเสมอกัน):
3 quick 3 the 2 brown 2 fox 1 dog 1 lazyโครงนี้รวมทุกอย่างของบทนี้เข้าด้วยกัน: แต่ละ thread สร้าง HashMapHashMapตาราง hash key→value; ใช้ `entry(k).or_insert(v)` เป็นสำนวนอัปเดตด้วย lookup เดียว local ของตัวเอง (ไม่มีการ share ไม่มีล็อก จึงขนานได้เต็มที่) นับคำใน thread ของมัน แล้ว ล็อก Arc<Mutex<HashMap>> กลางเพียงครั้งเดียวตอนจบ เพื่อรวมผลของตัวเองเข้าไป — ล็อกถูกถือสั้นที่สุด ตอนสุดท้าย Arc::into_inner (stable ตั้งแต่ 1.70) แกะ Arc ตัวสุดท้ายออกมา (join หมดแล้ว จึงเหลือเจ้าของเดียว) และ into_inner ของ Mutex ดึง HashMap ออกจากล็อกโดยไม่ต้อง lock อีก เพราะไม่มี thread อื่นเหลือแล้ว
flowchart TB MAIN["thread หลัก<br/>สร้าง Arc Mutex HashMap ว่าง"] MAIN -->|"Arc::clone + move"| W0["thread 0<br/>นับใน HashMap local"] MAIN -->|"Arc::clone + move"| W1["thread 1<br/>นับใน HashMap local"] MAIN -->|"Arc::clone + move"| W2["thread 2<br/>นับใน HashMap local"] W0 -->|"lock() ครั้งเดียว<br/>รวมผล"| LOCK["Mutex HashMap กลาง"] W1 -->|"lock() ครั้งเดียว<br/>รวมผล"| LOCK W2 -->|"lock() ครั้งเดียว<br/>รวมผล"| LOCK LOCK -->|"join หมดทุก thread<br/>Arc::into_inner"| OUT["ผลรวมเรียงตามความถี่"] classDef main fill:#fde68a,stroke:#92400e,color:#451a03; classDef worker fill:#fed7aa,stroke:#7c2d12,color:#431407; classDef lock fill:#fecaca,stroke:#991b1b,color:#450a0a; class MAIN,OUT main; class W0,W1,W2 worker; class LOCK lock;
คำบรรยายภาพ: N thread share Arc<Mutex<HashMap>> ตัวเดียว — แต่ละ thread นับใน HashMap ของตัวเองก่อน (ไม่มีล็อก ขนานเต็มที่) แล้วจึงล็อกเพียงครั้งเดียวเพื่อรวมผลเข้า HashMap กลาง ลดการแย่งล็อกให้เหลือน้อยที่สุด เมื่อทุก thread join แล้ว Arc::into_inner แกะข้อมูลออกมาโดยไม่ต้องล็อกอีก
สรุปคอร์ส: แปดบทที่เดินมาด้วยกัน
หัวข้อที่มีชื่อว่า “สรุปคอร์ส: แปดบทที่เดินมาด้วยกัน”taskcli เสร็จสมบูรณ์แล้ว และคุณเดินผ่าน model ทั้งหมดของ Rust มาครบ:
- บท1 — ทำไมต้อง Rust: หน่วยความจำ deterministic ไม่มี GC/null/data race โดย compiler บังคับ; toolchain
rustup/cargo/rustc; edition 2024 ต่างจาก version compiler - บท2 — ownership, borrowing, move: ไอเดียเดียวที่ทั้งคอร์สตั้งอยู่บนมัน; move ป้องกัน double-free (E0382); กติกา
&mutเดียว XOR หลาย& - บท3 — struct, enum แบบ sum type,
matchที่ exhaustive (E0004), และ OptionOption`Some(T)` หรือ `None` — แทน null; compiler บังคับให้จัดการกรณีว่างก่อนใช้ค่า แทน null - บท4 — error เป็นค่า: ResultResult`Ok(T)` หรือ `Err(E)` — error เป็นค่าปกติในลายเซ็น ไม่ใช่ exception, ตัวดำเนินการ
?, panic กับ thiserrorthiserrorderive macro สร้าง `Error`+`Display` ให้ error enum ของ library (crate v2.x) - บท5 — VecVecarray ปรับขนาดได้บน heap ที่เป็นเจ้าของสมาชิก/
HashMap,Stringกับ&str, IteratorIteratortrait ที่มีเมท็อดจำเป็นเดียว `next()`; adapter ทุกตัวขี้เกียจ (lazy) จนกว่าจะ consume แบบ lazy และ closure - บท6 — traittraitนิยามพฤติกรรมร่วม (คล้าย interface) แต่มี default method และ impl ให้ชนิดที่เราไม่ได้เขียนเองได้ กับ genericsgenericsparameter ชนิดที่ถูก monomorphize — สร้าง code เฉพาะต่อชนิด ต้นทุน dispatch = ศูนย์: static vs dynamic dispatch,
From/Into(ตัวที่?ใช้แปลง error), lifetime elision - บท7 — สร้าง CLI จริง: clapclapcrate parse argument CLI แบบ derive (v4.x, ต้องเปิด feature `derive`), serdeserdeframework (de)serialization แบบ derive (คู่กับ serde_json สำหรับ JSON),
std::fs, test, และการแยกlib.rs/main.rs - บท8 — fearless concurrency:
Arc<Mutex<T>>,thread::spawn,mpsc,thread::scope, และSend/Syncที่ทำให้ data race เป็น compile error
เส้นเรื่องเดียวที่ร้อยทุกบทคือ compiler เป็นครู — ทุก error code (E0382, E0499, E0502, E0106, E0277, E0004, E0782) คือ bug ทั้งชนชั้นที่ถูกกันไว้ตั้งแต่ compile และทุก “เหมือน C# X” มี ”…ยกเว้น” ตามมาเสมอ เพราะ Rust เป็นสะพานให้ข้าม ไม่ใช่ของอย่างเดียวกันเป๊ะ
ต่อจากนี้ไปไหน
หัวข้อที่มีชื่อว่า “ต่อจากนี้ไปไหน”เราจงใจอยู่ใน std เพื่อให้พื้นฐานแน่น จากตรงนี้ runway ข้างหน้าเปิดกว้าง — และแต่ละเส้นทางคือคอร์สของตัวเองต่างหาก:
- async/web —
async/await, tokio, และ ecosystem ของ web/network service ที่เราจงใจข้ามไป (โลกที่ concurrency ระดับหมื่น connection คุ้มกับความซับซ้อนของ runtime) - systems project — เขียนของที่ Rust เกิดมาเพื่อมันจริงๆ: parser, allocator, embedded, หรือ WebAssembly — ที่ไม่มี GC และควบคุมหน่วยความจำเองกลายเป็นข้อได้เปรียบ ไม่ใช่ภาระ
แต่ไม่ว่าจะไปทางไหน ฐานเดียวกันที่คุณเพิ่งวางคือสิ่งที่ทุกอย่างตั้งอยู่บนมัน — ownership, borrowing, และ compiler ที่เป็นครู ขอบคุณที่เดินมาจนจบ แล้วไปสร้างของจริงกันต่อ
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- The Rust Programming Language — ch16 Fearless Concurrency (เข้าถึง 2026-07-24) —
thread::spawn+move,mpsc(multiple producer single consumer),Arc<Mutex<T>>shared state, และSend/Sync - std::thread::scope — standard library docs (เข้าถึง 2026-07-24) — scoped thread ยืมข้อมูล non-
'staticได้ และ join ทุก thread เมื่อจบ scope - Announcing Rust 1.63.0 (2022-08-11) —
std::thread::scope(scoped threads) เข้า stable ที่ version นี้ - rustc Error Codes Index — E0277 (เข้าถึง 2026-07-24) — trait bound ไม่ครบ (ในบทนี้:
Rcไม่เป็นSendจึงข้าม thread ไม่ได้)
เช็กความเข้าใจ — บทที่ 8
ข้อ 1 / 3ทำไม `thread::spawn` ถึงบังคับให้ closure ใช้ `move` และทำไมเราจึง clone `Arc` หนึ่งอันต่อ thread?