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

capstone — ยก kaen-kvstore ขึ้น Tokio: 1 task ต่อ​คอน​เนกชัน พร้อม graceful shutdown

เจ็ด​บท​ที่​ผ่าน​มา​เรา​แยก​ชิ้น​ส่วน​ของ async Rust ออก​ดู​ที​ละ​ชิ้น: Future ที่​ขี้เกียจ​กับ executor 15 บรรทัด​ที่​เขียน​เอง (บท 1) · Tokio runtime ที่​เป็น scheduler + I/O driver + timer (บท 2) · spawn กับ JoinHandle (บท 3) · async TCP กับ wire แบบ​นำ​หน้า​ด้วย​ความ​ยาว (บท 4) · select! กับ​การ​ยกเลิก​ด้วย​การ drop (บท 5) · state ที่ share กับ channel (บท 6) · และ​กับดัก​คลาสสิกอย่าง function colouring กับ​การ block runtime (บท 7)

บท​นี้​คือ​บท​ที่ ประกอบ​มัน​กลับ​เข้า​ด้วย​กัน​เป็น server ตัว​เดียว​ที่​รัน​จริงkaen-kvstore ตัว​เดิม​จาก #22 แต่​คราว​นี้​คอน​เนกชัน​ไม่​ต้อง​ยึด worker จาก ThreadPool ขนาด​คงที่ 4 ตัว​ไว้​ทั้ง​เส้น​อีก​แล้ว เป็น 1 task ต่อ​คอน​เนกชัน และ​ปิด​งาน​ด้วย graceful shutdowngraceful-shutdownสาม​ส่วน: ตรวจ​จับ (`ctrl_c`) แพร่​สัญญาณ (`broadcast`) รอ drain (`mpsc` recv → None) ที่​ครบ​สาม​ส่วนจริงๆ ไม่ใช่​แค่ process::exit(0) แล้ว​ภาวนา

📦 kaen-kvstore

บท​นี้ ต่อยอด repo kaen-kvstore จาก #22 (code ตัวอย่าง​กำลัง​จัด​ทำ) เป็น​บท​ปิด — เรา ไม่​ได้​เขียน store ใหม่ engine ชั้น​ล่าง (append-only log, hash index, write→fsync→ack, tombstone/compaction) ยัง​เป็น​ของ #22 ทุก​บรรทัด สิ่ง​ที่​บท​นี้​เปลี่ยน​คือ ชั้น​รับ​คอน​เนกชัน เท่านั้น: จาก ThreadPool ที่​เขียน​เอง​ใน​บท 5 ของ #22 กลาย​เป็น tokio::spawn เพียง​แต่​ทุก call บน​สาย​กลาย​เป็น .await ส่วน​เรื่อง protocol ต้อง​พูด​ให้​ตรง — เรา​เก็บ framing ของ #22 ไว้​ทั้งดุ้น (u32 little-endian นำ​หน้า​ความ​ยาว ตาม​ด้วย payload ตรง​กัน​ทุก byte) แต่​ทำ command layer ให้​เป็น text เรียบ​ง่าย เพื่อ​ให้​บท​นี้​โฟกัส​ที่ async ไม่ใช่​ที่ opcode (รายละเอียด​และ​ราคา​ที่​จ่าย​อยู่​ใน​หัวข้อ​ถัด​ไป) code ทั้ง​ก้อน​ใน​บท​นี้ compile และ​รัน​ได้​จริง​ใน​แซนด์บ็อกซ์ ไม่มี​อะไร​เป็น​ไดอะแกรม​อย่าง​เดียว

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

ทุก snippet pin ที่ rustc 1.97.1 (8bab26f4f 2026-07-14) · edition = “2024” · tokio 1.53.1 และ​นี่​คือ [dependencies] ทั้งหมด​ของ capstone:

[dependencies]
tokio = { version = "1.53.1", features = ["rt-multi-thread", "net", "io-util", "macros", "sync", "signal"] }

6 feature นี้​คือ​รายการ​ที่ ใช้​จริง ทุก​ตัว: rt-multi-thread (scheduler หลาย thread ที่ #[tokio::main] เปล่าๆ ต้องการ) · net (TcpListener/TcpStream) · io-util (AsyncReadExt/AsyncWriteExt) · macros (#[tokio::main], #[tokio::test], select!) · sync (broadcast, mpsc, Semaphore) · signal (ctrl_c)

สอง​ตัว​แรก​ใน​รายการ​นั้น​คือ​คู่​ที่​พลาด​กัน​บ่อย​ที่สุด และ​มัน​พลาด​กัน​คนละ​ทาง: ถ้า​ลืม rt-multi-thread แล้ว​เขียน #[tokio::main] เปล่าๆ จะ​ได้ error: The default runtime flavor is `multi_thread`, but the `rt-multi-thread` feature is disabled. เพราะ macros แจก​แต่​ตัว​มาโคร ไม่​ได้​พา scheduler มา​ให้​เลย​สัก​ตัว (flavor current_thread ต้องการ​แค่ "rt" ส่วน default ต้องการ "rt-multi-thread") — 🔁 บท 2 กับ​บท 5 เจอ​กับดัก​คู่​นี้​มา​แล้ว​ทั้ง​สอง​ทาง

ยัง​คง​ห้าม features = ["full"] ด้วย scope นี้ cargo ตอบ​กลับ​มา​ว่า:

Locking 16 packages to latest Rust 1.97.1 compatible versions

ตัวเลข 16 คือ​จำนวน entry ใน Cargo.lock (ไม่​นับ crate ของ​เรา​เอง) ซึ่ง​รวม wasi, windows-link, windows-sys ที่ lock ไว้​เผื่อ platform อื่น​และ ไม่​ถูก compile บน Linux เลย​สัก​ตัว ถ้า​นับ​เฉพาะ crate ที่ compile จริง บน x86_64-unknown-linux-gnu ตัวเลข​คือ 7 → 13 เทียบ​กับ 7 crate ของ​บท 1 ที่​มี​แค่ ["rt", "macros"] ส่วน​ที่​งอก​มา​คือ​หก​ตัว​พอดี: mio 1.2.2, socket2 0.6.5, libc 0.2.189, bytes 1.12.1, errno 0.3.14 และ signal-hook-registry 1.4.8 ซึ่ง​มา​จาก net กับ signal โดยตรง

code ของ capstone ทั้ง​ก้อน compile เขียว​และ​ผ่าน cargo clippy --target x86_64-unknown-linux-gnu -- -D warnings แบบ zero-warnings ยกเว้น snippet ที่​ติด​ป้าย ❌ ซึ่ง​ตั้งใจ​ให้ compile ไม่​ผ่าน — นั่น​คือ​ประเด็น​ของ​มัน ทุก​บรรทัด output ใน​บท​นี้​คือ​ข้อความ​จริง​ที่​พิมพ์​ออก​มา

kaen-kvstore ของ #22 รับ​งาน​แบบ​นี้: listener.incoming() ได้ socket มา​หนึ่ง​ตัว โยน​เข้า ThreadPool::new(4) ที่​เรา​เขียน​เอง แล้ว worker ตัว​ที่​รับ​งาน​ไป​จะ หมุน​วน​อ่าน-ตอบ​จน​คอน​เนกชัน​ปิด model นี้​อ่าน​ง่าย​มาก และ #22 ก็​เลือก​มัน​อย่าง​จงใจ​แล้ว (บท 5 ของ #22 ปฏิเสธ​การ spawn thread ต่อ​หนึ่ง​คอน​เนกชันดื้อๆ ไว้​ตั้งแต่​ต้น) ปัญหา​จึง​ไม่ใช่ “thread เยอะ​เกิน” — ปัญหา​คือ จำนวน worker เป็น​เพดาน​ของ​จำนวน​คอน​เนกชัน​พร้อม​กัน คอน​เนกชัน​ที่ 5 ต้อง​รอ​จนกว่า​จะ​มี​คน​วาง​สาย และ​ตลอด​เวลา​ที่ worker นั่ง block รอ byte ที่​ยัง​มา​ไม่​ถึง มัน​ก็​ยัง​กิน stack ก้อน​นั้น​เต็มๆ ทั้ง​ที่​ไม่​ได้​ทำงาน​อะไร​เลย

ถ้า​คุณ​เคย​ดู​ประวัติ​ฝั่ง .NET จะ​รู้สึก​คุ้น​ทันที — ยุค TcpListener.AcceptTcpClient() + new Thread(HandleClient) ก็​ตัน​แบบ​เดียวกันเป๊ะ ทางออก​ของ .NET คือ​ย้าย​ไป​เป็น await AcceptTcpClientAsync() แล้ว​โยน​งาน​เข้า Task ซึ่ง​ลง​จอด​ที่ ThreadPool ไม่​กี่ thread ทางออก​ของ Rust คือ​สิ่ง​เดียวกัน แค่​เรียก​ว่า tokio::spawn และ task ของ Tokio ไม่มี stack เป็น​ของ​ตัวเอง — มัน​มี​ขนาด​เท่ากับ state machine ที่ compiler สร้าง​จาก async fn ของ​คุณ ซึ่ง​เป็น struct ก้อน​เดียว​ที่ scheduler ถือ​ไว้

โครง​ของ accept loop จึง​กลาย​เป็น​แบบ​นี้ (นี่​คือ​รูป​ที่​จะ​เติม​ให้​ครบ​ตลอด​ทั้ง​บท):

loop {
let (sock, _) = listener.accept().await?;
let db = Arc::clone(&db);
tokio::spawn(async move { handle_conn(sock, db).await; });
}

หนึ่ง​บรรทัด​ต่อ​คอน​เนกชัน ไม่มี thread pool ที่​ต้อง​เขียน​เอง ไม่มี Worker ที่​ต้อง join ตอน​ปิด — แต่ ความ​สะดวก​นี้​เอง​คือ​ที่มา​ของ​ปัญหา​ทั้ง​บท เพราะ tokio::spawn แล้ว​ปล่อย​ทิ้ง แปล​ว่า​เรา​ไม่มี​อะไร​ถือ​ไว้​เลย​ว่า​มี​กี่​คอน​เนกชัน​ที่​ยัง​ทำงาน​อยู่ ตอน shutdown เรา​จึง​ไม่รู้​ว่า​จะ​รอ​ใคร

framing ไม่​เปลี่ยน​แม้แต่ byte เดียว: u32 little-endian บอก​ความ​ยาว ตาม​ด้วย payload เท่านั้น ที่​เปลี่ยน​คือ​ฝั่ง I/O — Read/Write ของ std กลาย​เป็น AsyncReadExt/AsyncWriteExt ของ Tokio และ​ทุก method กลาย​เป็น .await และ2 function นี้​ไม่ใช่​ของ​ใหม่​ด้วย​ซ้ำ — มัน​คือ read_frame_or_eof/write_frame ที่ 🔁 บท 4 เขียน​และ test ไว้​แล้ว ยก​มา​ทั้งดุ้น​รวม​ทั้ง bound R: AsyncReadExt + Unpin:

use std::collections::HashMap;
use std::future::Future;
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{Semaphore, broadcast, mpsc};
type Db = Arc<Mutex<HashMap<String, Vec<u8>>>>;
const MAX_CONNS: usize = 64;
async fn read_frame_or_eof<R: AsyncReadExt + Unpin>(
r: &mut R,
) -> std::io::Result<Option<Vec<u8>>> {
let mut hdr = [0u8; 4];
let mut filled = 0;
while filled < 4 {
match r.read(&mut hdr[filled..]).await? {
0 if filled == 0 => return Ok(None),
0 => {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"header truncated",
));
}
n => filled += n,
}
}
let len = u32::from_le_bytes(hdr) as usize;
let mut buf = vec![0u8; len];
r.read_exact(&mut buf).await?;
Ok(Some(buf))
}
async fn write_frame<W: AsyncWriteExt + Unpin>(w: &mut W, payload: &[u8]) -> std::io::Result<()> {
w.write_u32_le(payload.len() as u32).await?;
w.write_all(payload).await?;
w.flush().await?;
Ok(())
}

