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

Concurrency, Testing & Cargo

หนึ่งในข้อเรียกร้องที่กล้าหาญที่สุดของ Rust คือ fearless concurrency (การทำงานพร้อมกันอย่างไม่หวั่นเกรง): การเขียนโปรแกรมแบบหลาย-thread โดยไม่มี data race ที่รบกวน C, C++ หรือแม้แต่ Java คอมไพเลอร์บังคับใช้กฎ ownership เดิมที่ขอบเขตของ thread เปลี่ยนบั๊กทั้งคลาสให้เป็น compile-time error แทนที่จะเป็น runtime failure ที่หาได้ยาก

โมดูลนี้สำรวจสี่เสาหลักของ Rust ในการใช้งานจริง:

บทเรียนหัวข้อรันได้
Threadsstd::thread, Arc<Mutex<T>>, การ join handleได้ — Playground ใน browser
Channelsstd::sync::mpsc, send/receive, การส่งต่อ ownershipได้ — Playground ใน browser
Async Introasync/.await, futures, tokio runtimeเฉพาะ Cargo project เท่านั้น
Cargo & Testscargo new, #[test], cargo testเฉพาะ Cargo project เท่านั้น

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 ต่องาน

ในภาษาส่วนใหญ่ 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"]
เลือกระหว่าง OS thread กับ async task

นี่คือโปรแกรม 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.");
}

Compiler พูดว่าอย่างไร: ใช้ค่าหลังจากถูก Move เข้า Thread ไปแล้ว

หัวข้อที่มีชื่อว่า “Compiler พูดว่าอย่างไร: ใช้ค่าหลังจากถูก Move เข้า Thread ไปแล้ว”

thread::spawn บังคับให้ closure เป็นเจ้าของทุกอย่างที่มัน capture มา (ผ่าน move) เพราะ thread ที่ spawn อาจยังทำงานอยู่หลังจากฟังก์ชันที่สร้างมันจบไปแล้ว การย้าย ownership นี้เกิดขึ้นจริง — ค่านั้นหายไปจากผู้เรียกจริง ๆ ไม่ใช่แค่ borrow:

// @expect-error E0382
use 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` trait
5 | let handle = thread::spawn(move || {
| ------- value moved into closure here
6 | 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()) แทนที่จะมีสำเนาที่สองอยู่ทั้งสองฝั่ง

ทุกค่าใน Rust มีเจ้าของเดียวในแต่ละเวลา และ closure ของ thread::spawn จะกลายเป็นเจ้าของใหม่ในทันทีที่คุณเขียน move ไม่มีอะไร “มหัศจรรย์” เกิดขึ้นที่ขอบเขตของ thread เลย — มันคือกฎ move-semantics เดียวกับที่ควบคุมการส่ง String ไปยังฟังก์ชัน เพียงแต่ถูกบังคับใช้เพิ่มเติมด้วยเงื่อนไขว่า closure (และทุกอย่างที่มัน capture) ต้องเป็น 'static และ Send เพราะคอมไพเลอร์ไม่สามารถรู้ได้ว่า thread ที่ spawn จะจบเมื่อไหร่เทียบกับส่วนที่เหลือของโปรแกรม

OS thread ให้ parallelism จริงและ mental model ที่เรียบง่าย (แต่ละ thread แค่ทำงานไป) แลกกับ stack ของ OS จริง (~8 MB) และเพดานจริงที่ประมาณหลักร้อยถึงหลักพันต้น ๆ ของ thread ส่วน async/.await task นั้นถูก — คุณสามารถมีได้เป็นพันถึงล้าน task — แต่ทุกตัวต้องการ executor อย่าง tokio เพื่อให้มันทำงานจริง และงานที่ผูกกับ CPU บน executor นั้นจะบล็อกทุก task อื่นที่ใช้ thread เดียวกัน

  • “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 คือเครื่องมือที่ถูกต้องสำหรับกรณีนั้น

โค้ด 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

การที่ประเภทหนึ่งเป็น Send ใน Rust หมายความว่าอะไร?
ประเภทใดใน standard library ที่ใช้แชร์ mutable state ระหว่าง thread อย่างปลอดภัย?
ควรใช้ async/await แทน OS thread เมื่อใด?
โค้ดนี้ compile ผ่านไหมและทำงานอย่างไร?
use std::thread;
fn main() {
    let data = vec![1, 2, 3];
    let h = thread::spawn(move || {
        println!("{}", data.len());
    });
    h.join().unwrap();
    println!("{}", data.len());
}
ในตัวอย่าง SpotTheBug ทำไม "all items processed" ถึงพิมพ์ออกมาก่อนที่ thread ที่ spawn ไว้จะทำงานเสร็จได้?