share state ข้าม task — std Mutex, tokio Mutex และ channel ทั้งสามแบบ
ห้าบทที่ผ่านมา task แต่ละตัวยังทำงานของตัวเองอยู่ในโลกของมัน บทนี้เราเปิดคำถามที่ระบบจริงทุกระบบต้องเจอในสัปดาห์แรก: หลาย task แตะข้อมูลก้อนเดียวกันพร้อมกัน แล้วยังไงต่อ สำหรับ kaen-kvstore คำถามนี้คือ HashMap ก้อนกลางที่คอนเนกชันทุกเส้นต้อง GET และ SET ลงไป
คำตอบมีสองชั้นและชั้นแรกไม่มีอะไรน่าตื่นเต้น — Arc เพื่อให้หลาย task ถือ ของชิ้นเดียวกันได้ บวก lock เพื่อให้ แก้ มันได้ทีละคน ชั้นที่สองต่างหากที่แบกน้ำหนักทั้งบท: จะใช้ std::sync::Mutex หรือ tokio::sync::Mutex และคำตอบสวนสัญชาตญาณคนที่มาจาก .NET เกือบทุกคน — ตัวที่คุณควรใช้เป็นค่าเริ่มต้นคือ std::sync::Mutex ตัวธรรมดาที่ block thread ไม่ใช่ตัว async ส่วน tokio::sync::Mutex เก็บไว้ใช้เฉพาะกรณีเดียวจริงๆ
คอร์สนี้ ต่อยอด repo kaen-kvstore จาก #22 (code ตัวอย่างกำลังจัดทำ) — ตลอด 8 บทเราจะยก store ตัวเดิมจาก ThreadPool ขนาดคงที่ 4 worker ของ #22 (หนึ่งคอนเนกชันยึด worker ไว้ทั้งเส้น) ขึ้นไปอยู่บน Tokio runtime บทนี้คือบทที่ตัดสินใจเรื่อง state กลางของ store: 🔁 capstone ในบท 8 จะใช้ Arc<std::sync::Mutex<HashMap>> ตรงๆ ตามข้อสรุปของบทนี้ ไม่ใช่ tokio Mutex — และเหตุผลทั้งหมดว่าทำไม อยู่ในหน้านี้
ทุก 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"] }"sync" คือ feature ที่แจก tokio::sync ทั้ง module — Mutex, RwLock, mpsc, oneshot, broadcast ส่วน "rt-multi-thread" จำเป็นเพราะบทนี้ทั้งบทพูดถึงสิ่งที่เกิดขึ้น เมื่อ task ถูกย้ายข้าม worker ซึ่งมีได้เฉพาะ scheduler แบบหลาย thread ห้ามใช้ features = ["full"] เด็ดขาดทั้งคอร์ส ด้วย scope นี้ cargo ยัง resolve มาแค่ 7 crate เท่าเดิมกับบท 1 — และ 7 ตัวนี้ตรงกันทั้งสองเกณฑ์ คือทั้งจำนวน dependency ที่ lock ไว้ (Locking 7 packages) และจำนวน crate ที่ compile จริง บน x86_64-unknown-linux-gnu (tokio 1.53.1, tokio-macros 2.7.1, pin-project-lite 0.2.17 + proc-macro2/quote/syn/unicode-ident ที่เป็น build dep ของมาโคร) — ยังไม่มี mio/socket2/libc เพราะ "sync" เป็น code pure-Rust ล้วน ไม่แตะ OS
ทุกอย่างในบทนี้ผ่าน cargo build / cargo run / cargo test / cargo clippy -- -D warnings บน target x86_64-unknown-linux-gnu แบบ zero-warnings ยกเว้น snippet ที่ติดป้าย ❌ ซึ่งตั้งใจให้พัง (สองอันแรก compile ไม่ผ่าน อีกสองอัน compile ผ่านแล้วไป panic หรือค้างตอนรัน — นั่นคือประเด็นของมัน) และ ทุกบรรทัด output กับทุก error ที่เห็นข้างล่างคือข้อความจริงที่พิมพ์ออกมา ไม่ใช่ของที่เขียนขึ้นเอง
Arc แก้ปัญหา1 lock แก้อีกปัญหาหนึ่ง
หัวข้อที่มีชื่อว่า “Arc แก้ปัญหา1 lock แก้อีกปัญหาหนึ่ง”คนที่มาจาก C# มักรวมสองเรื่องนี้เป็นเรื่องเดียว เพราะใน .NET มันเป็นเรื่องเดียวจริงๆ — class Store เป็น reference type อยู่แล้ว ส่งไปให้กี่ thread ก็ได้ GC ดูแล lifetime ให้ เหลือแค่ lock (obj) { … } รอบตรงที่แก้ค่า จบ
ใน Rust มันเป็นสองปัญหาที่แยกกันสะอาด:
- ใครเป็นเจ้าของ —
Arc<T>(atomically reference-counted) คือสิ่งที่ทำให้หลาย task ถือ pointer ไปยังของชิ้นเดียวกันได้ โดยของจะถูกปล่อยเมื่อผู้ถือคนสุดท้ายหายไป นี่คือสิ่งที่ GC ทำให้คุณฟรีใน .NET - ใครแก้ได้ตอนไหน —
Arc<T>ให้ได้แค่&Tเท่านั้น ถ้าอยากแก้ต้องมี interior mutability มาคั่น ซึ่งตัวที่ปลอดภัยข้าม thread คือMutex<T>หรือRwLock<T>
รวมกันจึงได้รูปที่คุณจะเห็นซ้ำไปตลอดชีวิตการเขียน Rust แบบ concurrent: Arc<Mutex<T>> ตัว Arc ทำให้ clone แล้ว move เข้า task ได้ ตัว Mutex ทำให้แก้ได้ทีละคน
use std::collections::HashMap;use std::sync::{Arc, Mutex};
let db: Arc<Mutex<HashMap<String, String>>> = Arc::new(Mutex::new(HashMap::new()));let db2 = Arc::clone(&db); // clone ตัว Arc ไม่ใช่ตัว HashMapการตัดสินใจที่แบกน้ำหนัก: std Mutex คือค่าเริ่มต้น
หัวข้อที่มีชื่อว่า “การตัดสินใจที่แบกน้ำหนัก: std Mutex คือค่าเริ่มต้น”ถ้าคุณเพิ่งเห็นว่า tokio::sync::Mutex มีอยู่ สัญชาตญาณจะบอกว่า “อยู่ใน code async ก็ต้องใช้ของ async สิ” — ผิด และเอกสารของ Tokio เองพูดเรื่องนี้ตรงๆ ไม่อ้อมค้อม:
“The feature that the async mutex offers over the blocking mutex is the ability to keep it locked across an
.awaitpoint… the blocking mutex should be preferred in the cases where it can be used… If the value behind the mutex is just data, it’s usually appropriate to use a blocking mutex such as the one in the standard library or parking_lot.”
และในบท shared state ของ tutorial ก็ย้ำอีกครั้ง:
“using a synchronous mutex from within asynchronous code is fine as long as contention remains low and the lock is not held across calls to .await”
เหตุผลตรงไปตรงมา: tokio::sync::Mutex ไม่ได้ฟรี มันคือ future ที่ต้องเข้าคิวรอ มี wait list ของตัวเอง และ — นี่คือส่วนที่คนไม่ค่อยรู้ — ข้างในมันใช้ std::sync::Mutex guard state ของตัวเองอยู่ดี คุณจึงจ่ายค่าของ std Mutex บวกค่าของกลไก async ทับลงไปอีกชั้น เพื่อซื้อความสามารถอย่างเดียวคือ “ถือ lock ข้าม .await ได้”
ใช้ std::sync::Mutex เป็นค่าเริ่มต้นเสมอ แล้วเปลี่ยนไปใช้ tokio::sync::Mutex เฉพาะเมื่อคุณจำเป็นต้องถือ lock คร่อม .await จริงๆ เท่านั้น — ถ้าสิ่งที่อยู่ใต้ lock เป็น ข้อมูลเฉยๆ คำตอบคือ std เกือบทุกครั้ง
เกณฑ์ตัดสินจึงเหลือคำถามเดียว: critical section ของคุณมี .await คั่นอยู่ข้างในไหม ถ้าไม่มี → std ถ้ามี → ถามตัวเองอีกรอบว่าย้าย .await ออกไปนอก lock ได้ไหม ถ้าย้ายไม่ได้ (เช่นสิ่งที่ถูก lock คือ connection handle ที่ต้อง query(...).await บนตัวมันเอง) ค่อยใช้ tokio
ทำไมกฎนี้ถึงเป็นกฎ: Send กับการที่ task ย้าย worker ได้
หัวข้อที่มีชื่อว่า “ทำไมกฎนี้ถึงเป็นกฎ: Send กับการที่ task ย้าย worker ได้”นี่คือส่วนที่สัญชาตญาณจาก .NET จะทำให้คุณอ่าน code ผิด และเป็นย่อหน้าที่สำคัญที่สุดของบท
ตอน compiler แปลง async block เป็น state machine ตัวแปรทุกตัวที่ ยังมีชีวิตอยู่ข้าม .await จะกลายเป็น field ของ struct นั้น (เราเห็นกลไกนี้มาแล้วในบท 1 ตอนคุยเรื่อง Pin) ทีนี้ scheduler แบบ multi-thread ของ Tokio มีสิทธิ์ย้าย task ที่ยังไม่จบ จาก worker หนึ่งไปอีก worker หนึ่งได้ที่ทุกจุด .await ตามที่ tutorial เขียนไว้ว่า “the Tokio runtime can move a task between threads at every .await”
ผลที่ตามมาเป็นตรรกะล้วน: ถ้า state machine ทั้งก้อนอาจถูกย้ายข้าม thread ทุก field ในนั้นก็ต้องย้ายข้าม thread ได้อย่างปลอดภัย ซึ่งใน Rust แปลว่าต้องเป็น SendSendauto-trait "ย้ายข้าม thread ได้"; ทุกค่าที่ยังอยู่ข้าม `.await` ต้อง Send สำหรับ `spawn` — เทรตเปล่าที่บอกว่า “ค่านี้ย้ายเจ้าของข้าม thread ได้ปลอดภัย” และ tokio::spawn ก็เรียกร้องมันตรงๆ ใน signature: pub fn spawn<F>(future: F) -> JoinHandle<F::Output> where F: Future + Send + 'static, F::Output: Send + 'static — สังเกตว่า bound มีสองบรรทัด ตัว future ต้อง Send และค่าที่มัน return ก็ต้อง Send ด้วย เพราะผลลัพธ์นั้นต้องเดินทางกลับข้าม thread มาหาคนที่ .await ตัว JoinHandle
แล้ว std::sync::MutexGuard เป็น !Send — จงใจให้ไม่ Send เพราะ mutex ของ OS หลายตัวกำหนดว่า thread ที่ปลดล็อกต้องเป็น thread เดียวกับที่ล็อก ทั้งเรื่องจึงบรรจบกันตรงนี้:
flowchart TD
A[task เริ่มทำงานบน worker 1] --> B[เรียก lock ได้ MutexGuard ของ std มาถือ]
B --> C{ระหว่างที่ยังถือ guard มี await คั่นไหม}
C -->|ไม่มี| D[guard ถูก drop ก่อนถึง await]
D --> E[state machine ไม่มี guard เป็น field จึงเป็น Send]
E --> F[Tokio ย้าย task ไป worker 2 ได้อย่างปลอดภัย]
C -->|มี| G[guard กลายเป็น field ของ state machine]
G --> H[MutexGuard ของ std ไม่ใช่ Send]
H --> I[tokio spawn ปฏิเสธตั้งแต่ compile time]
คำบรรยายภาพ: เส้นทางตัดสินใจที่ compiler เดินจริง — scheduler ของ Tokio ย้าย task ข้าม worker ได้ที่ทุกจุด await ค่าที่ยังมีชีวิตคร่อม await จึงกลายเป็น field ของ state machine และต้องเป็น Send ทั้งหมด ถ้า guard ของ std Mutex ถูกถือคร่อม await ไว้ อนาคตทั้งก้อนจะไม่ Send แล้ว tokio spawn ก็ปฏิเสธตั้งแต่ตอน compile ส่วนถ้า guard ถูกปล่อยก่อน await state machine ก็สะอาดและ Tokio ย้าย task ได้ตามใจ
❌ version ดิบ: ถือ guard คร่อม .await
หัวข้อที่มีชื่อว่า “❌ version ดิบ: ถือ guard คร่อม .await”เขียนแบบที่คนเขียนกันครั้งแรกเสมอ — file src/bin/red_guard.rs:
use std::collections::HashMap;use std::sync::{Arc, Mutex};
async fn flush() {}
#[tokio::main]async fn main() { let db = Arc::new(Mutex::new(HashMap::<String, String>::new())); let db2 = Arc::clone(&db); tokio::spawn(async move { let mut map = db2.lock().unwrap(); map.insert("k".into(), "v".into()); flush().await; println!("{}", map.len()); }) .await .unwrap();}cargo build --target x86_64-unknown-linux-gnu --bin red_guard ตอบกลับมาแบบนี้จริงๆ:
error: future cannot be sent between threads safely --> src/bin/red_guard.rs:10:5 | 10 | / tokio::spawn(async move { 11 | | let mut map = db2.lock().unwrap(); 12 | | map.insert("k".into(), "v".into()); 13 | | flush().await; 14 | | println!("{}", map.len()); 15 | | }) | |______^ future created by async block is not `Send` | = help: within `{async block@src/bin/red_guard.rs:10:18: 10:28}`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, HashMap<String, String>>`note: future is not `Send` as this value is used across an await --> src/bin/red_guard.rs:13:17 | 11 | let mut map = db2.lock().unwrap(); | ------- has type `std::sync::MutexGuard<'_, HashMap<String, String>>` which is not `Send` 12 | map.insert("k".into(), "v".into()); 13 | flush().await; | ^^^^^ await occurs here, with `mut map` 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`อ่าน error นี้ให้ครบสามชั้นแล้วคุณจะไม่กลัวมันอีกเลย: ชั้นบนบอก อาการ (future ตัวนี้ไม่ Send) ชั้น note แรกบอก ต้นเหตุ พร้อมชี้นิ้วไปที่บรรทัด .await เป๊ะๆ พร้อมวลี await occurs here, with mut map maybe used later ชั้น note ล่างสุดบอก ใครเป็นคนเรียกร้อง คือ bound F: Future + Send + 'static ใน tokio::spawn เอง
❌ version ดิบรอบสอง: drop(map) แล้วยัง ไม่ ผ่าน
หัวข้อที่มีชื่อว่า “❌ version ดิบรอบสอง: drop(map) แล้วยัง ไม่ ผ่าน”นี่คือ gotcha ที่กัดคนเขียน Rust ทุกคนอย่างน้อยหนึ่งครั้ง เมื่อเห็น error ข้างบนแล้วปฏิกิริยาแรกคือ “อ๋อ งั้นก็ปล่อย guard ก่อน await สิ” แล้วเติม drop(map); เข้าไปหนึ่งบรรทัด:
tokio::spawn(async move { let mut map = db2.lock().unwrap(); map.insert("k".into(), "v".into()); drop(map); // "น่าจะแก้ได้แล้วมั้ง?" flush().await; })ผลลัพธ์จริงบน rustc 1.97.1 คือ error ตัวเดิมเป๊ะ (ตัดเหลือบรรทัดที่มีน้ำหนัก):
error: future cannot be sent between threads safely --> src/bin/red_guard.rs:10:5... = help: within `{async block@src/bin/red_guard.rs:10:18: 10:28}`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, HashMap<String, String>>`note: future is not `Send` as this value is used across an await --> src/bin/red_guard.rs:14:17 | 11 | let mut map = db2.lock().unwrap(); | ------- has type `std::sync::MutexGuard<'_, HashMap<String, String>>` which is not `Send`... 14 | flush().await; | ^^^^^ await occurs here, with `mut map` maybe used laterเหตุผลอยู่ในประโยคเดียวของ tutorial: “The compiler currently calculates whether a future is Send based on scope information only” — compiler ดูแค่ว่าตัวแปรนั้น ยังอยู่ใน lexical scope ตอนถึงบรรทัด .await หรือเปล่า ไม่ได้ทำ drop tracking ให้ ตัวแปร map ประกาศไว้ที่ scope ของ async block ทั้งก้อน มันจึง “ยังอยู่” ตรงบรรทัด .await ต่อให้คุณ drop() ไปแล้วก็ตาม สังเกตคำว่า maybe used later ในข้อความ — คำว่า maybe นั่นคือคือหลักฐานว่านี่เป็นการวิเคราะห์แบบหยาบตามขอบเขต ไม่ใช่การไล่ดูจริงว่าใช้ต่อหรือไม่
ยืนยันแล้วว่าเป็นแบบนี้ทั้งบน edition 2024 และ edition 2021 — ไม่ใช่เรื่องของ edition และไม่ใช่ bug แต่เป็นข้อจำกัดของการวิเคราะห์ที่ยังเป็นแบบนี้อยู่ใน version นี้
✅ ทางแก้ที่ใช้ได้จริงมีสองทาง
หัวข้อที่มีชื่อว่า “✅ ทางแก้ที่ใช้ได้จริงมีสองทาง”ทางที่หนึ่ง — ปิดด้วย block scope { } จบตัวแปรทิ้งไปเลยตั้งแต่ระดับไวยากรณ์:
tokio::spawn(async move { { let mut map = db2.lock().unwrap(); map.insert("k".into(), "v".into()); } // guard drop ที่นี่ และ map ก็หลุด scope ไปด้วย tokio::task::yield_now().await;});ทางที่สอง — ย้าย lock ไปไว้ใน function non-async ซึ่งเป็นทางที่อ่านง่ายกว่าเมื่อ logic เริ่มยาว และเป็นทางที่ capstone บท 8 ใช้:
fn apply(db: &Mutex<HashMap<String, String>>, k: String, v: String) { db.lock().unwrap().insert(k, v); // guard เกิดและตายใน frame นี้}ทั้งสองทางให้ผลเดียวกันคือ guard ไม่เคยปรากฏใน scope ตอนถึง .await state machine จึงสะอาดและ Send
เมทริกซ์ Send สี่ช่อง ที่พิสูจน์ด้วย compiler จริง
หัวข้อที่มีชื่อว่า “เมทริกซ์ Send สี่ช่อง ที่พิสูจน์ด้วย compiler จริง”อย่าเชื่อคำอธิบาย ให้ compiler ตอบเอง เครื่องมือคือ function บรรทัดเดียวที่บังคับ bound ให้เห็นๆ:
fn assert_send<T: Send>(_: &T) {}สร้าง4 file ที่ต่างกันแค่จุดเดียว แล้วยิงคำสั่งเดียวกันใส่ทุก file — ไม่ต้องมี Cargo ไม่ต้องมี Tokio เพราะทั้งหมดนี้เป็นเรื่องของ std กับ compiler ล้วน:
rustc --edition 2024 --crate-type lib --emit=metadata --out-dir . case1.rsโครงร่วมของทุก file คือ case1.rs ข้างล่างนี้ ส่วนอีก3 file แก้เฉพาะตัว body ของ async block:
use std::collections::HashMap;use std::sync::{Arc, Mutex};
fn assert_send<T: Send>(_: &T) {}
async fn flush() {}
pub fn case1(db: Arc<Mutex<HashMap<String, String>>>) { let fut = async move { let mut map = db.lock().unwrap(); map.insert("k".into(), "v".into()); flush().await; println!("{}", map.len()); }; assert_send(&fut);}ผลจริงทั้งสี่ช่อง:
| เคส | สิ่งที่ต่างจาก case1 | ผล |
|---|---|---|
| 1 | ถือ guard คร่อม .await แล้วเรียก assert_send | REJECT — future cannot be sent between threads safely |
| 2 | เติม drop(map); ก่อน flush().await | REJECT เหมือนกันเป๊ะ ← gotcha |
| 3 | ครอบ lock ด้วย block { } | COMPILES |
| 4 | ถือ guard คร่อม .await แต่ ไม่มี assert_send เลย (คืน impl Future ออกไปเฉยๆ) | COMPILES |
ช่องที่ 4 คือช่องที่สอนอะไรมากที่สุด และเป็นช่องที่คนอ่าน error แบบผิวเผินจะเข้าใจผิด: การถือ guard คร่อม .await ไม่ได้ผิดกฎไวยากรณ์ของภาษาเลย code นั้น compile ผ่านสบายๆ ถ้าไม่มีใครเรียกร้อง Send จากมัน error ในสองช่องแรกไม่ได้มาจาก “Rust ห้ามถือ lock คร่อม await” แต่มาจาก bound ที่ tokio::spawn ตั้งไว้ ต่างหาก
ซึ่งแปลว่ามีอีกด้านหนึ่งที่ compiler ไม่ได้ ปกป้องคุณ: ถ้า future ก้อนนั้นไม่เคยถูกใครบังคับ Send เลย (เช่น .await ตรงๆ ใน main โดยไม่ผ่าน spawn, หรือ tokio::task::spawn_local บน LocalSet ที่ผูก task ไว้กับ thread เดียว) หรือใช้ mutex จาก crate ที่ implement Send ให้ guard ของตัวเอง code จะผ่านฉลุยแล้วไป deadlock เอาตอนรัน — และระวังความเข้าใจผิดที่พบบ่อย: แค่สลับไปใช้ flavor current_thread ไม่ช่วยให้ผ่าน เพราะ tokio::spawn เรียกร้อง Send ใน signature ของมันเสมอไม่ว่ารันบน flavor ไหน (ยืนยันแล้ว — เคสข้างบนใส่ #[tokio::main(flavor = "current_thread")] แล้วยังได้ error ตัวเดิม) ทางที่ compiler เงียบจริงคือ spawn_local ต่างหาก — tutorial ระบุเคสนี้ไว้ตรงๆ ว่า “some mutex crates implement Send for their MutexGuards… The code compiles, but it deadlocks!” กฎ “อย่าถือ std guard คร่อม await” จึงไม่ใช่แค่กฎเอาใจ compiler แต่เป็นกฎที่ควรถือแม้ในที่ที่ compiler เงียบ
tokio::sync::Mutex ตอนที่จำเป็น
หัวข้อที่มีชื่อว่า “tokio::sync::Mutex ตอนที่จำเป็น”เมื่อสิ่งที่อยู่ใต้ lock ไม่ใช่ข้อมูลเฉยๆ แต่เป็น handle ที่ต้อง .await บนตัวมันเอง — connection ของฐานข้อมูล, client ของ API — คุณย้าย .await ออกไปนอก lock ไม่ได้ นั่นคือกรณีเดียวที่ tokio Mutex คุ้ม:
use std::sync::Arc;use tokio::sync::Mutex as TokioMutex;
struct Conn { calls: u32 }
impl Conn { async fn query(&mut self, sql: &str) -> String { self.calls += 1; tokio::task::yield_now().await; format!("{sql}#{}", self.calls) }}
async fn demo_tokio_mutex() { let conn = Arc::new(TokioMutex::new(Conn { calls: 0 })); let c2 = Arc::clone(&conn); let h = tokio::spawn(async move { let mut guard = c2.lock().await; // .await เพื่อ acquire ไม่ block worker let a = guard.query("SELECT 1").await; // ถือ guard คร่อม await ได้ เพราะ guard นี้ Send let b = guard.query("SELECT 2").await; format!("{a},{b}") }); println!("tokio_mutex_task={}", h.await.unwrap());
let mut owned = Arc::clone(&conn).lock_owned().await; // OwnedMutexGuard ถือ Arc ไว้ในตัว println!("tokio_mutex_owned={}", owned.query("SELECT 3").await);}(alias TokioMutex มีไว้เพราะ file เดโมของบทนี้เก็บทั้ง std::sync::Mutex และ tokio::sync::Mutex ไว้ด้วยกัน — ใน code จริงที่ใช้ตัวเดียว เขียน use tokio::sync::Mutex; ตรงๆ ได้เลย)
ผลรันจริง:
tokio_mutex_task=SELECT 1#1,SELECT 2#2tokio_mutex_owned=SELECT 3#3ความต่างเชิงพฤติกรรมที่ต้องรู้เทียบกับ std:
lock()ของ tokio เป็น async — คืน future ที่ต้อง.awaitระหว่างรอคิว task จะยอมคืน worker ให้คนอื่นใช้ ต่างจาก std ที่ block ทั้ง thread ค้างไว้- ไม่มี poisoning — std คืน
LockResult(เพราะต้องบอกว่ามี thread เคย panic ระหว่างถือ lock ไหม) คุณจึงต้อง.unwrap()ทุกครั้ง ส่วน tokio คืน guard ออกมาตรงๆ ไม่ต้อง unwrap - ไม่ reentrant — ล็อกซ้ำจากที่ที่ยังถือ lock อยู่คือ deadlock ทันที (
Monitorของ C# reentrant ได้ อย่าเอาสัญชาตญาณนั้นมาใช้) lock_owned()รับself: Arc<Self>แล้วคืนOwnedMutexGuardที่มี lifetime'static— ใช้ตอนต้องmoveguard เข้าไปใน task ที่ spawn ออกไป- ถ้าอยากได้ reader หลายคนพร้อมกัน ใช้
tokio::sync::RwLockที่มีread().await/write().awaitเอกสารระบุว่ามันเป็น fair แบบ write-preferring “in order to ensure that readers cannot starve writers” — reader ไหลไม่หยุดจะไม่ทำให้ writer อดตาย
tokio::sync::Mutex มี method blocking_lock() ไว้เรียกจากโลก sync แต่ถ้าเผลอเรียกมันจากภายใน runtime context ผลจริงคือ panic ไม่ใช่แค่ช้า — file src/bin/red_blocking_lock.rs ทั้ง file คือแค่นี้:
use tokio::sync::Mutex;
#[tokio::main]async fn main() { let m = Mutex::new(0u32); // เรียก blocking_lock() ขณะที่ยังอยู่บน worker ของ runtime let g = m.blocking_lock(); println!("{g}");}thread 'main' (98621) panicked at src/bin/red_blocking_lock.rs:7:15:Cannot block the current thread from within a runtime. This happens because a function attempted to block the current thread while the thread is being used to drive asynchronous tasks.นี่คือ Tokio ปกป้องคุณจากการ block worker ตัวเอง — เรื่องเดียวกันนี้ใน version ที่ Tokio มองไม่เห็น และเงียบสนิท คือหัวข้อเปิดของ 🔁 บท 7
channel: ทางเลือกที่ไม่ต้อง share state เลย
หัวข้อที่มีชื่อว่า “channel: ทางเลือกที่ไม่ต้อง share state เลย”lock ไม่ใช่คำตอบเดียว ทางเลือกที่มักดีกว่าคือ ไม่ share state แต่ส่งข้อความหากัน — ให้ task เดียวเป็นเจ้าของข้อมูล แล้วคนอื่นส่งคำสั่งเข้าไป tokio::sync แจกมาสามแบบ ใช้คนละงานกันชัดเจน
mpsc — หลายคนส่ง คนเดียวรับ
หัวข้อที่มีชื่อว่า “mpsc — หลายคนส่ง คนเดียวรับ”use tokio::sync::mpsc;
async fn demo_mpsc() { let (tx, mut rx) = mpsc::channel::<String>(32); for i in 0..3 { let tx = tx.clone(); // Sender clone ได้ Receiver clone ไม่ได้ tokio::spawn(async move { tx.send(format!("job{i}")).await.unwrap(); }); } drop(tx); // ทิ้ง sender ตัวสุดท้าย
let mut got = Vec::new(); while let Some(v) = rx.recv().await { // None เมื่อ sender หมดเกลี้ยง got.push(v); } got.sort(); // เรียงเพื่อให้ output คงที่ ลำดับที่มาถึงจริงสลับได้ println!("mpsc_got={got:?}");}mpsc_got=["job0", "job1", "job2"]recv() คืน Option<T> และคืน None ก็ต่อเมื่อ sender ทุกตัวถูก drop หมดแล้ว ซึ่งพาไปสู่ RED ตัวถัดไป
ลบ drop(tx); ทิ้ง แล้วปล่อยให้ tx ตัวแรกยังมีชีวิตอยู่ใน function แม่ (และเติม println! เข้าไปใน loop ด้วย เพื่อให้เห็นว่าข้อความมาถึงจริงก่อนที่โปรแกรมจะค้าง — บรรทัด mpsc_got= ที่อยู่ หลัง loop ไม่มีวันถูกพิมพ์):
// ลบ drop(tx); ทิ้งไปแล้ว while let Some(v) = rx.recv().await { println!("got {v}"); got.push(v); }ผลจริงคือ:
got job0got job1got job2exit=124ข้อความครบทั้งสามใบมาถึง แล้ว loop while let Some(v) = rx.recv().await ไม่จบ — ต้องฆ่าด้วย timeout 5 (exit code 124) เพราะ channel ยังเห็นว่ามี sender ที่ยังมีชีวิตอยู่หนึ่งตัว จึงยังหวังว่าจะมีข้อความมาอีก (ลำดับสามบรรทัด got … สลับกันได้ในแต่ละรอบที่รัน เพราะทั้ง3 task วิ่งบน worker คนละตัว — รันสี่รอบติดกันได้ถึงสามลำดับต่างกัน คือ job0 job1 job2 สองรอบ กับ job0 job2 job1 และ job1 job0 job2 อย่างละรอบ แต่การค้างนั้นเกิดขึ้นทุกครั้ง) ทางแก้คือ drop(tx) หรือครอบส่วนที่ผลิตด้วย block scope ให้ tx ตายเองเมื่อจบ
bounded คือ backpressure — และนั่นคือ feature
หัวข้อที่มีชื่อว่า “bounded คือ backpressure — และนั่นคือ feature”ตัวเลข 32 ใน mpsc::channel::<String>(32) ไม่ใช่ hint ปรับจูน แต่คือ สัญญาว่าคิวจะไม่โตเกินนี้ เมื่อคิวเต็ม send().await จะ รอ จนกว่าจะมีที่ว่าง แปลว่าฝั่งผู้ผลิตถูกเบรกด้วยความเร็วของผู้บริโภคโดยอัตโนมัติ กลไกนี้เรียกว่า backpressurebackpressureการหน่วงผู้ส่งเมื่อ channel เต็ม (bounded `mpsc::channel`) และมันคือสิ่งเดียวที่กันไม่ให้ producer ที่เร็วกว่า consumer กินแรมจน process ตาย
เห็นด้วยตาแบบไม่ต้องพึ่งนาฬิกาได้ด้วย try_send ซึ่งเป็น version ที่ไม่รอ:
async fn demo_backpressure() { let (tx, mut rx) = mpsc::channel::<u32>(2); for i in 0..4 { match tx.try_send(i) { Ok(()) => println!("try_send({i})=Ok"), Err(e) => println!("try_send({i})=Err({e})"), } } println!("drained={}", rx.recv().await.unwrap()); tx.send(99).await.unwrap(); println!("send_after_drain=Ok");}try_send(0)=Oktry_send(1)=Oktry_send(2)=Err(no available capacity)try_send(3)=Err(no available capacity)drained=0send_after_drain=Okสองใบแรกเข้าคิวได้ ใบที่สามชนเพดานทันที พอ recv() ดูดออกไปหนึ่งใบ ที่ว่างก็คืนมาและ send().await ผ่านฉลุย — นี่คือหน้าตาของ backpressure แบบจับต้องได้
มี mpsc::unbounded_channel() ให้ใช้ด้วย และ UnboundedSender::send() ไม่ใช่ async เพราะมันไม่มีวันต้องรอ ฟังดูสะดวกจนน่าเลือก แต่ราคาคือคุณโยน backpressure ทิ้งไปทั้งดุ้น: ถ้าผู้บริโภคช้ากว่าผู้ผลิต คิวจะโตไปเรื่อยๆ จนหน่วยความจำหมด ใช้เมื่อคุณ รู้แน่ ว่าจำนวนข้อความมีขอบเขตจากเหตุผลอื่นเท่านั้น
คนที่มาจาก Go จะคุ้นกับ unbuffered channel ที่ capacity 0 แล้วนัดพบกันพอดี — Tokio ไม่ทำแบบนั้น มันเป็น panic ตอนสร้าง 2 file แฝดข้างล่างต่างกันแค่บรรทัดที่ 5 — file แรกคือ src/bin/red_zero_mpsc.rs:
use tokio::sync::mpsc;
#[tokio::main]async fn main() { let (_tx, _rx) = mpsc::channel::<u32>(0);}thread 'main' (95403) panicked at src/bin/red_zero_mpsc.rs:5:22:mpsc bounded channel requires buffer > 0file ที่สองคือ src/bin/red_zero_broadcast.rs โครงเดียวกันเป๊ะ:
use tokio::sync::broadcast;
#[tokio::main]async fn main() { let (_tx, _rx) = broadcast::channel::<i32>(0);}thread 'main' (95422) panicked at src/bin/red_zero_broadcast.rs:5:22:broadcast channel capacity cannot be zeroทั้งสองตัวออกด้วย exit code 101 ค่า capacity ต้องเป็น 1 ขึ้นไปเสมอ
oneshot — คำตอบใบเดียว
หัวข้อที่มีชื่อว่า “oneshot — คำตอบใบเดียว”use tokio::sync::oneshot;
async fn demo_oneshot() { let (otx, orx) = oneshot::channel::<u64>(); tokio::spawn(async move { let _ = otx.send(42); // send(self, T) -> Result<(), T> กิน self ไป ไม่ใช่ async }); match orx.await { // Receiver ตัวมันเองเป็น Future Ok(v) => println!("oneshot_got={v}"), Err(e) => println!("oneshot_err={e}"), }
let (otx2, orx2) = oneshot::channel::<u64>(); drop(otx2); println!("oneshot_after_drop={:?}", orx2.await);}oneshot_got=42oneshot_after_drop=Err(RecvError(()))สังเกตสองอย่าง: send กิน self ไปเลย จึงส่งซ้ำไม่ได้โดยหลักประกันของ type system และมันไม่ใช่ async (ไม่มีคิวให้รอ) ส่วนฝั่งรับ Receiver implement Future ตรงๆ จึงเขียน orx.await ได้เหมือนเป็น Task<T> และถ้าฝั่งส่งตายไปก่อนโดยไม่ส่ง คุณได้ Err(RecvError(())) แทนที่จะค้าง
คู่ oneshot กับ mpsc เป็น pattern ที่ใช้บ่อยที่สุดในระบบจริง: ส่งคำสั่งเข้า mpsc โดยแนบ oneshot::Sender ไปกับคำสั่งด้วย เพื่อให้ actor ตอบกลับมาเฉพาะเจ้าของคำถามนั้น
broadcast — คนเดียวส่ง ทุกคนได้
หัวข้อที่มีชื่อว่า “broadcast — คนเดียวส่ง ทุกคนได้”use tokio::sync::broadcast;
async fn demo_broadcast() { let (btx, mut fast) = broadcast::channel::<i32>(2); let mut slow = btx.subscribe(); // สมัครเพิ่มได้ตลอด
println!("broadcast_receivers={}", btx.send(10).unwrap()); // Ok(usize) = จำนวน receiver println!("broadcast_fast={}", fast.recv().await.unwrap()); println!("broadcast_slow_first={}", slow.recv().await.unwrap());
for v in 11..=14 { btx.send(v).unwrap(); // slow ไม่ได้อ่านเลยระหว่างนี้ } println!("broadcast_slow_next={:?}", slow.recv().await); println!("broadcast_slow_then={:?}", slow.recv().await);}broadcast_receivers=2broadcast_fast=10broadcast_slow_first=10broadcast_slow_next=Err(Lagged(2))broadcast_slow_then=Ok(13)broadcast ต้องการ T: Clone เพราะทุก receiver ได้สำเนาของตัวเอง และมันเก็บข้อความไว้ใน ring buffer ขนาดเท่า capacity เท่านั้น ในเดโมนี้ capacity คือ 2 แต่เราส่ง 11, 12, 13, 14 รวดเดียวโดยที่ slow ไม่ได้อ่าน ผลคือ 11 กับ 12 ถูกทับไปแล้ว recv() จึงคืน Err(RecvError::Lagged(2)) — ตัวเลข 2 คือจำนวนข้อความที่หายไป — จากนั้น receiver ตัวนั้นถูกเลื่อนไปยังข้อความเก่าที่สุดที่ยังเหลืออยู่ ซึ่งคือ 13
Lagged ไม่ใช่ error ที่ทำให้ receiver ตาย มันคือรายงานว่า “คุณตกไปกี่ใบ แล้วเราขยับคุณให้แล้ว” code ที่ match เฉพาะ RecvError::Closed จะพลาดเคสนี้แล้วเผลอ treat มันเป็นการปิด channel — เขียน match ให้ครบทั้ง2 variant เสมอ
2 test ที่ตรึงสัญญาของ channel ไว้
หัวข้อที่มีชื่อว่า “2 test ที่ตรึงสัญญาของ channel ไว้”ข้ออ้างสองข้อที่แบกน้ำหนักที่สุดของหัวข้อนี้ — recv() คืน None ก็ต่อเมื่อ sender หมดเกลี้ยง และ Lagged(n) ไม่ได้ฆ่า receiver แต่เลื่อนมันไปข้างหน้า — เขียนเป็น assert ได้ตรงๆ ต่อท้าย file เดียวกัน:
#[cfg(test)]mod tests { use super::*;
/// recv() คืน None ก็ต่อเมื่อ sender ตัวสุดท้ายถูก drop — ไม่ใช่ตอนคิวว่าง #[tokio::test] async fn recv_returns_none_only_after_the_last_sender_is_dropped() { let (tx, mut rx) = mpsc::channel::<u32>(4); let tx2 = tx.clone(); tx.send(1).await.unwrap(); drop(tx); assert_eq!(rx.recv().await, Some(1)); // ยังไม่ None เพราะ tx2 ยังมีชีวิต drop(tx2); // sender หมดเกลี้ยงตรงนี้ assert_eq!(rx.recv().await, None); }
/// Lagged(n) บอกจำนวนที่ตกหล่น แล้วเลื่อน receiver ไปที่ข้อความเก่าสุดที่ยังเหลือ #[tokio::test] async fn broadcast_reports_lagged_then_resumes_at_the_oldest_surviving_message() { let (btx, mut slow) = broadcast::channel::<i32>(2); for v in 10..=14 { btx.send(v).unwrap(); } assert!(matches!( slow.recv().await, Err(broadcast::error::RecvError::Lagged(3)) )); assert_eq!(slow.recv().await.unwrap(), 13); }}running 2 teststest tests::recv_returns_none_only_after_the_last_sender_is_dropped ... oktest tests::broadcast_reports_lagged_then_resumes_at_the_oldest_surviving_message ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s(ลำดับสองบรรทัด test … ไม่คงที่ libtest รายงานตามลำดับที่ test จบ ไม่ใช่ลำดับที่เขียน) test ตัวที่สองคือที่ที่ตัวเลข Lagged จับต้องได้: ring buffer ขนาด 2 รับ 10–14 เข้าไปห้าใบ เหลือรอดสองใบท้ายคือ 13 กับ 14 ที่หายคือ 10, 11, 12 = สามใบ จึงได้ Lagged(3) แล้ว recv() ครั้งถัดไปได้ 13 ทันที — ต่างจากเดโมข้างบนที่ slow อ่านใบแรกไปก่อนแล้วจึงตกแค่สองใบ
แผนที่คำศัพท์จาก C#/.NET
หัวข้อที่มีชื่อว่า “แผนที่คำศัพท์จาก C#/.NET”| C# / .NET | Rust | จุดที่ต่างจริง |
|---|---|---|
lock (obj) { … } และ Monitor | std::sync::Mutex + lock().unwrap() | C# มีกฎ hardcode ว่า await ใน lock block เป็น compile error CS1996; Rust ไม่ห้ามในไวยากรณ์ แต่ปฏิเสธที่ bound Send ของ tokio::spawn แทน |
SemaphoreSlim(1, 1) + await WaitAsync() | tokio::sync::Mutex + lock().await | เหตุผลที่ .NET dev หันไป SemaphoreSlim ตอนต้องถือ lock คร่อม await คือ split เดียวกับ std-vs-tokio Mutex เป๊ะ |
| reference ไปยัง object บน heap | Arc<T> | .NET มี GC ดูแล lifetime ให้ฟรี; Arc คือ refcount แบบอะตอมที่คุณเติมเอง |
Monitor reentrant ได้ใน thread เดิม | tokio::sync::Mutex ไม่ reentrant | ล็อกซ้ำ = deadlock ทันที |
System.Threading.Channels.Channel<T> | mpsc | WriteAsync ≈ send().await; ReadAllAsync จบ ≈ recv() คืน None; CreateUnbounded ≈ unbounded_channel() |
TaskCompletionSource<T> | oneshot | ทั้งคู่ให้คำตอบใบเดียว และฝั่งรับ await ได้เหมือน Task<T> |
| ไม่มีคู่ตรงใน BCL | broadcast | Channels เป็น single-consumer; broadcast ใกล้ Rx subject ที่มี ring buffer จำกัด จึงมี Lagged ที่ BCL ไม่มีแนวคิดเทียบ |
แถวแรกคือแถวที่ผมอยากให้จำที่สุด: C# กับ Rust ตัดสินใจเรื่องเดียวกัน แต่คนละชั้น C# ฝังกฎ “ห้าม await ใน lock” ไว้ในตัวภาษาเป็นกรณีพิเศษหนึ่งกรณี ส่วน Rust ไม่มีกฎเฉพาะกิจนั้นเลย มันใช้ระบบ trait ที่ทั่วไปกว่า (Send) แล้วปล่อยให้ผลลัพธ์ตกมาเอง ข้อดีคือกฎเดียวกันนี้คุ้มครองทุกอย่างที่ไม่ Send ไม่ใช่แค่ lock ข้อเสียคือ error ที่ได้อ้อมกว่าและอ่านยากกว่าตอนแรก
สิ่งที่พิสูจน์แล้วในบทนี้: เมทริกซ์ Send ทั้งสี่ช่องถูกยิงด้วย rustc 1.97.1 ตัวจริง ไม่ใช่การอนุมาน — สองช่องแรก REJECT ด้วยข้อความเดียวกัน อีกสองช่อง COMPILES และ snippet channel ทุกอันรันใน Cargo project จริงพร้อม cargo test 2 test ผ่านและ cargo clippy -- -D warnings สะอาด
สิ่งที่บทนี้ไม่ได้สัญญา: Send bound จับได้แค่ ครึ่งเดียว ของปัญหา ช่องที่ 4 พิสูจน์แล้วว่า code ที่ถือ guard คร่อม await compile ผ่านสบายถ้าไม่มีใครเรียกร้อง Send — deadlock กับ starvation เป็นเรื่อง runtime ที่ไม่มี compiler ตัวไหนช่วย และตัวเลข capacity ที่ควรใช้จริง (32? 1024?) ไม่มีคำตอบสำเร็จรูป ต้องวัดจากระบบของคุณเอง บทนี้ให้กลไก ไม่ได้ให้ตัวเลขวิเศษ
สิ่งที่ยังเป็นแบบนี้เฉพาะ version นี้: gotcha เรื่อง drop(guard) มาจากประโยคที่ว่า compiler คิด Send จาก scope อย่างเดียว ยืนยันบน 1.97.1 ทั้ง edition 2021 และ 2024 ณ วันที่เขียน — ถ้าวัน1 drop tracking ละเอียดขึ้น พฤติกรรมนี้อาจเปลี่ยน แต่ทางแก้ด้วย block scope จะยังถูกต้องเสมอ จึงเป็นทางที่ควรเขียนตั้งแต่แรกอยู่ดี
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”share state ใน async Rust แยกเป็นสองปัญหา: Arc ตอบเรื่อง ownership และ lock ตอบเรื่อง interior mutability รวมเป็น Arc<Mutex<T>> ที่จะตามคุณไปทุกที่ · การตัดสินใจที่แบกน้ำหนักคือ std::sync::Mutex เป็นค่าเริ่มต้น ใช้ tokio::sync::Mutex เฉพาะตอนต้องถือ lock คร่อม .await เพราะมันแพงกว่าและข้างในก็ใช้ std Mutex อยู่ดี · เหตุผลลึกสุดคือ scheduler ของ Tokio ย้าย task ข้าม worker ได้ที่ทุกจุด .await ค่าที่มีชีวิตคร่อม await จึงต้อง Send และ std::sync::MutexGuard ไม่ใช่ · gotcha ที่ต้องจำ: drop(guard) ก่อน await ไม่ช่วย เพราะ compiler ตัดสินจาก lexical scope อย่างเดียว ต้องใช้ block { } หรือ lock ใน function non-async · และเมทริกซ์สี่ช่องบอกความจริงอีกด้านว่า error มาจาก bound ของ spawn ไม่ใช่จากภาษาห้าม — ที่ไหนที่ไม่มี bound compiler ก็เงียบแล้วปล่อยให้ deadlock · ฝั่ง channel: mpsc many-to-one ที่ bounded = backpressure และ recv() คืน None ก็ต่อเมื่อ sender หมดจริง (ลืม drop(tx) = ค้างถาวร exit 124), oneshot สำหรับคำตอบใบเดียวที่ send กิน self, broadcast one-to-many ที่ต้อง T: Clone และต้อง handle Lagged(n)
บท 7 เราพลิกด้านกลับ: บทนี้ทั้งบทเป็นกับดักที่ compiler ตะโกน ใส่คุณ บทหน้าคือกับดักที่ compiler เงียบสนิท — std::thread::sleep ใน code async ที่ compile ผ่านสะอาดไม่มีแม้แต่ warning เดียวแต่ทำให้ worker ทั้งตัวค้าง, spawn_blocking ที่เป็นทางแก้, และ function colouring ที่ใน Rust ติดเชื้อถึงระดับ type
- Tokio Tutorial — Shared state (เข้าถึง 2026-07-26) — ต้นทางของประโยค “using a synchronous mutex from within asynchronous code is fine as long as contention remains low and the lock is not held across calls to
.await”, ของ “the Tokio runtime can move a task between threads at every.await”, ของ “The compiler currently calculates whether a future isSendbased on scope information only” และของเคส deadlock เงียบ “some mutex crates implement Send for their MutexGuards… The code compiles, but it deadlocks!” tokio::sync::Mutex— docs.rs (เข้าถึง 2026-07-27) — “The feature that the async mutex offers over the blocking mutex is the ability to keep it locked across an.awaitpoint… the blocking mutex should be preferred in the cases where it can be used… If the value behind the mutex is just data, it’s usually appropriate to use a blocking mutex such as the one in the standard library or parking_lot.” พร้อมlock(),try_lock(),blocking_lock(),lock_owned()tokio::sync::RwLock— docs.rs (เข้าถึง 2026-07-27) —read().await/write().awaitและคำอธิบายว่ามันเป็น fair แบบ write-preferring “in order to ensure that readers cannot starve writers”tokio::sync::mpsc— docs.rs (เข้าถึง 2026-07-27) — boundedsendเป็น async และให้ backpressure,channel(buffer)panic ถ้า buffer เป็น 0,unbounded_channelที่sendไม่ async และไม่มี backpressure,recv()คืนNoneเมื่อ sender หมด,Receiverเป็น single-consumer จึง clone ไม่ได้tokio::sync::oneshot— docs.rs (เข้าถึง 2026-07-27) —Sender::send(self, T) -> Result<(), T>ที่กินselfและไม่ใช่ async,ReceiverimplementFuturetokio::sync::broadcast::channel— docs.rs (เข้าถึง 2026-07-27) —channel<T: Clone>(capacity)ที่ panic ถ้า capacity เป็น 0,sendคืนOk(usize)เป็นจำนวน receiver, และRecvError::Lagged(u64)เมื่อ receiver ตามไม่ทัน
เช็กความเข้าใจ — บทที่ 6
ข้อ 1 / 3คุณกำลังเขียน handler ที่ใส่ค่าลง HashMap กลางของ kaen-kvstore ซึ่งเป็นงานระดับไมโครวินาทีและไม่มี I/O คั่นเลย ควรเลือก lock ตัวไหน และเพราะอะไร?