เหตุผล​ที่​เรา​ไม่​ลัด​ด้วย read_u32_le() แล้ว map UnexpectedEof เป็น Ok(None) คือ​สิ่ง​ที่​บท 4 พิสูจน์​ไป​แล้ว: ทาง​ลัด​นั้น แยก “ปิด​สาย​อย่าง​สุภาพ” กับ “หัว frame ขาด​กลางคัน” ไม่​ออก — peer ที่​ตาย​หลัง​ส่ง​หัว frame ไป 2 จาก 4 byte จะ​ถูก​รายงาน​ว่า​บอกลา​เรียบร้อย loop while filled < 4 ที่​อ่าน​หัว frame ด้วย​มือ​จึง​จำเป็น: Ok(None) เกิด​ได้ เฉพาะ​ตอน filled == 0 เท่านั้น คือ​ยัง​ไม่​ได้​อ่าน​อะไร​เลย​สัก byte ส่วน byte ที่ 1–3 แล้ว​สาย​ขาด​คือ Err(UnexpectedEof, "header truncated") test ตัว​ที่​สอง​ท้าย​บท​ตรึง​สอง​เคส​นี้​ไว้​ทั้ง​คู่ จำ​จุด​นี้​ไว้ เดี๋ยว​มัน​จะ​กลับ​มา​ตอน​พูด​เรื่อง cancellation-safety

เทียบ​กับ C# ตรงๆ: loop อ่าน​หัว frame + read_exact คือ ReadAsync ที่​วน​จน​ครบ บวก BinaryPrimitives.ReadUInt32LittleEndian; write_all + flush คือ WriteAsync + FlushAsync สัญชาตญาณ​โอน​มา​ได้​ทั้งดุ้น

นี่​คือ​จุด​ที่​คน​อ่าน​คู่มือ Tokio ผ่านๆ แล้ว​เลือก​ผิด​บ่อย​ที่สุด เห็น​คำ​ว่า async ก็​คว้า tokio::sync::Mutex มา​ใช้​ทันที ทั้ง​ที่​คู่มือ​ของ Tokio เอง​เขียน​ไว้​ตรง​ข้าม: std mutex ใช้​จาก async ได้​และ​มัก​จะ​เป็น​ตัว​เลือก​ที่​ถูกต้อง ตราบ​ใด​ที่ contention ต่ำ​และ ไม่​ถือ guard ข้าม .await

KV map ของ​เรา​เข้า​เงื่อนไข​นั้น​เต็มๆ — insert กับ get บน HashMap ใน​หน่วย​ความ​จำ​ใช้​เวลา​ระดับ​ไมโคร​วินาที ไม่มี I/O อยู่​ระหว่าง​กลาง การ​ไป​ใช้ async mutex ที่​ต้อง​ผ่าน scheduler จะ​ช้า​กว่า​โดย​เปล่า​ประโยชน์

กุญแจ​ของ discipline นี้​คือ เอา lock ไป​ไว้​ใน function non-async แล้ว​ให้ guard ตาย​ไป​พร้อม​กับ frame ของ function นั้น ไม่มี​ทาง​ที่​มัน​จะ​รอด​ออก​มา​เจอ .await ได้​เลย:

fn apply(db: &Db, req: &[u8]) -> Vec<u8> {
let text = match std::str::from_utf8(req) {
Ok(t) => t,
Err(_) => return b"ERR bad-utf8".to_vec(),
};
let mut parts = text.splitn(3, ' ');
let mut map = db.lock().unwrap();
match (parts.next(), parts.next(), parts.next()) {
(Some("SET"), Some(k), Some(v)) => {
map.insert(k.to_string(), v.as_bytes().to_vec());
b"OK".to_vec()
}
(Some("GET"), Some(k), None) => match map.get(k) {
Some(v) => v.clone(),
None => b"NOTFOUND".to_vec(),
},
_ => b"ERR bad-request".to_vec(),
}
} // guard drop ที่นี่ — ก่อน caller จะ await อะไรได้

