Concurrency, Testing & Cargo
Fearless Concurrency — สิ่งที่จะได้เรียนในโมดูลนี้
หัวข้อที่มีชื่อว่า “Fearless Concurrency — สิ่งที่จะได้เรียนในโมดูลนี้”หนึ่งในข้อเรียกร้องที่กล้าหาญที่สุดของ Rust คือ fearless concurrency (การทำงานพร้อมกันอย่างไม่หวั่นเกรง): การเขียนโปรแกรมแบบหลาย-thread โดยไม่มี data race ที่รบกวน C, C++ หรือแม้แต่ Java คอมไพเลอร์บังคับใช้กฎ ownership เดิมที่ขอบเขตของ thread เปลี่ยนบั๊กทั้งคลาสให้เป็น compile-time error แทนที่จะเป็น runtime failure ที่หาได้ยาก
โมดูลนี้สำรวจสี่เสาหลักของ Rust ในการใช้งานจริง:
| บทเรียน | หัวข้อ | รันได้ |
|---|---|---|
| Threads | std::thread, Arc<Mutex<T>>, การ join handle | ได้ — Playground ใน browser |
| Channels | std::sync::mpsc, send/receive, การส่งต่อ ownership | ได้ — Playground ใน browser |
| Async Intro | async/.await, futures, tokio runtime | เฉพาะ Cargo project เท่านั้น |
| Cargo & Tests | cargo new, #[test], cargo test | เฉพาะ Cargo project เท่านั้น |
สองโมเดลของ Concurrency
หัวข้อที่มีชื่อว่า “สองโมเดลของ Concurrency”standard library ของ Rust มี OS threads ผ่าน std::thread แต่ละ thread คือ thread ระดับ OS จริง ๆ ที่มี stack ของตัวเอง สถานะที่แชร์กันระหว่าง thread ถูกป้องกันด้วย Arc<Mutex<T>> — คอมไพเลอร์จะไม่ยอมให้คุณส่งประเภทที่ไม่ใช่ Send ข้ามขอบเขต thread
สำหรับ workload ที่ผูกติดกับ I/O — network server, การดำเนินการกับไฟล์, งานที่เกิดขึ้นพร้อมกันจำนวนมาก — โมเดล async/.await มีประสิทธิภาพมากกว่า โดยใช้ event loop แบบ single-threaded หรือ multi-threaded (โดยทั่วไปคือ tokio) เพื่อกำหนดเวลาของงานนับพัน ๆ งานโดยไม่มีค่าใช้จ่ายของ OS thread ต่องาน
ทำไม Concurrency ใน Rust จึงแตกต่าง
หัวข้อที่มีชื่อว่า “ทำไม Concurrency ใน Rust จึงแตกต่าง”ในภาษาส่วนใหญ่ data race คือปัญหา runtime คุณเขียนโค้ด รันขึ้นมา และบางครั้งโปรแกรมก็ crash หรือส่งคืนผลลัพธ์ที่ผิดเมื่อมีการโหลดสูง ใน Rust:
- ประเภทที่เป็น
Sendหมายความว่าปลอดภัยที่จะย้ายไปยัง thread อื่น - ประเภทที่เป็น
Syncหมายความว่าปลอดภัยที่จะแชร์ reference ข้าม thread - คอมไพเลอร์ปฏิเสธโค้ดที่ละเมิดสัญญาเหล่านี้ — ไม่ต้องการ sanitizer หรือ runtime check
flowchart TD
A["What kind of work am I scheduling?"] --> B{"CPU-bound, or I/O-bound?"}
B -->|CPU-bound| C["std::thread — real OS thread, ~8 MB stack"]
B -->|I/O-bound, many at once| D["async/.await — lightweight task, needs an executor"]
C --> E["Share state with Arc + Mutex/RwLock"]
D --> F["Share state the same way, but never hold a sync lock across .await"] ตัวอย่างสั้น ๆ
หัวข้อที่มีชื่อว่า “ตัวอย่างสั้น ๆ”นี่คือโปรแกรม Rust แบบ concurrent ที่เล็กที่สุดที่มีความหมาย — spawn thread รอ แล้วทำงานต่อใน main
use std::thread;
fn main() { let handle = thread::spawn(|| { println!("Hello from a spawned thread!"); }); handle.join().unwrap(); println!("Main thread done.");}Compiling…
Compiler พูดว่าอย่างไร: ใช้ค่าหลังจากถูก Move เข้า Thread ไปแล้ว
หัวข้อที่มีชื่อว่า “Compiler พูดว่าอย่างไร: ใช้ค่าหลังจากถูก Move เข้า Thread ไปแล้ว”thread::spawn บังคับให้ closure เป็นเจ้าของทุกอย่างที่มัน capture มา (ผ่าน move) เพราะ thread ที่ spawn อาจยังทำงานอยู่หลังจากฟังก์ชันที่สร้างมันจบไปแล้ว การย้าย ownership นี้เกิดขึ้นจริง — ค่านั้นหายไปจากผู้เรียกจริง ๆ ไม่ใช่แค่ borrow:
// @expect-error E0382use std::thread;
fn main() { let data = vec![1, 2, 3]; let handle = thread::spawn(move || { println!("sum from thread: {}", data.iter().sum::<i32>()); }); handle.join().unwrap(); println!("data length: {}", data.len()); // ERROR: data was moved into the thread above}error[E0382]: borrow of moved value: `data` --> src/main.rs:9:33 |4 | let data = vec![1, 2, 3]; | ---- move occurs because `data` has type `Vec<i32>`, which does not implement the `Copy` trait5 | let handle = thread::spawn(move || { | ------- value moved into closure here6 | println!("sum from thread: {}", data.iter().sum::<i32>()); | ---- variable moved due to use in closure...9 | println!("data length: {}", data.len()); | ^^^^ value borrowed here after move |help: consider cloning the value before moving it into the closure |5 ~ let value = data.clone();6 ~ let handle = thread::spawn(move || {7 ~ println!("sum from thread: {}", value.iter().sum::<i32>()); |คำแนะนำของคอมไพเลอร์เองคือ .clone() — และในกรณีนี้มันเป็นทางแก้ที่ใช้ได้จริง เพราะ main ต้องใช้ data ทั้งสองที่จริง ๆ แต่ควรมองคำแนะนำนี้เป็นทางเลือกสุดท้าย ไม่ใช่ปฏิกิริยาสะท้อน — ทางแก้ที่ดีกว่ามักจะเป็นให้ thread คำนวณแล้ว return ค่าที่ main ต้องการ (ผ่าน JoinHandle::join()) แทนที่จะมีสำเนาที่สองอยู่ทั้งสองฝั่ง
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกค่าใน Rust มีเจ้าของเดียวในแต่ละเวลา และ closure ของ thread::spawn จะกลายเป็นเจ้าของใหม่ในทันทีที่คุณเขียน move ไม่มีอะไร “มหัศจรรย์” เกิดขึ้นที่ขอบเขตของ thread เลย — มันคือกฎ move-semantics เดียวกับที่ควบคุมการส่ง String ไปยังฟังก์ชัน เพียงแต่ถูกบังคับใช้เพิ่มเติมด้วยเงื่อนไขว่า closure (และทุกอย่างที่มัน capture) ต้องเป็น 'static และ Send เพราะคอมไพเลอร์ไม่สามารถรู้ได้ว่า thread ที่ spawn จะจบเมื่อไหร่เทียบกับส่วนที่เหลือของโปรแกรม
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”OS thread ให้ parallelism จริงและ mental model ที่เรียบง่าย (แต่ละ thread แค่ทำงานไป) แลกกับ stack ของ OS จริง (~8 MB) และเพดานจริงที่ประมาณหลักร้อยถึงหลักพันต้น ๆ ของ thread ส่วน async/.await task นั้นถูก — คุณสามารถมีได้เป็นพันถึงล้าน task — แต่ทุกตัวต้องการ executor อย่าง tokio เพื่อให้มันทำงานจริง และงานที่ผูกกับ CPU บน executor นั้นจะบล็อกทุก task อื่นที่ใช้ thread เดียวกัน
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “Fearless concurrency หมายความว่า Rust ป้องกันบั๊กเรื่อง concurrency ได้ทั้งหมด” มันป้องกัน data race (สอง thread แตะ memory เดียวกันโดยไม่ synchronize อย่างน้อยหนึ่งฝั่งเขียน) ที่ compile time เท่านั้น มันไม่ได้ป้องกัน deadlock, logic race หรือ lock poisoning หลังจาก panic — สิ่งเหล่านั้นยังเป็นความรับผิดชอบของคุณ ซึ่งจะคลุมในบทถัดไปของโมดูลนี้
- “
SendและSyncเป็นสิ่งที่คุณต้อง implement เองสำหรับ type ส่วนใหญ่” เกือบทุก type ได้ทั้งสองอย่างอัตโนมัติ (มันเป็น auto trait) ตราบใดที่ทุก field เป็นSend/Syncคุณจะคิดถึงมันก็ต่อเมื่อคอมไพเลอร์บอกคุณว่า bound ไม่ถูก satisfy ซึ่งมักจะเป็นเพราะRc<T>,RefCell<T>หรือ raw pointer ที่ซ่อนอยู่ข้างใน - “async เร็วกว่า thread เสมอ” สำหรับงานที่ผูกกับ CPU
asyncเพิ่ม overhead (การ poll, การจอง memory ของ state machine) โดยไม่มีประโยชน์เลย — thread หรือ parallelism แบบrayonคือเครื่องมือที่ถูกต้องสำหรับกรณีนั้น
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”โค้ด Rust แบบ concurrent ที่ AI เขียนมักเรียก thread::spawn แล้วส่ง JoinHandle ที่ได้ไปเก็บใน Vec แต่บางครั้งก็ทิ้ง handle นั้นทันที (หรือไม่เก็บ Vec เลย) เพราะโค้ดรอบข้าง “ดูเหมือนจะเสร็จแล้ว” โดยไม่ต้องใช้มัน
🐛 หา bug ในโค้ดนี้
use std::thread;
fn process_all(items: Vec<i32>) { for item in items { thread::spawn(move || { println!("processed: {}", item * 2); }); } println!("all items processed");}เฉลย
thread::spawn คืนค่า JoinHandle และโค้ดนี้ไม่เคยเก็บมันไว้เลย — handle ถูก drop ทันที ซึ่งทำให้ thread เป็นอิสระ (detach) แต่ไม่รอมัน process_all สามารถพิมพ์ "all items processed" แล้ว return ก่อนที่ thread ที่ spawn ไว้จะทำงานเลยด้วยซ้ำ ถ้า main จบทันทีหลังจากนั้น process อาจจบกลางคันโดยที่บาง (หรือทั้งหมด) ของบรรทัด println!("processed: ...") ไม่เคยถูกพิมพ์เลย ทางแก้คือเก็บทุก JoinHandle ลงใน Vec แล้วเรียก .join().unwrap() กับแต่ละตัวก่อน return