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

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 เก็บ​ไว้​ใช้​เฉพาะ​กรณี​เดียวจริงๆ

📦 kaen-kvstore

คอร์ส​นี้ ต่อยอด 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 — และ​เหตุผล​ทั้งหมด​ว่า​ทำไม อยู่​ใน​หน้า​นี้

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"] }

"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 ที่​เห็น​ข้าง​ล่าง​คือ​ข้อความ​จริง​ที่​พิมพ์​ออก​มา ไม่ใช่​ของ​ที่​เขียน​ขึ้น​เอง

คน​ที่มา​จาก 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 .await point… 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 ได้​ตามใจ

เขียน​แบบ​ที่​คน​เขียน​กัน​ครั้ง​แรก​เสมอ — 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 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`

อ่าน error นี้​ให้​ครบ​สาม​ชั้น​แล้ว​คุณ​จะ​ไม่​กลัว​มัน​อีก​เลย: ชั้น​บน​บอก อาการ (future ตัว​นี้​ไม่ Send) ชั้น note แรก​บอก ต้นเหตุ พร้อม​ชี้​นิ้ว​ไป​ที่​บรรทัด .await เป๊ะๆ พร้อม​วลี await occurs here, with mut map maybe used later ชั้น note ล่าง​สุด​บอก ใคร​เป็น​คน​เรียกร้อง คือ bound F: Future + Send + 'static ใน tokio::spawn เอง

นี่​คือ 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

อย่า​เชื่อ​คำ​อธิบาย ให้ compiler ตอบ​เอง เครื่องมือ​คือ function บรรทัด​เดียว​ที่​บังคับ bound ให้​เห็นๆ:

fn assert_send<T: Send>(_: &T) {}

สร้าง4 file ที่​ต่าง​กัน​แค่​จุด​เดียว แล้ว​ยิง​คำ​สั่ง​เดียวกัน​ใส่​ทุก file — ไม่​ต้อง​มี Cargo ไม่​ต้อง​มี Tokio เพราะ​ทั้งหมด​นี้​เป็น​เรื่อง​ของ std กับ compiler ล้วน:

Terminal window
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_sendREJECTfuture cannot be sent between threads safely
2เติม drop(map); ก่อน flush().awaitREJECT เหมือนกันเป๊ะ ← 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 เงียบ

เมื่อ​สิ่ง​ที่​อยู่​ใต้ 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#2
tokio_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 — ใช้​ตอน​ต้อง move guard เข้าไป​ใน task ที่ spawn ออก​ไป
  • ถ้า​อยาก​ได้ reader หลาย​คน​พร้อม​กัน ใช้ tokio::sync::RwLock ที่​มี read().await / write().await เอกสาร​ระบุ​ว่า​มัน​เป็น fair แบบ write-preferring “in order to ensure that readers cannot starve writers” — reader ไหล​ไม่​หยุด​จะ​ไม่​ทำให้ writer อด​ตาย
blocking_lock() ใน code async = panic ทันที

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

lock ไม่ใช่​คำ​ตอบ​เดียว ทาง​เลือก​ที่​มัก​ดี​กว่า​คือ ไม่ share state แต่​ส่ง​ข้อความ​หา​กัน — ให้ task เดียว​เป็น​เจ้าของ​ข้อมูล แล้ว​คน​อื่น​ส่ง​คำ​สั่ง​เข้าไป tokio::sync แจก​มา​สาม​แบบ ใช้​คนละ​งาน​กัน​ชัดเจน

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) หนึ่ง​บรรทัด = ค้าง​ถาวร

ลบ 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 job0
got job1
got job2
exit=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 ตาย​เอง​เมื่อ​จบ

ตัวเลข 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)=Ok
try_send(1)=Ok
try_send(2)=Err(no available capacity)
try_send(3)=Err(no available capacity)
drained=0
send_after_drain=Ok

สอง​ใบ​แรก​เข้า​คิว​ได้ ใบ​ที่​สาม​ชน​เพดาน​ทันที พอ recv() ดูด​ออก​ไป​หนึ่ง​ใบ ที่​ว่าง​ก็​คืน​มา​และ send().await ผ่าน​ฉลุย — นี่​คือ​หน้าตา​ของ backpressure แบบ​จับ​ต้อง​ได้

มี mpsc::unbounded_channel() ให้​ใช้​ด้วย และ UnboundedSender::send() ไม่ใช่ async เพราะ​มัน​ไม่มี​วัน​ต้อง​รอ ฟัง​ดู​สะดวก​จน​น่า​เลือก แต่​ราคา​คือ​คุณ​โยน backpressure ทิ้ง​ไป​ทั้งดุ้น: ถ้า​ผู้บริโภค​ช้า​กว่า​ผู้​ผลิต คิว​จะ​โต​ไป​เรื่อยๆ จน​หน่วย​ความ​จำ​หมด ใช้​เมื่อ​คุณ รู้​แน่ ว่า​จำนวน​ข้อความ​มี​ขอบเขต​จาก​เหตุผล​อื่น​เท่านั้น

❌ capacity 0 = panic ไม่ใช่ rendezvous

คน​ที่มา​จาก 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 > 0

file ที่​สอง​คือ 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 ขึ้น​ไป​เสมอ

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=42
oneshot_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 ตอบ​กลับ​มา​เฉพาะ​เจ้าของ​คำถาม​นั้น

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=2
broadcast_fast=10
broadcast_slow_first=10
broadcast_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 เสมอ

ข้อ​อ้าง​สอง​ข้อ​ที่​แบก​น้ำหนัก​ที่สุด​ของ​หัวข้อ​นี้ — 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 tests
test tests::recv_returns_none_only_after_the_last_sender_is_dropped ... ok
test 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# / .NETRustจุด​ที่​ต่าง​จริง
lock (obj) { … } และ Monitorstd::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 บน heapArc<T>.NET มี GC ดูแล lifetime ให้​ฟรี; Arc คือ refcount แบบ​อะตอม​ที่​คุณ​เติม​เอง
Monitor reentrant ได้​ใน thread เดิมtokio::sync::Mutex ไม่ reentrantล็อก​ซ้ำ = deadlock ทันที
System.Threading.Channels.Channel<T>mpscWriteAsyncsend().await; ReadAllAsync จบ ≈ recv() คืน None; CreateUnboundedunbounded_channel()
TaskCompletionSource<T>oneshotทั้ง​คู่​ให้​คำ​ตอบ​ใบ​เดียว และ​ฝั่ง​รับ await ได้​เหมือน Task<T>
ไม่มี​คู่​ตรง​ใน BCLbroadcastChannels เป็น single-consumer; broadcast ใกล้ Rx subject ที่​มี ring buffer จำกัด จึง​มี Lagged ที่ BCL ไม่มี​แนวคิด​เทียบ

แถว​แรก​คือ​แถว​ที่​ผม​อยาก​ให้​จำ​ที่สุด: C# กับ Rust ตัดสิน​ใจ​เรื่อง​เดียวกัน แต่​คนละ​ชั้น C# ฝัง​กฎ “ห้าม await ใน lock” ไว้​ใน​ตัว​ภาษา​เป็น​กรณี​พิเศษ​หนึ่ง​กรณี ส่วน Rust ไม่มี​กฎ​เฉพาะ​กิจ​นั้น​เลย มัน​ใช้​ระบบ trait ที่​ทั่วไป​กว่า (Send) แล้ว​ปล่อย​ให้​ผลลัพธ์​ตก​มา​เอง ข้อดี​คือ​กฎ​เดียวกัน​นี้​คุ้มครอง​ทุก​อย่าง​ที่​ไม่ Send ไม่ใช่​แค่ lock ข้อ​เสีย​คือ error ที่​ได้​อ้อม​กว่า​และ​อ่าน​ยาก​กว่า​ตอน​แรก

honesty spine — สิ่ง​ที่​บท​นี้​ยืนยัน และ​สิ่ง​ที่​ไม่​ได้​สัญญา

สิ่ง​ที่​พิสูจน์​แล้ว​ใน​บท​นี้: เมทริกซ์ 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 is Send based 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 .await point… 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) — bounded send เป็น 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, Receiver implement Future
  • tokio::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 ตัวไหน และเพราะอะไร?