สังเกต​ว่า apply คืน Vec<u8> ที่ คัด​ลอก​ค่า​ออก​มา​แล้ว ไม่​ได้​คืน reference เข้าไป​ใน map นั่น​คือ​ราคา​ที่​จ่าย​เพื่อ​ให้ guard ตาย​ทัน​เวลา — และ​เป็น​ราคา​ที่​ถูก​มาก​เมื่อ​เทียบ​กับ​สิ่ง​ที่​จะ​เกิด​ถ้า​เรา​ไม่​จ่าย

ลอง​เขียน​แบบ​ที่ อ่าน​แล้ว​ดู​สม​เหตุ​สม​ผล — lock ไว้​ใน​ตัว handler เลย จะ​ได้​ไม่​ต้อง clone ค่า​ออก​มา file เต็มๆ ของ ❌ version ดิบ เป็น​แบบ​นี้:

use std::collections::HashMap;
use std::sync::{Arc, Mutex};
type Db = Arc<Mutex<HashMap<String, Vec<u8>>>>;
async fn flush_to_disk() {}
async fn handle_conn(db: Db) {
let mut map = db.lock().unwrap();
map.insert("k".into(), b"v".to_vec());
flush_to_disk().await;
println!("{}", map.len());
}
#[tokio::main]
async fn main() {
let db: Db = Arc::new(Mutex::new(HashMap::new()));
tokio::spawn(handle_conn(db));
}

cargo build --target x86_64-unknown-linux-gnu ตอบ​กลับ​มา​แบบ​นี้:

error: future cannot be sent between threads safely
--> src/main.rs:18:18
|
18 | tokio::spawn(handle_conn(db));
| ^^^^^^^^^^^^^^^ future returned by `handle_conn` is not `Send`
|
= help: within `impl Future<Output = ()>`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, HashMap<String, Vec<u8>>>`
note: future is not `Send` as this value is used across an await
--> src/main.rs:11:21
|
9 | let mut map = db.lock().unwrap();
| ------- has type `std::sync::MutexGuard<'_, HashMap<String, Vec<u8>>>` which is not `Send`
10 | map.insert("k".into(), b"v".to_vec());
11 | flush_to_disk().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`

อ่าน​ลูกโซ่​ของ​เหตุผล​จาก​ล่าง​ขึ้น​บน: tokio::spawn ต้องการ F: Future + Send + 'static เพราะ scheduler แบบ​หลาย thread อาจ​ย้าย task ไป​ทำ​ต่อ​บน thread อื่น​กลางคัน · future ตัว​นี้​ไม่ Send เพราะ​มี​ค่าที่ ยัง​มี​ชีวิต​ข้าม .await · ค่า​นั้น​คือ std::sync::MutexGuard ซึ่ง​จงใจ​ไม่ implement Send มา​ตั้งแต่​ต้น

นี่​คือ บรรทัด​เดียวกัน​กับ CS1996 ของ C# ที่​ห้าม await ใน block lock — แต่ Rust ไป​ไกล​กว่า​ตรง​ที่​ไม่​ต้อง​มี​กฎ​เฉพาะ​กิจ มัน​ตก​มา​จาก​ระบบ type เอง MutexGuard ไม่ Send, future ที่​อุ้ม​มัน​ไว้​ข้าม .await จึง​ไม่ Send, และ spawn ก็​ปฏิเสธ

ปฏิกิริยา​แรก​ของ​ทุก​คน​คือ​ใส่ drop(map); คั่น​ไว้​ก่อน .await ลองดูจริงๆ file เดิม​ทุก​อย่าง แค่​แทรก drop(map); เป็น​บรรทัด​ที่ 11 (และ​ตัด println! ที่​ใช้ map ทิ้ง):

async fn handle_conn(db: Db) {
let mut map = db.lock().unwrap();
map.insert("k".into(), b"v".to_vec());
drop(map);
flush_to_disk().await;
}

ยัง ไม่​ผ่าน (ตัด​เหลือ​บรรทัด​ที่​มี​น้ำหนัก):

error: future cannot be sent between threads safely
--> src/main.rs:18:18
|
18 | tokio::spawn(handle_conn(db));
| ^^^^^^^^^^^^^^^ future returned by `handle_conn` is not `Send`
|
= help: within `impl Future<Output = ()>`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, HashMap<String, Vec<u8>>>`
note: future is not `Send` as this value is used across an await
--> src/main.rs:12:21
|
9 | let mut map = db.lock().unwrap();
| ------- has type `std::sync::MutexGuard<'_, HashMap<String, Vec<u8>>>` which is not `Send`
...
12 | flush_to_disk().await;
| ^^^^^ await occurs here, with `mut map` maybe used later

เหตุผล​อยู่​ใน​วลี maybe used later — การ​วิเคราะห์​ที่​ตัดสิน​ว่า​อะไร​ต้อง​อยู่​ใน​ตัว state machine ทำงาน​ที่​ระดับ ขอบเขต​ของ​ตัวแปร (scope) ไม่ใช่​ระดับ “บรรทัด​สุดท้าย​ที่​มัน​ถูก​ใช้” ตัวแปร map ยัง อยู่​ใน​ขอบเขต ตอน​ที่ .await เกิด compiler จึง​จอง​ที่​ให้​มัน​ใน​โครงสร้าง future ไป​แล้ว ต่อ​ให้​ค่า​ข้าง​ใน​ถูก drop ไป​ก่อน​ก็ตาม

ทาง​ที่​ผ่าน​มี​สอง​ทาง และ​ทั้ง​สอง​ทาง​คือ ปิด scope จริงๆ (ทาง A ข้าง​ล่าง​คือ file เดิม​ทั้ง file — use สอง​บรรทัด, type Db, flush_to_disk ที่​โชว์​ไป​แล้ว​ข้าง​บน — เปลี่ยน​แค่ handle_conn กับ main):

// ทาง A — block ครอบ: guard ตายที่วงเล็บปิด
async fn handle_conn(db: Db) {
let len = {
let mut map = db.lock().unwrap();
map.insert("k".into(), b"v".to_vec());
map.len()
}; // guard drop ที่วงเล็บปิดนี้
flush_to_disk().await;
println!("GREEN scope-block ok, len={len}");
}
#[tokio::main]
async fn main() {
let db: Db = Arc::new(Mutex::new(HashMap::new()));
tokio::spawn(handle_conn(db)).await.unwrap(); // .await ตัว JoinHandle ไม่งั้น main จบก่อน task
}
GREEN scope-block ok, len=1

.await ต่อ​ท้าย tokio::spawn(...) ใน​บรรทัด​สุดท้าย​ไม่ใช่​ของ​ประดับ: ถ้า main คืน​ค่า​ไป​เฉยๆ runtime จะ​ถูก​ปิด​พร้อม​กับ process และ task ที่​เพิ่ง spawn ไป​อาจ​ยัง​ไม่ทัน​ได้​พิมพ์​อะไร​เลย — บรรทัด GREEN scope-block ok, len=1 จะ​กลาย​เป็นการ​แข่ง​กับ​การ​ปิด runtime แทนที่​จะ​เป็น​ผลลัพธ์​ที่​แน่นอน (รัน​สาม​รอบ​ติด​กัน​ได้​บรรทัด​เดิม​ทุกรอบ​เพราะ​เรา join แล้ว)

ทาง B คือ​สิ่ง​ที่ capstone ใช้​จริง: ยก​ทั้ง​ก้อน​ออก​ไป​เป็น function non-async อย่าง apply() ข้าง​บน ซึ่ง​ได้​ผล​เดียวกัน​แต่​แข็งแรง​กว่า เพราะ function fn ธรรมดา เขียน .await ลง​ไป​ไม่​ได้​เลย​ตั้งแต่​แรก — คน​ที่มา​แก้ code ต่อ​จาก​คุณ​อีก​หก​เดือน​ข้าง​หน้า​จึง​พลาด​ไม่​ได้ ไม่ใช่​แค่ “ยัง​ไม่​พลาด”

ประโยค​เดียว​ที่​ต้อง​จำ​จาก​บท​นี้

