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

กับดัก 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 พีค พวก​นี้​คือ​ของ​จริง และ​บท​นี้​ใช้​เวลา​ส่วน​ใหญ่​กับ​พวก​มัน

📦 kaen-kvstore

คอร์ส​นี้ ต่อยอด 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 ไม่​จำกัด​จำนวน

toolchain + feature scope ของ​บท​นี้

ทุก 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=true
FIXED other_task_ran blocked=false

blocked=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 แยก​จริงจัง

คำถาม​ต่อ​ไป​ที่​ทุก​คน​ถาม​คือ “แล้ว​แค่​ไหน​ถึง​เรียก​ว่า 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 ใหญ่ๆ อยู่​ใน​โซน​ที่​ต้อง​วัด​เอา​เอง

ทำไม​กับดัก​นี้​เจ็บ​กว่า​ใน .NET หลาย​เท่า

ถ้า​คุณ​เผลอ​เรียก 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 มัน​คือ​เงื่อนไข​ที่​ระบบ​จะ​อยู่​รอด

ตัด​มา​ที่​อีก​ฝั่ง​ของ​แผนที่​บ้าง นี่​คือ ❌ 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 later
note: 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 function
175 | where
176 | 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 ตาม​ไป​ด้วย

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 นี้​ใช้​เวลา​นาน​เกิน​ไป” — กับดัก​ที่​ทำให้​ระบบ​จริง​ล่ม อยู่​ใน​หมวด​ที่​สอง​ทั้งหมด

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: RcArc, RefCellMutex หรือ 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
ข้อสังเกต​ที่​ต้อง​พูดตรงๆ: diagnostic ชุด​นี้​ไม่มี​รหัส error

ตำรา​หลาย​เล่ม (รวม​ถึง​ร่าง​แรก​ของ​บท​นี้​เอง) เรียก​กับดัก​ตระกูล​นี้​ว่า 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

ปี 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 ที่อยากเรียก async
fn 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 blocks

E0728 — ตัว​นี้​มี​รหัส​จริง​และ rustc --explain E0728 ได้ นี่​คือ colouring ที่​ถูก​บังคับ​ใช้​ระดับ​ไวยากรณ์ และ note บรรทัด this is not async ก็​บอก​ทาง​แก้​เดียว​ที่​มี: ทาสี function นี้​เป็น async ด้วย แล้ว​ไล่​ทา​ขึ้น​ไป​ต่อ​เนื่อง​จนถึง main

ประตู​เดียว​ที่​เหลือ​คือ block_on เหมือน​ที่​เรา​เขียน​เอง​ใน​บท 1 ลอง​ใช้​มัน​เป็น​ทาง​ลัด​แบบ .Result ดู:

// ❌ ประตูจากโลก sync ที่เปิดผิดที่: เรียกจากใน async context
fn 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 backtrace

exit code 101 process ตาย นี่​คือ gap เดียว​ใน​บท​นี้​ที่​กระโดด​ข้าม​ฝั่ง​แผนที่ — compiler ปล่อย​ผ่าน แต่ runtime ปฏิเสธ​ด้วย panic ที่​ชัดเจน​แทน (ซึ่ง​ยัง​นับ​ว่า​โชค​ดี เพราะ version ที่ ไม่ panic ใน​ภาษา​อื่น​คือ version ที่ deadlock เงียบๆ)

สรุปตรงๆ: ใน Rust ไม่มี .Result ที่​ปลอดภัย ทาง​เดียว​ที่​ถูกต้อง​คือ​ทา async ขึ้น​ไป​ทั้ง​สาย หรือ​ถ้า​จำเป็น​ต้อง​เรียก async จาก​โลก sync จริงๆ ก็​ต้อง​เป็น​โลก sync ที่ ไม่​ได้​อยู่​บน worker thread ของ runtime เช่น​ใน function ที่​ถูก​เรียก​ผ่าน spawn_blocking หรือ​ใน thread ที่​คุณ​สร้าง​เอง

