กับดัก async ที่ compiler ไม่เตือน — block runtime, guard ข้าม await และ function colouring
หกบทที่ผ่านมาเราสร้างของขึ้นมาต่อเนื่อง — executor ด้วยมือ, runtime ตัวจริง, task, async I/O, select! แล้วก็ shared state บทนี้กลับด้าน: เราจะ พัง ของ ห้าครั้ง แล้วดูว่าใครเป็นคนบอกเรา
เพราะสิ่งที่แยกคนที่เขียน async Rust ได้ ออกจากคนที่เขียน async Rust แล้ว นอนหลับ ไม่ใช่รายการ API ที่จำได้ แต่คือความสามารถในการมอง code 1 block แล้วรู้ทันทีว่ามันอยู่ตระกูลไหนใน สองตระกูล: ตระกูลที่ compiler ตะโกนใส่หน้าคุณตั้งแต่ cargo build — พวกนี้ไม่อันตรายเลย เพราะมันไม่มีวันขึ้นโปรดักชัน — กับตระกูลที่ cargo build เขียว cargo clippy -- -D warnings เขียว test ผ่าน แล้วไปตายตอนตี 3 ของวันที่ traffic พีค พวกนี้คือของจริง และบทนี้ใช้เวลาส่วนใหญ่กับพวกมัน
คอร์สนี้ ต่อยอด repo kaen-kvstore จาก #22 (code ตัวอย่างกำลังจัดทำ) — ตลอด 8 บทเราจะยก store ตัวเดิมจาก ThreadPool ขนาดคงที่ 4 worker ของ #22 (หนึ่งคอนเนกชันยึด worker ไว้ทั้งเส้น) ขึ้นไปอยู่บน Tokio runtime จนถึง capstone ที่มี graceful shutdown ครบวงจร บทนี้คือบทซ้อมก่อนขึ้นสังเวียน — ทุกกับดักในนี้คือกับดักที่ kaen-kvstore version async ในบท 8 จะเดินเข้าไปเจอตรงๆ ถ้าเราไม่รู้จักมันก่อน: fsync กับ compaction ของ #22 เป็น blocking I/O เต็มตัว, HashMap ที่ share กันอยู่หลัง std::sync::Mutex, และ accept loop ที่ spawn ไม่จำกัดจำนวน
ทุก snippet pin ที่ rustc 1.97.1 (8bab26f4f 2026-07-14) · edition = “2024” · tokio 1.53.1
[dependencies]tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "sync", "time"] }"rt-multi-thread" เพราะกับดัก “block worker” จะโชว์ตัวก็ต่อเมื่อมี worker pool จริงๆ ให้ block · "macros" แจกแค่ตัวมาโคร #[tokio::main]/#[tokio::test] ไม่ได้พา scheduler มาด้วยสักตัว (ถ้าเอา "rt-multi-thread" ออกแล้วเหลือ "macros" เปล่าๆ #[tokio::main] จะฟ้องทันทีว่า default flavor คือ multi_thread แต่ feature ปิดอยู่ — flavor current_thread ต้องการแค่ "rt") · "sync" สำหรับ Semaphore · "time" สำหรับ tokio::time::sleep
ห้าม features = ["full"] เด็ดขาดทั้งคอร์ส — ด้วย scope สี่ตัวนี้ cargo resolve มา 7 crate พอดี (tokio 1.53.1, tokio-macros 2.7.1, pin-project-lite 0.2.17 + proc-macro2/quote/syn/unicode-ident ที่เป็น build dep ของ macro) ยังไม่มี mio/socket2/libc เพราะบทนี้ไม่เปิด "net"
code ทุก block ในบทนี้ถูก compile และรันจริงด้วย recipe เดียวของทั้งคอร์ส — cargo build/run/test/clippy -- -D warnings บน target x86_64-unknown-linux-gnu — รวมทั้ง block ที่ตั้งใจให้พัง ข้อความ error กับ panic ทุกบรรทัดข้างล่างคือข้อความจริงที่เครื่องพ่นออกมา
ข้อยกเว้นสองอย่างที่ ห้ามคาดหวังว่าจะได้เลขเดิม: เวลา compile ที่ cargo รายงาน (in 14.69s, in 2.92s) ขึ้นกับเครื่อง cache และรอบที่รัน · และเลขในวงเล็บของบรรทัด panic (thread 'main' (97292)) คือ thread id ของ OS ที่เปลี่ยนทุกครั้งที่รัน — สองอย่างนี้เราคัดมาจาก run จริงเพื่อไม่ให้แต่งเลขขึ้นเอง ไม่ใช่เพื่อให้คุณเทียบให้ตรง ส่วนที่ต้องตรงคือ ข้อความ ไม่ใช่ตัวเลข
แผนที่กับดัก: แบ่งตามเสียงของ compiler ไม่ใช่ตามหัวข้อ API
หัวข้อที่มีชื่อว่า “แผนที่กับดัก: แบ่งตามเสียงของ compiler ไม่ใช่ตามหัวข้อ API”ตำราส่วนใหญ่จัดหมวดกับดัก async ตามหัวข้อ — “เรื่อง lock”, “เรื่อง I/O”, “เรื่อง cancellation” ซึ่งจำยากและใช้งานไม่ได้ตอนอ่าน code จริง แกนที่ใช้ได้จริงมีแกนเดียว: ใครเป็นคนจับผิด
flowchart TD
A[กับดัก async ใน Rust] --> B[ตระกูลเสียงดัง compiler จับให้]
A --> C[ตระกูลเงียบสนิท ไม่มีใครจับให้]
B --> B1[std MutexGuard ข้าม await ในงานที่ spawn]
B --> B2[Rc หรือ RefCell ข้าม await ในงานที่ spawn]
B --> B3[เขียน await ใน function sync ไม่ได้ ฟ้อง E0728]
C --> C1[thread sleep หรือ blocking IO บน worker]
C --> C2[await ใน loop งานเรียงกันทีละตัว]
C --> C3[spawn ไม่จำกัดจำนวน กลายเป็นช่องโหว่ DoS]
C --> C4[block on ซ้อนใน async context]
B1 --> D[ตายตั้งแต่ cargo build ไม่มีวันขึ้นโปรดักชัน]
B2 --> D
B3 --> D
C1 --> E[ขึ้นโปรดักชันได้สบาย แล้วไปพังตอนโหลดสูง]
C2 --> E
C3 --> E
C4 --> F[ผ่าน build และ clippy แต่ panic ตั้งแต่รันครั้งแรก ไม่เกี่ยวกับโหลด]
คำบรรยายภาพ: แผนที่กับดัก async แบ่งเป็นสองตระกูลตามว่าใครเป็นคนจับผิด ตระกูลเสียงดังคือกลุ่มที่ compiler ปฏิเสธตั้งแต่ build ได้แก่การถือ std MutexGuard หรือ Rc หรือ RefCell ข้าม await ในงานที่ spawn และการพยายามเขียน await ใน function sync ซึ่งฟ้อง E0728 ทั้งหมดตายที่ cargo build จึงไม่มีวันขึ้นโปรดักชัน ส่วนตระกูลเงียบสนิทคือการ block worker ด้วย thread sleep หรือ blocking IO การ await ใน loop จนงานเรียงกันทีละตัว และการ spawn ไม่จำกัดจำนวน สามอย่างนี้ผ่าน build และ clippy ได้หมดแล้วไปพังตอนโหลดสูง ส่วนการเรียก block on ซ้อนในบริบท async เป็นกรณีลูกผสมที่ผ่าน build เหมือนกันแต่ panic ตั้งแต่รันครั้งแรกไม่ว่าโหลดจะเป็นอย่างไร
สังเกตความอสมมาตร: ฝั่งซ้ายที่ “น่ากลัว” กว่าในสายตาคนเพิ่งเริ่ม (error หน้าจอเต็มไปหมด) จริงๆ แล้วคือฝั่งที่ปลอดภัยที่สุด เพราะมันเป็นไปไม่ได้เลยที่ code พวกนั้นจะหลุดไปถึง server จริง ฝั่งขวาต่างหากที่กินคนมาแล้วนับไม่ถ้วน
กับดักที่ 1 (เงียบสนิท): block worker ทั้งเส้นโดยไม่มีใครเตือน
หัวข้อที่มีชื่อว่า “กับดักที่ 1 (เงียบสนิท): block worker ทั้งเส้นโดยไม่มีใครเตือน”เริ่มด้วยตัวที่แพงที่สุด นี่คือ ❌ version ดิบ — code สี่บรรทัดที่หน้าตาไร้พิษภัย:
use std::time::Duration;
#[tokio::main]async fn main() { tokio::spawn(async { std::thread::sleep(Duration::from_secs(1)); // block worker ทั้งเส้น }) .await .unwrap(); println!("SILENT_BUILD_NO_WARNING");}คำถามคือ compiler ว่าอย่างไร คำตอบคือ:
Finished `dev` profile [unoptimized + debuginfo] target(s) in 14.69sไม่มี warning สักบรรทัด แล้ว clippy ที่เข้มที่สุดล่ะ:
$ cargo clippy --target x86_64-unknown-linux-gnu -- -D warnings Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.92sเงียบสนิทเหมือนกัน และรันแล้วก็ได้ SILENT_BUILD_NO_WARNING ออกมาตามปกติ ทุกอย่าง “ผ่าน” ครบทุกด่านที่คุณมี
นี่คือหัวใจของกับดักนี้: std::thread::sleep ไม่ได้ผิดกฎอะไรของภาษาเลย มันเป็น function sync ธรรมดาที่ async block เรียกได้เต็มที่ (async เรียก sync ได้เสมอ — ปัญหาอยู่ทางกลับกัน ซึ่งเป็นเรื่องของกับดักที่ 4) compiler ไม่มีทางรู้ว่า function ที่คุณเรียกจะกินเวลา 50 นาโนวินาทีหรือ 50 วินาที มันจึงไม่มีทางเตือนคุณได้ การรู้ว่า code ของคุณ block อะไรไว้นานแค่ไหน เป็นงานของคุณคนเดียว ไม่มีเครื่องมือไหนทำแทนได้
พิสูจน์ว่ามันกินคนอื่นจริง
หัวข้อที่มีชื่อว่า “พิสูจน์ว่ามันกินคนอื่นจริง”พูดว่า “block worker” ยังเป็นนามธรรม เขียนให้เห็นด้วยตา — 2 task บน runtime ที่มี worker ตัวเดียว ตัวแรก sleep แบบ blocking 300ms ตัวที่สองแค่พิมพ์ว่าตัวเองได้รันตอนไหน:
async fn demo_blocking_bad() { let t0 = Instant::now(); let a = tokio::spawn(async { std::thread::sleep(Duration::from_millis(300)); }); let b = tokio::spawn(async move { println!("BAD other_task_ran blocked={}", t0.elapsed() >= Duration::from_millis(200)); }); let _ = tokio::join!(a, b);}ทางแก้คือย้ายงานที่ block ออกไปนอกเส้น async ด้วย tokio::task::spawn_blocking ซึ่งโยนงานลง blocking pool ที่เป็นคนละ pool กับ worker (ขนาด default ถึง 512 thread — มันถูกออกแบบมาให้ถูก block โดยเฉพาะ):
async fn demo_blocking_fixed() { let t0 = Instant::now(); let a = tokio::spawn(async { let _ = tokio::task::spawn_blocking(|| std::thread::sleep(Duration::from_millis(300))).await; }); let b = tokio::spawn(async move { println!("FIXED other_task_ran blocked={}", t0.elapsed() >= Duration::from_millis(200)); }); let _ = tokio::join!(a, b);}2 function นี้ต่างกันบรรทัดเดียว ผลที่รันจริงต่างกันคนละเรื่อง:
BAD other_task_ran blocked=trueFIXED other_task_ran blocked=falseblocked=true แปลว่า task ตัวที่สอง ไม่ได้แตะ CPU เลยตลอด 300 มิลลิวินาที ทั้งที่งานของมันคือ println! บรรทัดเดียว มันนั่งอยู่ในคิวของ worker ที่กำลังหลับอยู่ ส่วน blocked=false คือหลังย้ายไป blocking pool แล้ว worker ว่างทันทีและหยิบงานถัดไปได้เลย
signature ที่ต้องจำ (feature gate rt):
pub fn spawn_blocking<F, R>(f: F) -> JoinHandle<R>where F: FnOnce() -> R + Send + 'static, R: Send + 'staticสังเกตว่ามันรับ closure ธรรมดา ไม่ใช่ future — เพราะสิ่งที่คุณส่งเข้าไปคือ code sync ที่ตั้งใจจะ block และมันคืน JoinHandle เหมือน tokio::spawn คุณจึง .await ผลลัพธ์กลับมาได้ ทางเลือกอื่นที่ใช้ได้เหมือนกันคือ dedicated thread ที่คุณคุมเอง (เหมาะกับงานที่รันยาวตลอดอายุ process) หรือ rayon สำหรับงาน CPU-bound ที่ต้องการ work-stealing pool แยกจริงจัง
เส้นแบ่ง 10–100 ไมโครวินาที
หัวข้อที่มีชื่อว่า “เส้นแบ่ง 10–100 ไมโครวินาที”คำถามต่อไปที่ทุกคนถามคือ “แล้วแค่ไหนถึงเรียกว่า block” Alice Ryhl วางกฎไว้ตั้งแต่ 2020: code async ที่ประพฤติดี ควรแตะ .await ทุกราวๆ 10 ถึง 100 ไมโครวินาที ถ้าคุณเขียน block ที่ทำงานยาวกว่านั้นโดยไม่มี await point เลย คุณกำลังกัน runtime ไม่ให้สลับ task และควรย้ายมันออกไป
ตัวเลขนี้ไม่ใช่กฎเหล็ก มันเป็นแค่ order of magnitude ที่ช่วยตัดสินใจ: HashMap::insert หนึ่งครั้ง (ไมโครวินาทีต้นๆ) ปลอดภัยชัดเจน · อ่าน file ด้วย std::fs::read ไม่ปลอดภัยชัดเจน · parse JSON ก้อน 5MB หรือคำนวณ hash ของ block ใหญ่ๆ อยู่ในโซนที่ต้องวัดเอาเอง
ถ้าคุณเผลอเรียก blocking API ใน method async ของ C# ผลที่ได้มักเป็นแค่ ความสิ้นเปลือง — CLR ThreadPool มี thread เป็นหลักร้อย และยัง inject thread เพิ่มให้อัตโนมัติเมื่อ starve นานพอ code ยังเดินต่อได้ แค่ช้าลงและกินหน่วยความจำมากขึ้น คุณอาจไม่รู้ตัวเป็นปีก็ได้
Tokio ไม่ให้ของขวัญนั้น runtime แบบ multi_thread ตั้ง worker ไว้เท่าจำนวนคอร์ (บนเครื่อง 8 คอร์คือ 8 ตัว) และ จะไม่เพิ่มให้เลยไม่ว่าจะ starve แค่ไหน — worker ทั้งหมดที่มีคือทั้งหมดที่มี blocking call ตัวเดียวบนเครื่อง 8 คอร์คือการทิ้ง capacity ไป 12.5% ทันที และแปดตัวพร้อมกันคือ ระบบหยุดสนิท ทั้งที่ CPU ว่างและ metric ทุกตัวดูปกติ นี่คือเหตุผลว่าทำไม spawn_blocking ในโลก Rust สำคัญกว่า Task.Run ในโลก .NET มาก — ใน C# มันคือ optimization ใน Rust มันคือเงื่อนไขที่ระบบจะอยู่รอด
กับดักที่ 2 (ดังสนั่น): ถือ guard ข้าม await
หัวข้อที่มีชื่อว่า “กับดักที่ 2 (ดังสนั่น): ถือ guard ข้าม await”ตัดมาที่อีกฝั่งของแผนที่บ้าง นี่คือ ❌ version ดิบ ที่คนเขียน C# มาจะเขียนโดยไม่คิดอะไรเลย — ล็อก, ทำงาน async สักหน่อย, แล้วค่อยเขียนลง map:
use std::collections::HashMap;use std::sync::{Arc, Mutex};use std::time::Duration;
async fn tiny_await() { tokio::time::sleep(Duration::from_millis(1)).await;}
#[tokio::main]async fn main() { let m: Arc<Mutex<HashMap<u32, u32>>> = Arc::new(Mutex::new(HashMap::new())); tokio::spawn(async move { let mut g = m.lock().unwrap(); tiny_await().await; g.insert(1, 42); });}คราวนี้ compiler ไม่ได้เงียบ มันตอบยาวเหยียด (ตัดเหลือบรรทัดที่มีน้ำหนัก):
error: future cannot be sent between threads safely --> src/main.rs:12:5 | 12 | / tokio::spawn(async move { 13 | | let mut g = m.lock().unwrap(); 14 | | tiny_await().await; 15 | | g.insert(1, 42); 16 | | }); | |______^ future created by async block is not `Send` | = help: within `{async block@src/main.rs:12:18: 12:28}`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, HashMap<u32, u32>>`note: future is not `Send` as this value is used across an await --> src/main.rs:14:22 | 13 | let mut g = m.lock().unwrap(); | ----- has type `std::sync::MutexGuard<'_, HashMap<u32, u32>>` which is not `Send` 14 | tiny_await().await; | ^^^^^ await occurs here, with `mut g` maybe used laternote: required by a bound in `tokio::spawn` --> /home/nook/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.53.1/src/task/spawn.rs:176:21 |174 | pub fn spawn<F>(future: F) -> JoinHandle<F::Output> | ----- required by a bound in this function175 | where176 | F: Future + Send + 'static, | ^^^^ required by this bound in `spawn`อ่านย้อนจากล่างขึ้นบนจะเห็นตรรกะครบวง: tokio::spawn ต้องการ F: Future + Send เพราะ work-stealing scheduler อาจย้าย task ไป resume บน worker คนละตัวได้ทุกครั้งที่มัน suspend · std::sync::MutexGuard ไม่ใช่ Send โดยเจตนา เพราะ POSIX mutex บางระบบต้องปลดล็อกด้วย thread เดียวกับที่ล็อก · และตัวแปร g ยังมีชีวิตอยู่ ณ จุด await จึงกลายเป็น field ของ state machine ที่ compiler สร้าง ผลคือ state machine ทั้งก้อนไม่ Send ตามไปด้วย
ทางแก้ที่ได้ผล: ปิด guard ไว้ใน block
หัวข้อที่มีชื่อว่า “ทางแก้ที่ได้ผล: ปิด guard ไว้ใน block”async fn demo_guard_scope(m: Arc<Mutex<HashMap<u32, u32>>>) { let h = tokio::spawn(async move { { let mut g = m.lock().unwrap(); g.insert(1, 42); } // guard drop ที่ปิด block tiny_await().await; let g = m.lock().unwrap(); println!("GUARD_SCOPE_OK value={}", g[&1]); }); let _ = h.await;}GUARD_SCOPE_OK value=42❌ ทางแก้ที่ ดูเหมือน จะได้ผลแต่ไม่ได้ผล: drop(g)
หัวข้อที่มีชื่อว่า “❌ ทางแก้ที่ ดูเหมือน จะได้ผลแต่ไม่ได้ผล: drop(g)”นี่คือจุดที่คนอ่าน error message แล้วแก้ตามสัญชาตญาณและเสียเวลาไปทั้งบ่าย — “ก็แค่ปล่อย guard ก่อน await สิ”:
tokio::spawn(async move { let mut g = m.lock().unwrap(); g.insert(1, 42); drop(g); // ❌ ยังไม่พอ tiny_await().await;});รันจริงบน rustc 1.97.1 ได้ error ตัวเดิมเป๊ะ โดยชี้ไปที่บรรทัด await บรรทัดใหม่:
error: future cannot be sent between threads safely --> src/main.rs:12:5... = help: within `{async block@src/main.rs:12:18: 12:28}`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, HashMap<u32, u32>>`note: future is not `Send` as this value is used across an await --> src/main.rs:16:22 | 13 | let mut g = m.lock().unwrap(); | ----- has type `std::sync::MutexGuard<'_, HashMap<u32, u32>>` which is not `Send`... 16 | tiny_await().await; | ^^^^^ await occurs here, with `mut g` maybe used laterเหตุผลอยู่ในคำว่า maybe used later — การวิเคราะห์ว่าค่าไหนต้องอยู่ในโครง state machine ใช้เกณฑ์ สโคป ไม่ใช่ borrow-checker แบบ NLL ที่คุณคุ้นเคยจาก #21 ตราบใดที่ชื่อ g ยังประกาศอยู่ในสโคปเดียวกับ await point ชนิดของมันก็ยังถูกนับเข้าโครงสร้าง ถึงจะพิสูจน์ได้ว่าค่าถูกทิ้งไปแล้วก็ตาม
และเราตรวจซ้ำแล้วว่า ไม่ใช่เรื่องของ edition — compile ก้อนเดียวกันทั้งบน edition = "2024" และ edition = "2021" ได้ error เดียวกันแบบตัวต่อตัว วิธีเดียวที่ได้ผลจริงคือทำให้ชื่อนั้น หายไปจากสโคป คือปิดมันไว้ใน block { } หรือย้ายการล็อกทั้งก้อนเข้าไปใน function non-async แยกต่างหาก — ซึ่งเป็น pattern ที่บท 8 จะใช้ตลอดทั้ง capstone
compiler จับได้แค่ “ค่านี้ข้าม thread ไม่ได้” (Send) แต่จับไม่ได้เลยว่า “code นี้ใช้เวลานานเกินไป” — กับดักที่ทำให้ระบบจริงล่ม อยู่ในหมวดที่สองทั้งหมด
กับดักที่ 3: อะไรก็ตามที่ไม่ Send ข้าม await
หัวข้อที่มีชื่อว่า “กับดักที่ 3: อะไรก็ตามที่ไม่ Send ข้าม await”MutexGuard ไม่ได้พิเศษ มันเป็นแค่สมาชิกที่ดังที่สุดของครอบครัวใหญ่ — ทุกชนิดที่ไม่ Send จะทำให้ future ที่อุ้มมันข้าม await point กลายเป็น non-Send ทั้งก้อน สมาชิกที่เจอบ่อยคือ Rc, RefCell, Cell และ raw pointer:
use std::cell::RefCell;use std::rc::Rc;use std::time::Duration;
#[tokio::main]async fn main() { let counter = Rc::new(RefCell::new(0i32)); tokio::spawn(async move { *counter.borrow_mut() += 1; tokio::time::sleep(Duration::from_millis(1)).await; println!("{}", counter.borrow()); });}error: future cannot be sent between threads safely --> src/main.rs:8:5... = help: within `{async block@src/main.rs:8:18: 8:28}`, the trait `Send` is not implemented for `Rc<RefCell<i32>>`note: captured value is not `Send` --> src/main.rs:9:10 | 9 | *counter.borrow_mut() += 1; | ^^^^^^^ has type `Rc<RefCell<i32>>` which is not `Send`note: required by a bound in `tokio::spawn`สังเกตความต่างเล็กๆ ที่มีความหมาย: กรณี guard คำอธิบายคือ future is not Send as this value is used across an await (ปัญหาอยู่ที่ ข้าม await) ส่วนกรณี Rc คือ captured value is not Send (ปัญหาอยู่ตั้งแต่ ตอน capture เพราะ Rc ถูก move เข้ามาทั้งตัว) — ถ้าอ่าน note บรรทัดนี้เป็น คุณจะรู้ทันทีว่าต้องแก้ที่ scope ของ guard หรือแก้ที่ชนิดที่เลือกใช้
ทางแก้ของครอบครัวนี้คือเปลี่ยนไปใช้ญาติที่ Send: Rc → Arc, RefCell → Mutex หรือ RwLock (แล้วยังต้องระวัง guard ข้าม await ตามกฎข้อ 2 อยู่ดี) version ที่ผ่านและรันจริง:
async fn demo_arc_fix() { let c = Arc::new(Mutex::new(0i32)); let h = tokio::spawn(async move { { let mut g = c.lock().unwrap(); *g += 1; } tiny_await().await; println!("ARC_FIX_OK value={}", c.lock().unwrap()); }); let _ = h.await;}ARC_FIX_OK value=1ตำราหลายเล่ม (รวมถึงร่างแรกของบทนี้เอง) เรียกกับดักตระกูลนี้ว่า E0277 ซึ่ง ไม่ตรงกับของจริง บน rustc 1.97.1 — เราดึง diagnostic ออกมาเป็น JSON ด้วย cargo build --message-format=json แล้วได้ field "code": null ตรงๆ
พูดอีกอย่างคือมันขึ้นต้นด้วย error: เปล่าๆ ไม่ใช่ error[E0277]: เพราะเป็น diagnostic เฉพาะทางที่ rustc สร้างขึ้นเพื่ออธิบาย Send bound ของ future โดยเฉพาะ ไม่ใช่ error ทั่วไปเรื่อง trait ไม่ถูก implement คุณจึง rustc --explain มันไม่ได้ และ grep หา E0277 ในล็อก CI ก็จะไม่เจอ สิ่งที่ต้อง grep คือ string future cannot be sent between threads safely
และฝาแฝดเงียบของกับดักตระกูลนี้: ตัวแพ้ใน select! ที่ถือของค้างไว้ครึ่งทาง
หัวข้อที่มีชื่อว่า “และฝาแฝดเงียบของกับดักตระกูลนี้: ตัวแพ้ใน select! ที่ถือของค้างไว้ครึ่งทาง”ตระกูล !Send ทั้งหมดข้างบนดังสนั่นเพราะมันเป็นคำถามเรื่อง ชนิด ซึ่ง compiler ตอบได้ แต่มีคำถามพี่น้องอีกข้อที่หน้าตาคล้ายกันมากและ compiler ตอบไม่ได้เลย: ค่าที่มีชีวิตอยู่ข้าม .await นั้น จะเป็นอย่างไรถ้า future ทั้งก้อนถูก drop ตรงจุดนั้นพอดี
จุดที่มันกัดบ่อยที่สุดคือ select! ใน loop ตัวแพ้ของทุกรอบถูก drop ทิ้ง และถ้าตัวแพ้นั้นคือ operation ที่ทำงานไปแล้วครึ่งทาง — read_exact ที่ดูด byte ออกจาก socket ไปแล้วแต่ยังไม่ครบ buffer เป็นตัวอย่างมาตรฐาน — byte ที่มันดูดไปแล้วจะหายไปพร้อมกับ future ที่ถูก drop ไม่มี panic ไม่มี error ไม่มี warning มีแต่ข้อมูลที่หายเป็นๆ หายๆ ตามจังหวะเวลา สังเกตความอสมมาตรของสองกรณีนี้ให้ดี: ถือ MutexGuard ข้าม await ในงานที่ spawn = error หน้าจอเต็ม · ถือ read_exact ค้างไว้ในแขนที่แพ้ของ select! = compile ผ่านสะอาด clippy เงียบ แล้วข้อมูลหาย
เรื่องนี้คือหัวข้อ cancellation safety ที่ 🔁 บท 5 — select! กับการยกเลิก พิสูจน์ให้เห็นกับตาไปแล้วด้วย loop ที่ได้ buf = "EFGH\0\0\0\0" ออกมาแทนที่จะเป็นข้อมูลครบ8 byte พร้อมทางแก้คือ tokio::pin! ตัว operation ไว้นอก loop แล้ว poll &mut op ให้มัน resume ไม่ใช่ restart ที่ยกมาย้ำตรงนี้เพราะมันคือสมาชิกเต็มตัวอีกคนหนึ่งของ ตระกูลเงียบสนิท ที่บทนี้ทั้งบทกำลังไล่นับ ไม่ใช่แค่เชิงอรรถของบท 5
กับดักที่ 4: function colouring — async ติดเชื้อทั้ง call stack
หัวข้อที่มีชื่อว่า “กับดักที่ 4: function colouring — async ติดเชื้อทั้ง call stack”ปี 2015 Bob Nystrom เขียนบทความชื่อ What Color is Your Function? ที่ทำให้ function-colouringfunction-colouringasync ติดเชื้อทั้ง call stack; sync เรียก async ต้องผ่าน runtime; Rust ติดถึงระดับ type กลายเป็นศัพท์มาตรฐานของวงการไปแล้ว: ในภาษาที่มี async/await function จะมี “สี” สองสี function สีแดง (async) เรียกสีน้ำเงิน (sync) ได้เสมอ แต่สีน้ำเงินเรียกสีแดงไม่ได้ ผลคือทันทีที่คุณทำให้ function ชั้นล่างสุดเป็น async ทุกคนที่เรียกมันขึ้นไปตลอด call stack ต้องเปลี่ยนสีตาม
ใน C# ปัญหานี้มีอยู่ แต่มีทางลัดที่ ใช้ได้ อยู่: .Result และ .GetAwaiter().GetResult() — คุณโดนเตือนว่ามันเสี่ยง deadlock ใน UI/ASP.NET แต่ในหลายบริบทมันก็ทำงานได้จริง ใน Rust ทางลัดแบบนั้น ไม่มี ลองดูว่าเกิดอะไรขึ้นเมื่อ function sync พยายามเรียก async ตรงๆ:
async fn price_of(id: u32) -> u32 { tokio::time::sleep(std::time::Duration::from_millis(1)).await; id * 10}
// function sync ที่อยากเรียก asyncfn total_price(id: u32) -> u32 { price_of(id).await}
#[tokio::main]async fn main() { println!("{}", total_price(4));}error[E0728]: `await` is only allowed inside `async` functions and blocks --> src/main.rs:8:18 |7 | fn total_price(id: u32) -> u32 { | ------------------------------ this is not `async`8 | price_of(id).await | ^^^^^ only allowed inside `async` functions and blocksE0728 — ตัวนี้มีรหัสจริงและ rustc --explain E0728 ได้ นี่คือ colouring ที่ถูกบังคับใช้ระดับไวยากรณ์ และ note บรรทัด this is not async ก็บอกทางแก้เดียวที่มี: ทาสี function นี้เป็น async ด้วย แล้วไล่ทาขึ้นไปต่อเนื่องจนถึง main
แล้วถ้าไม่อยากทาสีขึ้นไปล่ะ
หัวข้อที่มีชื่อว่า “แล้วถ้าไม่อยากทาสีขึ้นไปล่ะ”ประตูเดียวที่เหลือคือ block_on เหมือนที่เราเขียนเองในบท 1 ลองใช้มันเป็นทางลัดแบบ .Result ดู:
// ❌ ประตูจากโลก sync ที่เปิดผิดที่: เรียกจากใน async contextfn total_price(id: u32) -> u32 { tokio::runtime::Handle::current().block_on(price_of(id))}
#[tokio::main]async fn main() { println!("{}", total_price(4));}คราวนี้ compile ผ่านสะอาด — แล้วรันแล้วระเบิด:
thread 'main' (97292) panicked at src/main.rs:8:39:Cannot start a runtime from within a runtime. This happens because a function (like `block_on`) attempted to block the current thread while the thread is being used to drive asynchronous tasks.note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceexit code 101 process ตาย นี่คือ gap เดียวในบทนี้ที่กระโดดข้ามฝั่งแผนที่ — compiler ปล่อยผ่าน แต่ runtime ปฏิเสธด้วย panic ที่ชัดเจนแทน (ซึ่งยังนับว่าโชคดี เพราะ version ที่ ไม่ panic ในภาษาอื่นคือ version ที่ deadlock เงียบๆ)
สรุปตรงๆ: ใน Rust ไม่มี .Result ที่ปลอดภัย ทางเดียวที่ถูกต้องคือทา async ขึ้นไปทั้งสาย หรือถ้าจำเป็นต้องเรียก async จากโลก sync จริงๆ ก็ต้องเป็นโลก sync ที่ ไม่ได้อยู่บน worker thread ของ runtime เช่นใน function ที่ถูกเรียกผ่าน spawn_blocking หรือใน thread ที่คุณสร้างเอง
และมันติดถึงระดับ type ด้วย
หัวข้อที่มีชื่อว่า “และมันติดถึงระดับ type ด้วย”จุดที่ Rust หนักกว่า C# คือสีมันไม่ได้อยู่แค่ที่ signature แต่ อยู่ในระบบชนิด — fn f() -> u32 กับ async fn f() -> u32 คืนคนละชนิดกันจริงๆ (ตัวหลังคืน impl Future<Output = u32> ที่เป็น opaque type นิรนาม) trait จึงรับผลกระทบเต็มๆ: async fn ใน trait เพิ่งเข้า stable ที่ Rust 1.75 (2023-12-21) พร้อม RPIT in traits และจนถึงวันนี้มัน ยังไม่ dyn-compatible ลองประกาศ trait ที่มี async fn แล้วเอาไปทำ dyn ดู — file src/bin/red_dyn.rs ทั้ง file คือแค่นี้:
trait PriceSource { async fn price_of(&self, id: u32) -> u32;}
fn main() { let _sources: Vec<Box<dyn PriceSource>> = Vec::new();}error[E0038]: the trait `PriceSource` is not dyn compatible --> src/bin/red_dyn.rs:6:31 |6 | let _sources: Vec<Box<dyn PriceSource>> = Vec::new(); | ^^^^^^^^^^^ `PriceSource` is not dyn compatible...2 | async fn price_of(&self, id: u32) -> u32; | ^^^^^^^^ ...because method `price_of` is `async` = help: consider moving `price_of` to another traitผลคือ ecosystem แตกเป็นสองโลก — crate สาย sync กับ crate สาย async แทบไม่ใช้ code ร่วมกันเลย และ pattern “เก็บ implementation หลายตัวไว้ใน Vec ของ Box<dyn Trait>” ที่คุณใช้ตลอดใน #21–#23 ต้องเปลี่ยนวิธี (workaround ที่คนใช้กันคือ crate async-trait ซึ่งแลกด้วย Box หนึ่งใบต่อการเรียกหนึ่งครั้ง) — รายละเอียดชั้นลึกกว่านี้อยู่นอกขอบเขตคอร์สนี้ แค่รู้ว่ากำแพงนี้มีอยู่จริงก็พอ
กับดักที่ 5 (เงียบสนิท): .await ใน loop ฆ่า concurrency ทิ้งโดยไม่มีใครรู้
หัวข้อที่มีชื่อว่า “กับดักที่ 5 (เงียบสนิท): .await ใน loop ฆ่า concurrency ทิ้งโดยไม่มีใครรู้”กลับมาฝั่งเงียบอีกครั้ง กับดักตัวนี้พิเศษตรงที่ code มัน ถูกต้องทุกประการ — ผลลัพธ์ครบ ไม่มี bug ไม่มี panic มีแค่ throughput ที่หายไปโดยไม่มีใครสังเกต นี่คือ ❌ version ดิบ:
async fn work(id: u32, ms: u64) -> u32 { tokio::time::sleep(Duration::from_millis(ms)).await; println!(" finished item={id}"); id}
async fn demo_sequential() { let t0 = Instant::now(); println!("SEQ"); for (id, ms) in [(1u32, 300u64), (2, 200), (3, 100)] { work(id, ms).await; // ❌ รอให้จบก่อนค่อยเริ่มตัวถัดไป } println!("SEQ_TOTAL over_550ms={}", t0.elapsed() >= Duration::from_millis(550));}เทียบกับ version ที่ spawn ต่อ item แล้วค่อยรอทีเดียว:
async fn demo_concurrent() { let t0 = Instant::now(); println!("CONCURRENT"); let mut hs = Vec::new(); for (id, ms) in [(1u32, 300u64), (2, 200), (3, 100)] { hs.push(tokio::spawn(work(id, ms))); } for h in hs { let _ = h.await; } println!("CONCURRENT_TOTAL under_400ms={}", t0.elapsed() < Duration::from_millis(400));}รันจริงติดกันสามรอบ ได้ผลเหมือนกันทุกรอบ:
SEQ finished item=1 finished item=2 finished item=3SEQ_TOTAL over_550ms=trueCONCURRENT finished item=3 finished item=2 finished item=1CONCURRENT_TOTAL under_400ms=trueลำดับที่จบคือหลักฐาน ฝั่ง SEQ จบตามลำดับที่สั่ง 1-2-3 เพราะแต่ละตัวเริ่มหลังตัวก่อนหน้าจบ รวมเวลา 300+200+100 = 600ms ส่วนฝั่ง CONCURRENT จบเรียงตาม ระยะเวลาของงาน คือ 3-2-1 เพราะทั้งสามเริ่มพร้อมกัน รวมเวลาเท่ากับตัวที่ช้าที่สุดคือ 300ms — เร็วขึ้นเท่าตัวจากการย้าย .await ออกจาก loop เฉยๆ
นี่คือกับดักเดียวกับ await ใน foreach ของ C# ที่ควรเป็น Task.WhenAll เป๊ะๆ ต่างกันแค่ว่าใน C# บางครั้ง analyzer ยังพอเตือนได้บ้าง ส่วนใน Rust ไม่มีใครเตือนเลย
ถ้าอ่านหัวข้อนี้แล้วสรุปว่า “งั้น spawn ให้หมดสิ” คุณเพิ่งเปิดช่องโหว่ DoS ให้ตัวเอง — tokio::spawn ใน loop ที่ขนาด loop มาจาก input ของผู้ใช้ แปลว่าใครก็ตามที่ยิงคำขอมาหนึ่งครั้ง สั่งให้คุณสร้าง task ได้ไม่จำกัดจำนวน แต่ละตัวกินหน่วยความจำและคิวของ scheduler จนล้ม
concurrency ที่ปลอดภัยต้อง มีเพดาน เสมอ เครื่องมือมาตรฐานคือ tokio::sync::Semaphore (feature "sync")
ใส่เพดานด้วย Semaphore::acquire_owned
หัวข้อที่มีชื่อว่า “ใส่เพดานด้วย Semaphore::acquire_owned”async fn demo_semaphore() -> usize { let sem = Arc::new(Semaphore::new(2)); let inflight = Arc::new(AtomicUsize::new(0)); let peak = Arc::new(AtomicUsize::new(0)); let mut hs = Vec::new(); for _ in 0..6 { let permit = Arc::clone(&sem).acquire_owned().await.unwrap(); let inflight = Arc::clone(&inflight); let peak = Arc::clone(&peak); hs.push(tokio::spawn(async move { let _p = permit; // ปล่อยคืนตอน drop ปลาย task let now = inflight.fetch_add(1, Ordering::SeqCst) + 1; peak.fetch_max(now, Ordering::SeqCst); tokio::time::sleep(Duration::from_millis(50)).await; inflight.fetch_sub(1, Ordering::SeqCst); })); } for h in hs { let _ = h.await; } peak.load(Ordering::SeqCst)}SEMAPHORE_PEAK_INFLIGHT=2acquire_owned(self: Arc<Self>) คืน Result<OwnedSemaphorePermit, AcquireError> — ตัว OwnedSemaphorePermit เป็นเจ้าของ permit แบบไม่ผูกกับ lifetime ของ semaphore มันจึง move เข้าไปใน task ที่ spawn ได้ (ต่างจาก acquire() ธรรมดาที่คืน Result<SemaphorePermit<'_>, AcquireError> คือ permit แบบยืม ซึ่งข้าม 'static boundary ของ spawn ไม่ได้) ฝั่ง Err คือ AcquireError ที่เกิดเมื่อ semaphore ถูก close() ไปแล้ว เดโมข้างบนจึง .unwrap() ได้เพราะเราไม่เคยปิดมัน และเพราะ permit ถูกคืนตอน drop อัตโนมัติ คุณจึงไม่มีทางลืมคืนแม้ task จะ panic หรือถูกยกเลิกกลางคัน
จุดสำคัญคือตำแหน่งของ .await: เราเรียก acquire_owned().await ก่อน tokio::spawn ไม่ใช่ข้างใน ถ้าย้ายเข้าไปข้างใน task ทั้ง 6 ตัวจะถูกสร้างขึ้นมาก่อนแล้วค่อยไปรอคิวกันข้างใน ซึ่งไม่ได้กันหน่วยความจำอะไรเลย — เพดานต้องอยู่ที่ ปากทาง ถึงจะเป็นเพดานจริง เขียนแบบนี้ accept loop จะหยุดรับคอนเนกชันใหม่เองเมื่อเต็มโควตา คือ backpressure จากบท 6 ในอีกรูปหนึ่ง
test ที่ยืนยันเพดานนี้รันจริงและผ่าน:
#[cfg(test)]mod tests { use super::*;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn semaphore_bounds_concurrency_at_two() { assert_eq!(demo_semaphore().await, 2); }}running 1 testtest tests::semaphore_bounds_concurrency_at_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.16sประกอบทั้งหมดแล้วรันครั้งเดียว
หัวข้อที่มีชื่อว่า “ประกอบทั้งหมดแล้วรันครั้งเดียว”main ที่ขับทุก demo ข้างบนคือตัวนี้ — use ทุกบรรทัดที่ file นี้ต้องใช้อยู่ครบใน block เดียวกัน:
use std::collections::HashMap;use std::sync::atomic::{AtomicUsize, Ordering};use std::sync::{Arc, Mutex};use std::time::{Duration, Instant};use tokio::sync::Semaphore;
#[tokio::main(flavor = "multi_thread", worker_threads = 1)]async fn main() { demo_blocking_bad().await; demo_blocking_fixed().await; demo_guard_scope(Arc::new(Mutex::new(HashMap::new()))).await; demo_sequential().await; demo_concurrent().await; println!("SEMAPHORE_PEAK_INFLIGHT={}", demo_semaphore().await); demo_arc_fix().await;}เอา tiny_await, demo_blocking_bad, demo_blocking_fixed, demo_guard_scope, work, demo_sequential, demo_concurrent, demo_semaphore, demo_arc_fix จากหัวข้อก่อนหน้ามาต่อท้ายใน file เดียวกัน แล้ว cargo run ครั้งเดียวได้ทั้งหมดนี้:
BAD other_task_ran blocked=trueFIXED other_task_ran blocked=falseGUARD_SCOPE_OK value=42SEQ finished item=1 finished item=2 finished item=3SEQ_TOTAL over_550ms=trueCONCURRENT finished item=3 finished item=2 finished item=1CONCURRENT_TOTAL under_400ms=trueSEMAPHORE_PEAK_INFLIGHT=2ARC_FIX_OK value=1worker_threads = 1 ในบรรทัด #[tokio::main] ไม่ใช่ของประดับ — มันคือสิ่งที่ทำให้ blocked=true เป็นผลที่ แน่นอน ไม่ใช่เรื่องบังเอิญของ scheduler บนเครื่อง 8 คอร์คุณก็จะเจอ blocked=true เหมือนกัน แค่ต้องมี task ที่ block ครบ 8 ตัวก่อน ซึ่งบนโปรดักชันจริงคือเรื่องปกติมาก
แผนที่คำศัพท์จาก C#/.NET
หัวข้อที่มีชื่อว่า “แผนที่คำศัพท์จาก C#/.NET”| C# / .NET | Rust / Tokio | จุดที่ต่างจริง |
|---|---|---|
await ใน lock/Monitor = compile error CS1996 | MutexGuard ไม่ Send → future cannot be sent between threads safely | ผลลัพธ์เดียวกัน กลไกคนละแบบ — C# ตั้งกฎเฉพาะไว้ที่ syntax ส่วน Rust ได้มาฟรีจาก auto-trait Send ที่ใช้ทั่วทั้งภาษา |
SemaphoreSlim.WaitAsync() เป็นล็อกที่ await ได้ | tokio::sync::Mutex::lock().await | ทางออกคู่กันเมื่อจำเป็นต้องถือล็อกข้าม await จริงๆ (แลกด้วยความเร็ว — บท 6 อธิบายว่าเมื่อไหร่ควรใช้) |
Task.Run(syncWork) offload งาน sync | tokio::task::spawn_blocking(closure) | ใน .NET เป็น optimization ใน Rust เป็นเงื่อนไขการอยู่รอด เพราะ worker มีไม่กี่ตัวและไม่ inject เพิ่ม |
| ThreadPool inject thread เพิ่มเองเมื่อ starve | worker = จำนวนคอร์ คงที่ ไม่เพิ่มให้ | blocking ใน .NET = ช้าลง · blocking ใน Tokio = capacity หายเป็นเปอร์เซ็นต์ต่อครั้ง |
await ใน foreach แทน Task.WhenAll | .await ใน loop แทน spawn ต่อ item | กับดักเดียวกันเป๊ะ แต่ฝั่ง Rust ไม่มี analyzer ตัวไหนเตือนเลย |
.Result / .GetAwaiter().GetResult() — เสี่ยงแต่ใช้ได้ | Handle::current().block_on(...) — panic ทันทีในบริบท async | ใน Rust ไม่มีทางลัดที่ปลอดภัย มีแต่การทาสี async ขึ้นไปทั้งสาย |
async ใน interface ทำได้มาตลอด | async fn ใน trait มาที่ 1.75 (2023-12-21) และยังไม่ dyn-compatible | Box<dyn Trait> ที่มี method async ยังต้องพึ่ง workaround อยู่ |
A — Future ขี้เกียจจริง: กับดักที่ 5 คือด้านมืดของความขี้เกียจนั้น work(id, ms) ไม่เริ่มทำอะไรจนกว่าจะโดน .await หรือ spawn การเขียน .await ติดกันใน loop จึงแปลว่า “สั่งให้เริ่มทีละตัว” ตรงตัว ไม่ใช่ผลข้างเคียงแปลกๆ ของ runtime
B — std ไม่มี runtime มาให้: spawn_blocking กับ Semaphore ที่บทนี้ใช้เป็นของ Tokio ล้วน ไม่มีใน std และนี่คือเหตุผลที่การเลือก runtime เป็นการตัดสินใจที่ผูกมัดจริง — ย้าย runtime แปลว่าต้องหาของแทนทุกชิ้นในบทนี้
C — function colouring จริง และแรงกว่า C# (เส้นหลักของบทนี้): เราพิสูจน์ครบสามชั้นด้วยผลรันจริง — ชั้นไวยากรณ์ E0728 ห้าม .await ใน function sync · ชั้น runtime block_on ในบริบท async panic ทันทีด้วย Cannot start a runtime from within a runtime (exit 101) ไม่มี .Result ที่ปลอดภัยให้ใช้แบบ C# · ชั้น type async fn ใน trait เพิ่งมาที่ 1.75 และยังทำ dyn ไม่ได้ (E0038) นี่คือสิ่งที่บท 1 สัญญาไว้ว่าจะเก็บให้เต็มในบท 7
D — แซนด์บ็อกซ์และการตรวจ: ทั้ง 8 บทของคอร์สนี้ตรวจด้วย recipe เดียวคือ build/run/test/clippy บน target native x86_64-unknown-linux-gnu — กล musl + bundled rust-lld ที่ #22/#23 ใช้ได้เพราะเป็น zero-crate ปลดระวางไปตั้งแต่คอร์สนี้เริ่ม เพราะ tokio ลาก native dependency เข้ามาจริง สิ่งที่ได้กลับมาคือบทนี้ ไม่มีอะไรเป็นไดอะแกรมอย่างเดียว — error ทุกก้อน panic ทุกบรรทัด และ blocked=true ทุกตัว มาจากการรันจริงบน rustc 1.97.1 / tokio 1.53.1
สิ่งที่บทนี้ ไม่ อ้าง: เราไม่ได้วัด throughput เป็น req/s ที่ไหนเลย และไม่ได้อ้างว่า spawn_blocking ทำให้ระบบเร็วขึ้นกี่เท่า — สิ่งที่วัดได้จริงในแซนด์บ็อกซ์คือ ลำดับ กับ เพดานเวลา แบบ boolean เท่านั้น ตัวเลข 10–100µs ก็เป็น rule of thumb ของ Ryhl ไม่ใช่ผลวัดของเรา
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”จัดกับดัก async ตามเสียงของ compiler แล้วจำได้ง่ายกว่าจัดตามหัวข้อ API เยอะ ตระกูลเสียงดัง — MutexGuard/Rc/RefCell ข้าม await ในงานที่ spawn — ตายที่ cargo build ด้วยข้อความ future cannot be sent between threads safely ที่ไม่มีรหัส error กำกับ (ยืนยันแล้วว่า "code": null) ทางแก้คือปิด guard ไว้ใน block { } ไม่ใช่ drop(g) ซึ่ง ยังพังเหมือนเดิมทั้งบน edition 2024 และ 2021 เพราะเกณฑ์คือสโคป ไม่ใช่ NLL · ตระกูลเงียบสนิท อันตรายกว่ามาก: std::thread::sleep ใน async block ผ่านทั้ง build และ clippy -- -D warnings แบบไม่มี warning เลย แต่กิน worker ทั้งเส้น (blocked=true) และเจ็บกว่า .NET หลายเท่าเพราะ Tokio ไม่ inject worker เพิ่ม ทางแก้คือ spawn_blocking โดยยึดกฎแตะ .await ทุก ~10–100µs · .await ใน loop ทำให้งานเรียงกันทีละตัวเงียบๆ (SEQ จบ 1-2-3 ใน 600ms ส่วน spawn ต่อ item จบ 3-2-1 ใน 300ms) แต่การ spawn ไม่จำกัดคือช่องโหว่ DoS จึงต้องใส่เพดานด้วย Semaphore::acquire_owned() ที่ ปากทาง · สมาชิกเงียบอีกคนที่ต้องจำคู่กันคือ operation ที่ทำไปแล้วครึ่งทางแล้วไปเป็นตัวแพ้ใน select! — ถูก drop พร้อม byte ที่ดูดมาแล้ว โดยไม่มี panic ไม่มี warning · และ function colouring ใน Rust ติดเชื้อครบสามชั้น — syntax (E0728), runtime (panic Cannot start a runtime from within a runtime) และ type (async fn ใน trait ที่ 1.75 ยังไม่ dyn-compatible)
บท 8 คือ capstone ที่เอากับดักทั้งห้าตัวนี้มาใช้จริง: เรายก server ของ kaen-kvstore จาก #22 ขึ้น Tokio ทั้งตัว เปลี่ยนจาก worker ของ ThreadPool ที่ถูกยึดไว้ทั้งเส้น เป็น1 task ต่อคอนเนกชัน ใช้ framing เดิมคือ u32 little-endian นำหน้าความยาว ล็อก HashMap ด้วย std::sync::Mutex ใน function non-async ให้ guard drop ก่อนกลับเสมอ (กับดักที่ 2 ตรงๆ) ย้าย fsync/compaction ไป spawn_blocking (กับดักที่ 1) ใส่ Semaphore ที่ accept loop (กับดักที่ 5) แล้วปิดท้ายด้วย graceful shutdown ครบสามส่วน — ctrl_c ตรวจจับ, broadcast แพร่สัญญาณ, mpsc รอ drain
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- Async: What is blocking? — Alice Ryhl, 2020-12-21 (เข้าถึง 2026-07-27) — ที่มาของกฎ แตะ
.awaitทุก ~10–100 ไมโครวินาที และคำอธิบายว่า code ที่ block กัน runtime ไม่ให้สลับ task พร้อมสามทางออกมาตรฐาน:spawn_blocking, dedicated thread และ rayon สำหรับงาน CPU-bound - Shared state — Tokio Tutorial (เข้าถึง 2026-07-27) —
std::sync::MutexGuardไม่Sendและเงื่อนไขที่ std Mutex ยังเหมาะกว่า tokio Mutex คือ contention ต่ำและ ไม่ถือ guard ข้าม.await· ผลรันในแซนด์บ็อกซ์ของเราเมื่อ 2026-07-26 ยืนยันครบว่า guard ข้าม await ฟ้องfuture cannot be sent between threads safely, block-scope ผ่าน,drop(g)ยังไม่ผ่าน และstd::thread::sleepใน async block compile สะอาด - Announcing
async fnand return-positionimpl Traitin traits — Rust Blog, 2023-12-21 (เข้าถึง 2026-07-27) —async fnใน trait เข้า stable ที่ Rust 1.75 พร้อม RPIT in traits และข้อจำกัดที่ยังเหลืออยู่เรื่อง dyn compatibility - What Color is Your Function? — Bob Nystrom, 2015-02-01 (เข้าถึง 2026-07-27) — ต้นทางของศัพท์ function colouring: async เรียก sync ได้ แต่ sync เรียก async ไม่ได้ถ้าไม่มี runtime และ async ติดเชื้อขึ้นทั้ง call stack
- Cancelling async Rust — sunshowers, 2025 (เข้าถึง 2026-07-27) — cancellation คือการ drop future ที่
.awaitpoint ซึ่งทำให้ branch ที่แพ้ในselect!ทิ้ง partial state ไปเงียบๆ 🔁 บท 5 ปูเรื่องนี้ไว้แล้ว และบท 8 จะกลับมาเก็บในรูป cancellation safety ของ shutdown branch
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3code tokio::spawn(async { std::thread::sleep(Duration::from_secs(1)); }) ผ่านทั้ง cargo build และ cargo clippy -- -D warnings โดยไม่มี warning สักบรรทัด ข้อใดอธิบายสถานการณ์นี้ได้ถูกต้อง?