drop(guard) ไม่ใช่​คำ​ตอบ — scope ต่างหาก เอา lock ไป​ไว้​ใน function non-async แล้ว​ปัญหา​ทั้ง​ชั้น​นี้​หาย​ไป​โดย​โครงสร้าง ไม่ใช่​โดย​วินัย

ทีนี้​มา​ถึง​หัวใจ​ของ​บท graceful shutdown ที่​ทำ​จริง​มี​สาม​ส่วน และ​ขาด​ส่วน​ไหน​ไป​ก็​พัง​คนละ​แบบ:

  1. ตรวจ​จับ — รู้​ว่า​ถูก​สั่ง​ให้​ปิด (tokio::signal::ctrl_c())
  2. แพร่​สัญญาณ — บอก​ทุก task ที่​กำลัง​ทำงาน​อยู่ (broadcast ที่​ทุก​คอน​เนกชัน subscribe)
  3. รอ drain — รอจน in-flight connection จบจริงๆ ก่อน process จะ​ตาย (mpsc ที่ recv() คืน None เมื่อ sender หมด)

ส่วน​ที่ 3 คือ​ส่วน​ที่​คน​ลืม​บ่อย​ที่สุด และ​เป็น​ส่วน​ที่​ทำให้​คำ​ว่า graceful มี​ความหมาย ถ้า​มี​แค่ 1 กับ 2 คุณ​ก็​แค่ บอก ทุก​คน​ว่า​จะ​ปิด แล้ว​ปิด​ทับ​หน้า​เขา​ทันที

flowchart TD
    A[ctrl_c หรือ SIGTERM] --> B[select ใน loop accept แตกออกจาก loop]
    B --> C[broadcast send แพร่สัญญาณไปทุก task]
    C --> D1[task คอนเนกชัน 1 ตอบ BYE แล้วจบ]
    C --> D2[task คอนเนกชัน 2 ตอบ BYE แล้วจบ]
    B --> E[drop drain_tx ตัวหลักทิ้ง]
    D1 --> F[sender ที่ task ถือ ถูก drop ตาม]
    D2 --> F
    E --> F
    F --> G[drain_rx recv คืน None เพราะ sender หมดเกลี้ยง]
    G --> H[ถึงตรงนี้แปลว่าไม่มี in-flight connection เหลือ ปิด process ได้]

คำ​บรรยาย​ภาพ: กลไก graceful shutdown สาม​ส่วน​ของ capstone — สัญญาณ​จาก ctrl_c ทำให้ loop accept แตก​ออก จาก​นั้น broadcast แพร่​สัญญาณ​ให้​ทุก task คอน​เนกชัน​รู้ตัว​และ​ตอบ BYE ก่อน​จบ พร้อม​กัน​นั้น​เรา​ทิ้ง sender ตัว​หลัก​ของ​ช่อง drain ทิ้ง เมื่อ task สุดท้าย​จบ​และ sender สำเนา​สุดท้าย​ถูก drop ตาม ตัว​รับ​ของ​ช่อง drain จะ​คืน None ซึ่ง​เป็น​หลักฐาน​ว่า​ไม่​มี​คอน​เนกชัน​ไหน​ค้าง​อยู่​แล้ว จึง​ปิด process ได้​อย่าง​ปลอดภัย

กลไก drain ใน​ข้อ 3 คือทริก​ที่​สวย​ที่สุด​ใน​บท​นี้ และ​มัน​ไม่​ได้​ส่ง​ข้อมูล​อะไร​เลย​สัก byte — เรา​สร้าง mpsc::channel::<()>(1) แล้ว​ยัด Sender สำเนา​หนึ่ง​เข้าไป​ใน​ทุก task ที่ spawn โดย ไม่​เคย​เรียก send เลย ตัว task ถือ​มัน​ไว้​เฉยๆ พอ task จบ Sender ก็​ถูก drop ไป​ด้วย เมื่อ​ฝั่ง​หลัก​ทิ้ง drain_tx ตัว​ต้นแบบ​และ task ตัว​สุดท้าย​จบ​ลง sender ก็​หมด​เกลี้ยง drain_rx.recv().await จึง​คืน None — นี่​คือ Task.WhenAll ที่​ไม่​ต้อง​เก็บ List<Task> ไว้​เลย

async fn run_server(
listener: TcpListener,
shutdown: impl Future<Output = ()>,
) -> std::io::Result<Db> {
tokio::pin!(shutdown);
let db: Db = Arc::new(Mutex::new(HashMap::new()));
let (shutdown_tx, _) = broadcast::channel::<()>(1);
let (drain_tx, mut drain_rx) = mpsc::channel::<()>(1);
let limit = Arc::new(Semaphore::new(MAX_CONNS));
loop {
tokio::select! {
res = listener.accept() => {
let (sock, _) = res?;
let permit = Arc::clone(&limit).acquire_owned().await.unwrap();
let db = Arc::clone(&db);
let rx = shutdown_tx.subscribe();
let d = drain_tx.clone();
tokio::spawn(async move {
let _permit = permit;
let _drain = d;
handle_conn(sock, db, rx).await;
});
}
_ = &mut shutdown => {
let _ = shutdown_tx.send(());
break;
}
}
}
drop(drain_tx);
let _ = drain_rx.recv().await; // None เมื่อ sender ฝั่ง task หมด = drain ครบ
Ok(db)
}

รายละเอียด​ที่​ไม่ใช่​ของ​ประดับ:

  • tokio::pin!(shutdown) — future ของ​สัญญาณ​ปิด​ต้อง​อยู่​รอด​ข้าม​การวน loop เรา​จึง​ตรึง​มัน​ไว้​ก่อน​เข้า loop แล้ว​ใช้ &mut shutdown ใน​แต่ละ​รอบ ถ้า​เขียน shutdown เปล่าๆ ใน loop มัน​จะ​ถูก move ใน​รอบ​แรก — เป็น E0382 แบบ​เดียว​กับ​ที่​ลืม .as_mut() ในบท 1 เป๊ะ
  • drop(drain_tx) ก่อน recv()ถ้า​ลืม​บรรทัด​นี้ โปรแกรม​จะ​ค้าง​ตลอด​กาล เพราะ​ยัง​มี sender ตัว​หนึ่ง​มี​ชีวิต​อยู่​ใน​มือ​เรา​เอง recv() จึง​ไม่มี​วัน​คืน None
  • let _ = shutdown_tx.send(());broadcast::send คืน Err เมื่อ ไม่มี​ใคร subscribe อยู่​เลย ซึ่ง​กรณี​ปิด server ที่​ไม่​มี​คอน​เนกชัน​ค้าง​เป็น​เรื่อง​ปกติ ไม่ใช่ error
  • run_server รับ shutdown เป็น impl Future แทนที่​จะ​เรียก ctrl_c() ข้างในตรงๆ — นี่​คือ​สิ่ง​เดียว​ที่​ทำให้ test ใน​หัวข้อ​ถัด​ไป​มี​อยู่​ได้

ฝั่ง​คอน​เนกชัน: select! ระหว่าง​อ่าน frame กับ​สัญญาณ​ปิด

หัวข้อ​ที่​มีชื่อ​ว่า “ฝั่ง​คอน​เนกชัน: select! ระหว่าง​อ่าน frame กับ​สัญญาณ​ปิด”
async fn handle_conn(mut sock: TcpStream, db: Db, mut shutdown: broadcast::Receiver<()>) {
loop {
tokio::select! {
frame = read_frame_or_eof(&mut sock) => match frame {
Ok(Some(req)) => {
let resp = apply(&db, &req);
if write_frame(&mut sock, &resp).await.is_err() {
return;
}
}
_ => return,
},
_ = shutdown.recv() => {
let _ = write_frame(&mut sock, b"BYE").await;
return;
}
}
}
}

apply(&db, &req) เรียก​แบบ sync ตรง​กลาง​ของ async fn ได้​อย่าง​สบายใจ เพราะ​มัน​เป็น fn ธรรมดา​ที่ guard ตาย​ก่อน​คืน​ค่า​เสมอ — นี่​คือ​ทาง B ของ​หัวข้อ​ก่อนหน้า​ใช้งาน​จริง