จุด​ที่ 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=3
SEQ_TOTAL over_550ms=true
CONCURRENT
finished item=3
finished item=2
finished item=1
CONCURRENT_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 ทุก​อย่าง' คือ​กับดัก​ถัด​ไป​ทันที

ถ้า​อ่าน​หัวข้อ​นี้​แล้ว​สรุป​ว่า “งั้น spawn ให้​หมด​สิ” คุณ​เพิ่ง​เปิด​ช่อง​โหว่ DoS ให้​ตัวเอง — tokio::spawn ใน loop ที่​ขนาด loop มา​จาก input ของ​ผู้​ใช้ แปล​ว่า​ใคร​ก็ตาม​ที่​ยิง​คำขอ​มา​หนึ่ง​ครั้ง สั่ง​ให้​คุณ​สร้าง task ได้​ไม่​จำกัด​จำนวน แต่ละ​ตัว​กิน​หน่วย​ความ​จำ​และ​คิว​ของ scheduler จน​ล้ม

concurrency ที่​ปลอดภัย​ต้อง มี​เพดาน เสมอ เครื่องมือ​มาตรฐาน​คือ tokio::sync::Semaphore (feature "sync")

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=2

acquire_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 test
test 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=true
FIXED other_task_ran blocked=false
GUARD_SCOPE_OK value=42
SEQ
finished item=1
finished item=2
finished item=3
SEQ_TOTAL over_550ms=true
CONCURRENT
finished item=3
finished item=2
finished item=1
CONCURRENT_TOTAL under_400ms=true
SEMAPHORE_PEAK_INFLIGHT=2
ARC_FIX_OK value=1

worker_threads = 1 ใน​บรรทัด #[tokio::main] ไม่ใช่​ของ​ประดับ — มัน​คือ​สิ่ง​ที่​ทำให้ blocked=true เป็น​ผล​ที่ แน่นอน ไม่ใช่​เรื่อง​บังเอิญ​ของ scheduler บน​เครื่อง 8 คอร์​คุณ​ก็​จะ​เจอ blocked=true เหมือน​กัน แค่​ต้อง​มี task ที่ block ครบ 8 ตัว​ก่อน ซึ่ง​บน​โปร​ดัก​ชัน​จริง​คือ​เรื่อง​ปกติ​มาก

C# / .NETRust / Tokioจุด​ที่​ต่าง​จริง
await ใน lock/Monitor = compile error CS1996MutexGuard ไม่ Sendfuture 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 งาน synctokio::task::spawn_blocking(closure)ใน .NET เป็น optimization ใน Rust เป็น​เงื่อนไข​การ​อยู่​รอด เพราะ worker มี​ไม่​กี่​ตัว​และ​ไม่ inject เพิ่ม
ThreadPool inject thread เพิ่ม​เอง​เมื่อ starveworker = จำนวน​คอร์ คงที่ ไม่​เพิ่ม​ให้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-compatibleBox<dyn Trait> ที่​มี method async ยัง​ต้อง​พึ่ง workaround อยู่
honesty spine — สี่​เส้น​เดิม บท​นี้​ตอก​เส้น​ที่​สาม

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 fn and return-position impl Trait in 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 ที่ .await point ซึ่ง​ทำให้ branch ที่​แพ้​ใน select! ทิ้ง partial state ไป​เงียบๆ 🔁 บท 5 ปู​เรื่อง​นี้​ไว้​แล้ว และ​บท 8 จะ​กลับ​มา​เก็บ​ใน​รูป cancellation safety ของ shutdown branch

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

ข้อ 1 / 3

code tokio::spawn(async { std::thread::sleep(Duration::from_secs(1)); }) ผ่านทั้ง cargo build และ cargo clippy -- -D warnings โดยไม่มี warning สักบรรทัด ข้อใดอธิบายสถานการณ์นี้ได้ถูกต้อง?