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

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 ทั้ง​กอง และ​ตอน​ท้าย​เรา​จะ​อธิ​บายตรงๆ ว่า​ทำไม

📦 kaen-taskcli

code ลงมือ​ของ​บท​นี้​อยู่​ใน repo kaen-taskcli (code ตัวอย่าง​กำลัง​จัด​ทำ) — บท​นี้​ต่อ taskcli จาก​บท7 (clap + serde + module split) ด้วย feature สุดท้าย: ตัว​นับ​ความถี่​คำ​แบบ ขนาน ที่​กระจาย​งาน​ลง​หลาย thread แล้ว​รวม​ผล​ใต้​ล็อก​เดียว concurrency ไม่​เพิ่ม crate ใหม่​แม้แต่​ตัว​เดียว — Cargo.toml ยัง​เป็น​ชุด​เดิม​ของ​บท7 ทุก​อย่าง​ใน​บท​นี้​อยู่​ใน std::thread + std::sync

ก่อน​จะ spawn thread แรก ต้อง​เข้าใจ สะพาน สอง​คู่​ก่อน ใน​โลก thread เดียว Rust มี smart pointer สอง​ตัว​ที่​คุณ​อาจ​เคย​เห็น​ผ่านๆ:

  • Rc<T> — reference-counted pointer ให้​ค่า​ก้อน​เดียว มี​เจ้าของ​หลาย​คน ได้ (นับ reference แล้ว drop เมื่อ​นับ​ถึง​ศูนย์) แต่​ตัว​นับ​เป็น​แบบ ไม่ atomic — เร็ว แต่​ไม่​ปลอดภัย​ข้าม thread
  • RefCell<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) แยก​กัน​คนละ​ชั้น

…ยกเว้น: Arc<Mutex<T>> ไม่ใช่ 'lock(obj) เฉยๆ'

reflex ของ C# คือ lock(obj) { obj.Field = x; } — แต่ obj ยัง เข้าถึง​ได้ โดย​ไม่​ต้อง​ถือ lock ใคร​ลืม lock ก็​แก้​ข้อมูล​ชน​กัน​ได้ ไม่มี​อะไร​กัน ส่วน Mutex<T> ของ Rust เป็น​เจ้าของ ข้อมูลนั้นจริงๆ — ข้อมูล​อยู่ ข้าง​ใน Mutex และ​คุณ เข้า​ไม่​ถึง​เลย จนกว่า​จะ​เรียก lock() สำเร็จ การ​ลืม​ล็อก​จึง​ไม่ใช่ “bug ที่​ต้อง​ระวัง” แต่​เป็น​สิ่ง​ที่ เขียน​ออก​มา​ไม่​ได้ ตั้งแต่​แรก

สร้าง 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 ตาม”

…ยกเว้น: C# ไม่มี compile-time move

lambda ของ C# ที่​ส่ง​ให้ Task.Run จับ​ตัวแปร​แบบ by reference และ GC จะ​ต่อ​อายุ​ให้​เอง — คุณ​ไม่​ต้อง​เขียน move เพราะ​ไม่มี​ใคร​บังคับ​เรื่อง​เจ้าของ Rust ไม่มี GC มา​ต่อ​อายุ closure ที่​ข้าม thread จึง​ต้อง move เป็น​เจ้าของ (หรือ​ใช้ thread::scope ที่​เรา​จะ​เจอ​ตอน​ท้าย) — นี่​คือ ownership จาก​บท2 ที่​ตาม​มา​หลอก​หลอน​ถึง thread

ทำไม​ต้อง 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 ได้
});
}

เบื้องหลัง 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# ไม่มี Send/Sync

ใน C# คุณ​ส่ง object อะไร​ก็ได้​ข้าม thread compiler ไม่​ห้าม — ความ​ปลอดภัย​เป็น​ความ​รับผิดชอบ​ของ​คุณ​ล้วนๆ และ race จะ​โผล่​ตอน runtime Rust ยก​ภาระ​นี้​ไป​ให้ type system: Send/Sync คือ ส่วน​หนึ่ง​ของ​ชนิด ที่ compiler ตรวจ​ให้​ฟรี​ทุก​ครั้ง

กลับ​มา​ที่ 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 เป็น​พิษ

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

เทียบ C#: mpsc ≈ Channels แต่ single-consumer

mpsc คล้าย System.Threading.Channels หรือ BlockingCollection<T> ของ C# แต่ design ให้​มี consumer เดียว​เสมอ (จึง​ไม่​ต้อง​กังวล​ว่า​ใคร​จะ​แย่ง​อ่าน) เงื่อนไข “ปิด channel เมื่อ producer หมด” ที่ C# ทำ​ผ่าน Writer.Complete() ฝั่ง Rust ทำ​โดย การ drop Sender ตัว​สุดท้าย — ซึ่ง​เป็น​เหตุผล​ที่ drop(tx) สำคัญ

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 แล้ว​ออก​มา เป็น​ค่า​ของ scopethread::scope ทั้ง block คืน sum ออก​มา​ให้​ใช้​ต่อ อย่า​ไป​พยายาม assign เข้า​ตัวแปร let ที่​ประกาศ​ไว้ นอก scope แล้ว​อ่าน​ทีหลัง (แบบ​นั้น borrow checker จะ​ไม่​ยอม เพราะ​ขัด​กับ ownership) — ให้​ปล่อย​ค่า​ไหล​ออก​มา​จาก block ตรงๆ อย่าง​ใน​ตัวอย่าง แล้ว​ค่อย​เอา​ไป​ใช้ (data.push(sum))

ตลอด​คอร์ส​เรา​สัญญา​ว่า​จะ​ไม่​แตะ 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

ถึง​เวลา​ปิด 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
  • บท2ownership, 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)
  • บท5VecVecarray ปรับ​ขนาด​ได้​บน heap ที่​เป็น​เจ้าของ​สมาชิก/HashMap, String กับ &str, IteratorIteratortrait ที่​มี​เมท็​อด​จำเป็น​เดียว `next()`; adapter ทุก​ตัว​ขี้เกียจ (lazy) จนกว่า​จะ consume แบบ lazy และ closure
  • บท6traittraitนิยาม​พฤติกรรม​ร่วม (คล้าย 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/webasync/await, tokio, และ ecosystem ของ web/network service ที่​เรา​จงใจ​ข้าม​ไป (โลก​ที่ concurrency ระดับ​หมื่น connection คุ้ม​กับ​ความ​ซับซ้อน​ของ runtime)
  • systems project — เขียน​ของ​ที่ Rust เกิด​มา​เพื่อ​มันจริงๆ: parser, allocator, embedded, หรือ WebAssembly — ที่​ไม่มี GC และ​ควบคุม​หน่วย​ความ​จำ​เอง​กลาย​เป็น​ข้อ​ได้​เปรียบ ไม่ใช่​ภาระ

แต่​ไม่​ว่า​จะ​ไป​ทาง​ไหน ฐาน​เดียวกัน​ที่​คุณ​เพิ่ง​วาง​คือ​สิ่ง​ที่​ทุก​อย่าง​ตั้ง​อยู่​บน​มัน — ownership, borrowing, และ compiler ที่​เป็น​ครู ขอบคุณ​ที่​เดิน​มา​จน​จบ แล้วไป​สร้าง​ของ​จริง​กัน​ต่อ


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

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

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

ข้อ 1 / 3

ทำไม `thread::spawn` ถึงบังคับให้ closure ใช้ `move` และทำไมเราจึง clone `Arc` หนึ่งอันต่อ thread?