cancellation-safety: ทำไม branch นี้​ถึง​ปลอดภัย (และ​เมื่อไหร่​ที่​มัน​จะ​ไม่)

หัวข้อ​ที่​มีชื่อ​ว่า “cancellation-safety: ทำไม branch นี้​ถึง​ปลอดภัย (และ​เมื่อไหร่​ที่​มัน​จะ​ไม่)”

บท 5 วาง​กฎ​ไว้​แล้ว​ว่า select! ที่​วน​ใน loop จะ drop future ของ branch ที่​แพ้​ทิ้ง​ทุกรอบ ฉะนั้น​ทุก​ครั้ง​ที่​เขียน select! ใน loop คำถาม​ที่​ต้อง​ตอบ​ให้​ได้​คือ: ถ้า future ตัว​นี้​ถูก drop กลางคัน ข้อมูล​หาย​ไหม

read_frame_or_eof ของ​เรา ไม่ cancel-safe ชัดเจน — ลอง​ไล่​ดู: มัน​อ่าน 4 byte ของ​ความ​ยาว​สำเร็จ แล้ว​เข้า read_exact เพื่อ​อ่าน payload ถ้า​โดน drop ตอน​อ่าน payload ไป​ได้​ครึ่ง​ทาง byte ที่​ดูด​ออก​จาก socket ไป​แล้ว​จะ​หาย​ไป​กับ buffer ที่​ถูก drop ทิ้ง รอบ​ถัด​ไป​ที่​เรียก​ใหม่ มัน​จะ​ไป​อ่าน​กลาง frame เก่า​แล้ว​ตีความ payload เป็น “ความ​ยาว” — สาย corrupt ทันที​โดย​ไม่มี​ใคร​สังเกต

แล้ว​ทำไม code ข้าง​บน​ถึง​ยัง​ถูก? เพราะ branch ที่​ชนะ​แล้ว​เรา​ไม่​กลับ​เข้า loop อีก​เลย สังเกต​ว่า branch shutdown.recv() ลงท้าย​ด้วย return ไม่ใช่ continue เมื่อ​สัญญาณ​ปิด​มา​ถึง เรา​ไม่​ได้ “พัก read_frame_or_eof ไว้​ก่อน​แล้ว​ค่อย​กลับ​มา​อ่าน​ต่อ” — เรา เลิก​อ่าน​ถาวร frame ที่​อ่าน​ค้าง​จึง​ไม่มี​ใคร​สนใจ​อีก เพราะ​คอน​เนกชัน​นี้​กำลัง​จะ​ปิด​อยู่​แล้ว

นี่​คือ​รูป​ทั่วไป​ของ​กฎ: future ที่​ไม่ cancel-safe วาง​ใน select! ได้ ก็​ต่อ​เมื่อ branch ที่​ทำให้​มัน​ถูก cancel เป็น branch ที่​จบ​เรื่อง ถ้า​คุณ​เขียน code ที่​ดู​คล้าย​กัน​มาก​แต่​ลงท้าย​ด้วย continue — เช่น เอา heartbeat timer มา​แข่ง​กับ read_frame_or_eof แล้ว​วน​ต่อ — คุณ​จะ​ได้ protocol corruption แบบ​สุ่ม​ที่ reproduce ยาก​ที่สุด​ใน​ชีวิต ทาง​แก้​มาตรฐาน​คือ​ย้าย buffer ที่​อ่าน​ค้าง​ออก​ไป​ไว้ นอก future (เป็น field ของ struct connection) เพื่อ​ให้การ​อ่าน​รอบ​ใหม่​กลับ​มา​ต่อ​จาก​เดิม​ได้ Tokio เอง​ก็​เดิน​เส้น​นี้: เอกสาร​ของ AsyncBufReadExt::read_line ระ​บุตรงๆ ว่า​มัน ไม่ cancel safe — ถูก cancel กลางคัน​แล้ว​ข้อมูล​ที่​อ่าน​ไป​แล้ว​หาย​เลย — แต่ next_line() ของ stream ที่​ได้​จาก lines() cancel safe เพราะ​สถานะ​ที่​อ่าน​ค้าง​ไป​เก็บ​อยู่​ใน​ตัว Lines ไม่ใช่​ใน​ตัว future ที่​ถูก drop 🔁 ทบทวน​รายละเอียด​ที่ บท 5 — select! กับ​การ​ยกเลิก

เหลือ​สอง​ชิ้น​ที่​ต้อง​เติม​ให้​ครบ: client จับ​คู่ กับ​ตัว main ที่​ประกอบ​ทุก​อย่าง client เป็น​แค่ function เดียว​ที่​ใช้ write_frame/read_frame_or_eof ตัว​เดียว​กับ server (wire เดียวกัน code เดียวกัน):

async fn request(addr: &str, req: &str) -> std::io::Result<Vec<u8>> {
let mut sock = TcpStream::connect(addr).await?;
write_frame(&mut sock, req.as_bytes()).await?;
let resp = read_frame_or_eof(&mut sock).await?.unwrap_or_default();
Ok(resp)
}

และ​งาน sync ก้อน​สุดท้าย​ที่​ยก​มา​จาก #22 ตรงๆ คือ​การ​เขียน snapshot ลง disk — สังเกต​ว่า​มัน​เป็น fn ธรรมดา ไม่มี async ปน​เลย​สัก​บรรทัด และ​มี sync_all() (คือ fsync) อยู่​ข้าง​ใน:

fn write_snapshot_sync(path: &str, db: &HashMap<String, Vec<u8>>) -> std::io::Result<usize> {
use std::io::Write;
let mut body = Vec::new();
for (k, v) in db {
body.extend_from_slice(k.as_bytes());
body.push(b'=');
body.extend_from_slice(v);
body.push(b'\n');
}
let mut f = std::fs::File::create(path)?;
f.write_all(&body)?;
f.sync_all()?; // fsync — syscall แบบ blocking ของแท้
Ok(body.len())
}

เหลือ​ชิ้น​สุดท้าย​คือ​ตัว​สัญญาณ​ปิด — จุด​นี้​ต้อง​พูด​ให้​ตรง การ​กด Ctrl-C จริง​เป็น interactive เรา​จึง​รัน​มัน​ใน​แซนด์บ็อกซ์​ไม่​ได้ วิธี​ที่​ซื่อสัตย์​ที่สุด​คือ​ไม่​แกล้ง​ทำ​เป็น​ว่า​รัน​ได้ แต่​เขียน​สัญญาณ​ให้ select! ระหว่าง ctrl_c() ตัว​จริง กับ​ตัว​กระตุ้น​จำลอง​ผ่าน mpscctrl_c() จึง​ถูก compile และ​ตรวจ signature จริง​ใต้ feature signal ทุก​ครั้ง ส่วน​สิ่ง​ที่ รัน ใน​แซนด์บ็อกซ์​คือ​ฝั่ง​จำลอง นี่​คือ จุด​เดียว​ใน​ทั้ง​คอร์ส​ที่​รัน​จริง​ใน​แซนด์บ็อกซ์​ไม่​ได้ 100% และ​หน้าตา​ของ​มัน​บวก​กับ main ที่​ประกอบ​ทุก​อย่าง​เข้า​ด้วย​กัน​คือ​แบบ​นี้:

async fn shutdown_signal(mut simulated: mpsc::Receiver<()>) {
tokio::select! {
r = tokio::signal::ctrl_c() => {
if let Err(e) = r {
eprintln!("ctrl_c listener failed: {e}");
}
}
_ = simulated.recv() => {}
}
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:7878").await?;
let addr = listener.local_addr()?.to_string();
let (sim_tx, sim_rx) = mpsc::channel::<()>(1);
tokio::spawn(async move {
let _ = request(&addr, "SET foo bar").await;
let got = request(&addr, "GET foo").await.unwrap_or_default();
println!("SELFTEST_GET_RESULT={}", String::from_utf8_lossy(&got));
let _ = sim_tx.send(()).await;
});
let db = run_server(listener, shutdown_signal(sim_rx)).await?;
println!("GRACEFUL_SHUTDOWN_OK");
let snapshot = { db.lock().unwrap().clone() };
let n = tokio::task::spawn_blocking(move || write_snapshot_sync("kaen.snapshot", &snapshot))
.await
.unwrap()?;
println!("SNAPSHOT_BYTES={n}");
Ok(())
}

cargo run --target x86_64-unknown-linux-gnu ได้​ผล​ตาม​นี้:

SELFTEST_GET_RESULT=bar
GRACEFUL_SHUTDOWN_OK
SNAPSHOT_BYTES=8

สาม​บรรทัด​นี้​คือ​หลักฐาน​ครบ​ชุด: SET foo bar แล้ว GET foo ได้ bar กลับ​มา​จริง​ผ่าน socket จริง · GRACEFUL_SHUTDOWN_OK พิมพ์ หลัง drain_rx.recv() คืน None แปล​ว่า in-flight connection จบ​ครบ​ก่อน​แล้ว · และ 8 byte ของ snapshot คือ foo=bar\n ที่​ถูก fsync ลง disk บน blocking pool

เพราะ run_server รับ​สัญญาณ​ปิด​เป็น parameter เรา​จึง test ทั้ง​วงจร​ได้​ใน test เดียว​โดย​ไม่​ต้อง​มี signal จริง​เลย bind("127.0.0.1:0") ให้ OS เลือก port ว่าง​ให้ — test จึง​รัน​ขนาน​กัน​ได้​ไม่​ชน​กัน ส่วน test ตัว​ที่​สอง​มี​ไว้​ตรึง​เส้น​แบ่ง​ที่ read_frame_or_eof ทั้ง function มี​อยู่​เพื่อ​มัน: ปิด​สาย​ตรง​ขอบ frame = Ok(None) ส่วน​หัว frame ขาด​กลางคัน = Err ถ้า​วัน​หนึ่ง​มี​คน​มา “ทำให้​สั้น​ลง” ด้วย read_u32_le() test ตัว​นี้​จะ​แดง​ทันที:

#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn round_trip_then_graceful_drain() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let (tx, rx) = mpsc::channel::<()>(1);
let server = tokio::spawn(run_server(listener, shutdown_signal(rx)));
assert_eq!(request(&addr, "SET foo bar").await.unwrap(), b"OK");
assert_eq!(request(&addr, "GET foo").await.unwrap(), b"bar");
assert_eq!(request(&addr, "GET nope").await.unwrap(), b"NOTFOUND");
tx.send(()).await.unwrap();
let db = server.await.unwrap().unwrap();
assert_eq!(db.lock().unwrap().get("foo").unwrap(), b"bar");
}
#[tokio::test]
async fn clean_close_is_none_truncated_header_is_err() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
// สายแรก: ปิดตรงขอบ frame พอดี ยังไม่ส่งอะไรเลยสัก byte
let (sock, _) = listener.accept().await.unwrap();
drop(sock);
// สายที่สอง: ส่งหัว frame ไป 2 จาก 4 byte แล้วตาย
let (mut sock, _) = listener.accept().await.unwrap();
sock.write_all(&[1, 0]).await.unwrap();
drop(sock);
});
let mut clean = TcpStream::connect(&addr).await.unwrap();
assert!(read_frame_or_eof(&mut clean).await.unwrap().is_none());
let mut truncated = TcpStream::connect(&addr).await.unwrap();
let err = read_frame_or_eof(&mut truncated).await.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
assert_eq!(err.to_string(), "header truncated");
}
}
running 2 tests
test tests::round_trip_then_graceful_drain ... ok
test tests::clean_close_is_none_truncated_header_is_err ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

บรรทัด server.await.unwrap().unwrap() คือ​หัวใจ​ของ test นี้: มัน​จะ​คืน​ค่า​ก็​ต่อ​เมื่อ run_server เดิน​ผ่าน drain เรียบร้อย​แล้ว ถ้า drain ค้าง (เช่น​ลืม drop(drain_tx)) test นี้​จะ​แขวน​จนกว่า harness จะ​ฆ่า​ทิ้ง — เป็น​ตาข่าย​ที่​จับ bug ที่ debug ยาก​ที่สุด​ของบทได้ตรงๆ

หัวข้อ​ก่อนหน้า​ทั้งหมด​มี compiler ยืน​คุ้มกัน​อยู่ ทีนี้​มา​ถึง​ส่วน​ที่ ไม่มี​ใคร​คุ้มกัน​เลย

ย้อน​กลับ​ไป​ดู​ข้อความ error ของ guard-across-await ที่​ยาว​เป็น​หน้า นั่น​คือ Rust ตอน​ที่​มัน​ดัง​ที่สุด ทีนี้​ดู​โปรแกรม​นี้ — task A เรียก std::thread::sleep ซึ่ง​เป็น blocking syscall เต็ม​ตัว ส่วน task B ไม่​ทำ​อะไร​เลย​นอกจาก​พิมพ์​เวลา:

use std::time::{Duration, Instant};
#[tokio::main(flavor = "current_thread")]
async fn main() {
let t0 = Instant::now();
let a = tokio::spawn(async move {
std::thread::sleep(Duration::from_millis(300)); // blocking syscall ใน async task
println!("A done at {:>4}ms", t0.elapsed().as_millis());
});
let b = tokio::spawn(async move {
println!("B done at {:>4}ms", t0.elapsed().as_millis());
});
let _ = tokio::join!(a, b);
}

cargo clippy --target x86_64-unknown-linux-gnu -- -D warnings ผ่าน​เงียบๆ ไม่มี warning สัก​บรรทัด แล้ว​รัน​ได้​ผล​นี้:

A done at 306ms
B done at 306ms

task B ไม่มี​งาน​ให้​ทำ​เลย​แม้แต่​นิดเดียว แต่​มัน​ได้​พิมพ์​ตอน 306 ms เพราะ​มัน​ติด​อยู่​หลัง A ที่​ยึด thread ของ runtime ไว้​ทั้งดุ้น ใน server จริง​นี่​คือ​ทุก​คอน​เนกชัน​ที่​ต่อ​คิว​อยู่​บน thread นั้น​ค้าง​พร้อม​กัน​หมด และ scheduler ก็​ทำ​อะไร​ไม่​ได้ เพราะ Rust ยึด​คืน task ที่​กำลัง​รัน​อยู่​ไม่​ได้ — มัน​ได้​คืน worker เฉพาะ​ตอน​ที่ task ยอม .await เท่านั้น

ทาง​แก้​คือ spawn_blocking ซึ่ง​ย้าย​งาน blocking ไป​ที่ blocking pool แยก​ต่างหาก (default สูงสุด 512 thread) แล้ว​คืน worker ให้ scheduler ทันที:

let a = tokio::spawn(async move {
tokio::task::spawn_blocking(move || std::thread::sleep(Duration::from_millis(300)))
.await
.unwrap();
println!("A done at {:>4}ms", t0.elapsed().as_millis());
});
B done at 0ms
A done at 302ms

0ms กับ 306ms — code 2 version นี้​ต่าง​กัน​แค่​ห่อ​ด้วย spawn_blocking และ​ทั้ง​คู่ compiler เงียบ​เท่า​กัน นี่​คือ​เหตุผล​ที่ capstone ห่อ write_snapshot_sync (ที่​ข้าง​ใน​มี File::sync_all() = fsync ของ​จริง) ไว้​ใน spawn_blocking เสมอ — และ​เป็น​เหตุผล​ที่ fsync กับ compaction ของ #22 ทุก​จุด​ต้อง​เข้า blocking pool ตอน​ย้าย​มา​อยู่​บน Tokio

เทียบ​กับ .NET: ThreadPool ของ CLR โต​อัตโนมัติ​เมื่อ​เจอ thread starvation (ช้า​หน่อย​แต่​มัน​โต) Tokio ไม่​โต — worker มี​เท่า​ที่​ตั้ง​ไว้​ตอน build runtime ผล​คือ​การ block worker ใน Rust เจ็บ​กว่า ใน C# มาก ทั้ง​ที่​คำ​เตือน​น้อย​กว่า

ย้อน​กลับ​ไป​ดู accept loop ร่าง​แรก​ของ​บท​อีก​ครั้ง — นี่​คือ ❌ version ดิบ ของ​หัวข้อ​นี้​เต็มๆ:

loop {
let (sock, _) = listener.accept().await?;
let db = Arc::clone(&db);
tokio::spawn(async move { handle_conn(sock, db).await; }); // ❌ ไม่มีเพดาน
}

มัน​รับ​ทุก​คอน​เนกชัน​ที่​เข้า​มา​แล้ว spawn ทันที​โดย​ไม่มี​เพดาน ใน​เว็บ​สาธารณะ​แปล​ว่า​ใคร​ก็​เปิด​คอน​เนกชัน​รัว​จน​หน่วย​ความ​จำ​หมด​ได้ ✅ ทาง​แก้​คือ Semaphore ที่ acquire_owned() คืน permit ซึ่ง​ย้าย​เข้าไป​อยู่​ใน​ตัว task แล้ว​ปล่อย​คืน​อัตโนมัติ​ตอน task จบ — นี่​คือ​สาม​บรรทัด​ที่​ทำให้​ร่าง​แรก​ข้าง​บน​กลาย​เป็น run_server ตัว​จริง​ที่​เรา​ประกอบ​ไป​แล้ว:

let permit = Arc::clone(&limit).acquire_owned().await.unwrap();
// ...
tokio::spawn(async move {
let _permit = permit; // ถือไว้เฉย ๆ ปล่อยคืนตอน task จบ
let _drain = d;
handle_conn(sock, db, rx).await;
});

acquire_owned (ไม่ใช่ acquire) เป็น​ตัว​ที่​ต้อง​ใช้ เพราะ permit ต้อง​มีอายุ​ยืน​เท่ากับ task ที่ 'static ไม่​ผูก​กับ borrow ของ limit และ​นี่​คือ backpressure แบบ​เดียว​กับ​ที่ 🔁 บท 6 — state ที่ share กับ channel สอน​กับ bounded channel เพียง​แต่​ย้าย​มา​อยู่​ที่​ชั้น​คอน​เนกชัน

พูด​ให้​ตรง​ข้อ​หนึ่ง: การ await permit ไว้ ใน​ตัว branch ของ accept แปล​ว่า​ตอน​ที่​คอน​เนกชัน​เต็ม​เพดาน loop จะ​หยุด​รับ​สาย​ใหม่​ชั่วคราว (kernel backlog รับ​ไว้​ให้​แทน) ซึ่ง​เป็น​สิ่ง​ที่​เรา​ต้องการ — แต่​มัน​ก็​หน่วง​การ​รับรู้​สัญญาณ shutdown ไป​ด้วย​จนกว่า​จะ​มี permit ว่าง ถ้า​อยาก​ปิด​ช่อง​นั้น ต้อง​เอา acquire_owned() ไป​แข่ง​ใน select! กับ​สัญญาณ shutdown อีก​ชั้น

C# / .NETRust + Tokioจุด​ที่​ต่าง​จริง
AcceptTcpClientAsync() + _ = HandleAsync(c)listener.accept().await + tokio::spawn(...)ฝั่ง C# task เริ่ม​เดิน​ทันที​ที่​เรียก ฝั่ง Rust ไม่มี​อะไร​เดิน​จนกว่า spawn จะ​ส่ง​เข้า scheduler
lock (obj) { await ... }CS1996guard ข้าม .awaitfuture cannot be sent between threads safelyC# มี​กฎ​เฉพาะ​กิจ; Rust ตก​มา​จาก​ระบบ type เพราะ MutexGuard ไม่ Send
SemaphoreSlim.WaitAsync()tokio::sync::Mutex::lock().awaitใช้​เมื่อ ต้อง ถือ lock ข้าม await จริงๆ เท่านั้น ไม่ใช่ default
CancellationTokenSource ที่​ทุก task observebroadcast::Sender + Receiver ต่อ taskRust ไม่มี token กลาง แต่​มี​ช่อง​ทาง​แพร่​ที่​ทุก task subscribe เอง
Task.WhenAll(connectionTasks)mpsc drain จน recv() คืน Noneไม่​ต้อง​เก็บ list ของ task ไว้​เลย ใช้​การ drop Sender เป็น​สัญญาณ​แทน
Task.WhenAny(work, ct.AsTask())tokio::select! { ... }select! drop future ที่​แพ้​ทิ้ง; WhenAny ปล่อย​ให้​มัน​วิ่ง​ต่อ​ใน​เบื้องหลัง
Console.CancelKeyPress / ApplicationStoppingtokio::signal::ctrl_c().awaitรูป​เดียวกัน แต่ Rust ต้อง​เปิด feature signal เอง
Task.Run(() => syncWork()) เพื่อ offloadtokio::task::spawn_blocking(...)ThreadPool ของ CLR โต​เอง​เมื่อ starve; worker ของ Tokio ไม่​โต — block ที่​นี่​เจ็บ​กว่า​มาก
honesty spine 4 เส้น — ปิด​บัญชี​ทั้ง​คอร์ส

A — Future ขี้เกียจ​จริง: capstone นี้​ไม่มี​บรรทัด​ไหน​ที่ “รัน​เอง” เลย ทุก future เดิน​ได้​เพราะ​มี​คน .await หรือ spawn มัน tokio::spawn(async move { ... }) ใน loop accept คือ​จุด​เดียว​ที่​งาน​ของ​คอน​เนกชัน​เริ่ม​มี​ชีวิต ถ้า​ลบ spawn ทิ้ง​แล้ว​เหลือ handle_conn(sock, db, rx); เป็น​คำ​สั่ง​เปล่าๆ คอน​เนกชัน​จะ​เงียบ​สนิท แต่​ไม่​ได้​เงียบ​สนิท​เสียที​เดียว — ตาข่าย #[must_use] ของ​บท 1 ยัง​ทำงาน compiler ยิง warning: unused implementer of `Future` that must be used พร้อม note: futures do nothing unless you `.await` or poll them ให้ (ยืนยัน​แล้ว​ด้วย​การ​รัน​จริง) จุด​ที่​ต้อง​ระวัง​คือ​รูป let _ = handle_conn(sock, db, rx); ต่างหาก — let _ = ปิดปาก lint ตัว​นี้​สนิท compile ผ่าน​แบบ zero-warnings แล้ว​คอน​เนกชัน​เงียบจริงๆ โดย​ไม่มี​ใคร​บอก​อะไร​เลย

B — std ไม่มี runtime มา​ให้: ทุก​อย่าง​ที่​ทำให้​บท​นี้​เป็น​ไป​ได้ — scheduler หลาย thread, reactor ที่​คุย epoll, broadcast/mpsc, Semaphore, ตัว​จับ signal, blocking pool — มา​จาก crate เดียว​ชื่อ tokio ไม่มี​อัน​ไหน​อยู่​ใน std เลย และ​เรา​ยัง​คง​เลือก​เปิด​แค่ 6 feature ที่​ใช้​จริง ไม่ใช่ "full"

C — function colouring จริง: apply() เป็น fn ธรรมดา ซึ่ง​เป็น ข้อ​ได้​เปรียบ ใน​บท​นี้ เพราะ​มัน​เขียน .await ลง​ไป​ไม่​ได้​เลย แต่​ใน​ทาง​กลับ​กัน มัน​ก็​เรียก​อะไร​ที่​เป็น async ไม่​ได้​เลย​เช่น​กัน ถ้า​วัน​หนึ่ง apply ต้อง​อ่าน​จาก log ที่​เป็น async ขึ้น​มา คุณ​ต้อง​เปลี่ยน​สี​มัน​ทั้ง function แล้ว​ปัญหา guard ก็​จะ​กลับ​มา​ทันที

D — ที่​แซนด์บ็อกซ์​รัน​ได้​จริง​แค่​ไหน: ทั้ง 8 บท​ตรวจ​ด้วย recipe เดียว​คือ build/run/test/clippy บน target x86_64-unknown-linux-gnu — กล musl + rust-lld ที่ #22/#23 ใช้ได้​เพราะ​เป็น zero-crate ปลดระวาง​ไป​ตั้งแต่​บท​แรก (#21 เอง​มี clap/serde/thiserror อยู่​แล้ว สิ่ง​ที่​ใหม่​จริง​ใน​คอร์ส​นี้​คือ crate dependency กลาย​เป็น แกน ของ​ทุก​บท และ tokio ลาก native dependency อย่าง mio/libc/socket2 เข้า​มา​ด้วย ซึ่ง​ต้อง​ใช้ system linker) จุด​เดียว​ใน​ทั้ง​คอร์ส​ที่​รัน​จริง​ใน​แซนด์บ็อกซ์​ไม่​ได้​คือ​การ​กด Ctrl-C เพราะ​มัน​เป็น interactive — เรา​จึง​ให้ ctrl_c() ตัว​จริง​ถูก compile และ​ตรวจ signature ทุก​ครั้ง​ใต้ feature signal แล้ว​จำลอง​การ​กด​ด้วย mpsc สำหรับ​ส่วน​ที่​รัน​จริง เรา​ไม่​แกล้ง​ทำ​เป็น​ว่า​รัน​ได้ และ​ไม่​แสดง output ที่​ไม่​ได้​เกิด​ขึ้น​จริง​สัก​บรรทัด

คุณ​เดิน​ครบ​สี่​คอร์ส​ของ Systems pillar แล้ว และ​มัน​เป็น​เส้น​เรื่อง​เดียว​ที่​ต่อ​กันจริงๆ ไม่ใช่​คอร์ส​สี่​คอร์ส​ที่​บังเอิญ​ใช้​ภาษา​เดียวกัน:

  • #21 rust-from-scratch — เรียน​ภาษา ownership/borrow/lifetime, trait, error handling, thread + channel จบ​ด้วย CLI ที่ std ล้วน
  • #22 rust-kvstore — สร้าง​เอนจิน kaen-kvstore node เดียว​ที่​รัน​จริง: wire แบบ​นำ​หน้า​ด้วย​ความ​ยาว, append-only log + hash index, write→fsync→ack, tombstone/compaction และ thread pool ที่​เขียน​เอง ซึ่ง​บท​นี้​เพิ่ง​ปลดระวาง​ไป
  • #23 rust-distributed-systems — กระจาย​มัน เลื่อน node เดียว​ขึ้น​เป็น cluster: logical clock, consistent hashing, quorum, CRDT, anti-entropy พร้อม​เส้น scope ที่​ซื่อสัตย์​ว่า consensus พิสูจน์​ใน​เครื่อง​เดียว​ไม่​ได้
  • #24 rust-async-tokio — คอร์ส​นี้: ทำให้​มัน concurrent อย่าง​ที่​ควร​จะ​เป็น จาก Future ที่​ขี้เกียจ​ไป​จนถึง server 1 task ต่อ​คอน​เนกชัน ที่​วิ่ง​บน worker ไม่​กี่​ตัว มี​เพดาน​พร้อม​กัน​ที่ MAX_CONNS = 64 ตัว และ​ปิด​ตัวเอง​ได้​อย่าง​สะอาด​ด้วย graceful shutdown สาม​ส่วน (เรา​ไม่​อ้าง​ตัวเลข throughput ใดๆ — ไม่​ได้​วัด จึง​ไม่​พูด)

บทเรียน​กลาง​ของ​คอร์ส​นี้ ถ้า​จะ​เหลือ​ไว้​ประโยค​เดียว คือ compiler ของ Rust คุ้มกัน​คุณ​ได้​แค่​ครึ่ง​เดียว​ใน​โลก async ครึ่ง​ที่​มัน​คุ้มกัน​นั้น​ทรง​พลัง​จน​น่า​ทึ่ง — ถือ MutexGuard ข้าม .await แล้ว​มัน​ด่า​ยาว​เป็น​หน้า​พร้อม​ชี้​บรรทัด​ให้​ครบ อีก​ครึ่ง​ที่​เหลือ — block worker ด้วย syscall, ปล่อย future ที่​ไม่ cancel-safe ไว้​ใน loop ที่​ยัง​วน​ต่อ, spawn โดย​ไม่มี​เพดาน, ลืม drain ก่อน​ปิด — มัน​เงียบ​สนิท ทุก​กับดัก​ใน​หัวข้อ production hardening ผ่าน clippy -D warnings ได้​หมด นั่น​แปล​ว่า​ความ​รู้เรื่อง runtime ไม่ใช่​ของ​แถม แต่​เป็น​สิ่ง​ที่​คุณ​ต้อง​ถือ​ไว้​ใน​หัว​เอง

ก้าว​ถัด​ไป​ของ Systems pillar ที่​จ่อ​คิว​อยู่​คือ rust-interpreter (Systems V) — เขียน interpreter ของ​ภาษา​เล็กๆ ด้วย Rust ตั้งแต่ lexer ถึง tree-walking evaluator ซึ่ง​จะ​พา​ไป​เจอ Rust อีก​ด้าน​ที่​คอร์ส​นี้​ไม่​ได้​แตะ​เลย: enum กับ pattern matching ที่ลึกจริงๆ, ownership บน AST ที่​เป็น recursive และ error reporting ที่​มนุษย์​อ่าน​รู้เรื่อง


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

  • tokio — crates.io (เข้าถึง 2026-07-27) — รุ่น​ล่าสุด 1.53.1 ปล่อย​ราว 2026-07-20 ซึ่ง​เป็น​รุ่น​ที่​ทุก snippet ใน​คอร์ส​นี้ pin ไว้ และ MSRV ของ​มัน​คือ Rust 1.71
  • Tokio Tutorial — Shared state (เผยแพร่ 2024-01-01 · เข้าถึง 2026-07-27) — ที่มา​ของ​กฎ​ที่​บท​นี้​ยึด: std mutex ใช้​จาก async ได้​และ​มัก​เป็น​ตัว​เลือก​ที่​ถูกต้อง​เมื่อ contention ต่ำ ตราบ​ใด​ที่ ไม่​ถือ guard ข้าม .await เพราะ std::sync::MutexGuard ไม่ Send
  • Tokio Topics — Graceful Shutdown (เผยแพร่ 2024-01-01 · เข้าถึง 2026-07-27) — โครง​สาม​ส่วน​ที่ capstone นี้​ใช้​ทั้งดุ้น: ตรวจ​จับ​ด้วย ctrl_c, แพร่​สัญญาณ​ผ่าน channel ที่ race ด้วย select! และ​รอ drain ด้วย mpsc ที่ recv() คืน None เมื่อ Sender หมด
  • tokio::signal::ctrl_c — docs.rs (เข้าถึง 2026-07-27) — signature ctrl_c() -> io::Result<()> ที่ complete เมื่อ​ได้​รับ Ctrl-C อยู่​ใต้ feature signal
  • tokio::task::spawn_blocking — docs.rs (เข้าถึง 2026-07-27) — spawn_blocking<F, R> where F: FnOnce() -> R + Send + 'static รัน​บน blocking pool แยก (default สูงสุด 512 thread) คือ​ที่​ที่ fsync/compaction ของ #22 ต้อง​ไป​อยู่
  • ไทม์​ไลน์​อ้างอิง​ของ​ทั้ง​คอร์ส (ไม่มี link เดียว​รวม): เทรต Future stable ใน Rust 1.36.0 (2019-07-04) · async/.await stable ใน 1.39.0 (2019-11-07) · Tokio 1.0 ปล่อย 2020-12-23

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

ข้อ 1 / 3

ใน version ดิบของบทนี้ เราแทรก drop(map); ไว้ก่อน .await แล้ว แต่ cargo build ก็ยังตอบ future cannot be sent between threads safely อยู่ดี ข้อใดอธิบายได้ถูกต้อง?