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

select!, timeout และ​การ​ยกเลิก — cancel ใน Rust คือ​การ drop future

คำถาม​แรก​ที่ .NET dev ถาม​เมื่อ​มา​ถึง​จุด​นี้​เสมอ​คือ “แล้ว CancellationToken ของ Rust อยู่​ตรง​ไหน” — คำ​ตอบสั้นๆ คือ ไม่มี และ​ไม่​ต้อง​มี เพราะ Rust ยกเลิก​งาน​ด้วย​กลไก​ที่​คุณ​ใช้​มา​ตั้งแต่ #21 โดย​ไม่รู้ตัว: drop มัน​ทิ้ง

ฟัง​ดูเหมือน​เล่นคำ แต่​มัน​คือ​ความ​ต่าง​เชิง​โครงสร้าง​ที่​ลึก​ที่สุด​ข้อ​หนึ่ง​ระหว่าง​สอง​ภาษา ใน .NET cts.Cancel() ไม่​หยุด​อะไร​ทั้งนั้น มัน​แค่​พลิกบู​ลีน​หนึ่ง​ตัว​แล้ว​ปลุก callback ที่​ลง​ทะเบียน​ไว้ ถ้า code ข้าง​ใน​ไม่​เคย​เรียก ThrowIfCancellationRequested() หรือ​ไม่​เคย​ส่ง token ต่อ​ลง​ไป งาน​นั้น​ก็​ยัง​วิ่ง​ของ​มัน​ต่อ​ไป​จน​จบ​อย่าง​มี​ความ​สุข — cancellation ของ .NET เป็น cooperative คือ ขอ​ความ​ร่วมมือ ส่วน​ใน Rust ถ้า​คุณ drop future ทิ้ง มัน หยุด​จริง โดย​ไม่​ต้อง​ขอ​ความ​ร่วมมือ​จาก​ใคร เพราะ future คือ state machine ที่​จะ​เดิน​หน้า​ได้​ก็​ต่อ​เมื่อ​มี​คน poll มัน พอ​ไม่มี​ใคร​ถือ​มัน​อยู่ ก็​ไม่มี​ใคร poll ก็​ไม่มี​บรรทัด​ไหน​รัน​อีก — cancellation ของ Rust เป็น structural คือ รับประกัน​โดย​โครงสร้าง

บท​นี้​ไล่​ตั้งแต่​ความ​จริง​ข้อ​นั้น ไป​จนถึง select! (เครื่องมือ​ที่​ทำให้การ drop เกิด​ขึ้น​เป็น​ปกติ​ทุก​วินาที), tokio::time ทั้ง​ชุด, กับดัก​ชื่อ cancellation safety ที่​กิน​ข้อมูล​คุณ​หาย​ไป​เงียบๆ และ​โครง graceful shutdown สาม​ส่วน​ที่​บท 8 จะ​ยก​ไป​ใช้​ทั้งดุ้น

📦 kaen-kvstore

คอร์ส​นี้ ต่อยอด repo kaen-kvstore จาก #22 (code ตัวอย่าง​กำลัง​จัด​ทำ) — บท 4 เพิ่ง​ยก accept loop ของ store ขึ้น​ไป​อยู่​บน tokio::net แล้ว บท​นี้​คือ​บท​ที่​ทำให้​มัน​ปิด​ตัว​เป็น server ที่​รับ​คอน​เนกชัน​ได้​อย่าง​เดียว​แต่​ไม่รู้จัก​คำ​ว่า “หยุด” ยัง​ไม่ใช่ server ที่ deploy ได้ ทุก​ชิ้น​ใน​บท​นี้ — select! บน accept loop, timeout ต่อ read, interval สำหรับ​งาน periodic, และ broadcast + mpsc สำหรับ shutdown — จะ​ถูก​ประกอบ​เข้า​ด้วย​กัน​เป็น​ตัว​จริง​ใน capstone บท 8

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

ทุก snippet pin ที่ rustc 1.97.1 (8bab26f4f 2026-07-14) และ edition = “2024” กับ tokio 1.53.1 และ​บท​นี้​เป็น​บท​ที่ feature list ยาว​ที่สุด​ของ​คอร์ส:

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

อ่านที​ละ​ตัว​ว่า​มา​ทำ​อะไร: macros ให้ tokio::select! กับ #[tokio::main]/#[tokio::test] · time ให้ sleep/timeout/interval ทั้ง module · signal ให้ ctrl_c() · sync ให้ broadcast/mpsc · net + io-util คือ​ของ​เดิม​จาก​บท 4 · และ test-util คือ​ตัว​ใหม่​ที่​ปลด​ล็อก นาฬิกา​ปลอม ให้ test timing ไม่ flaky ห้าม​ใช้ 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 ส่วน​ที่​งอก​มา​คือ​หก​ตัว​พอดี: mio, socket2, libc, bytes, signal-hook-registry, errno ซึ่ง​เห็น​ชัด​ว่า​มา​จาก net กับ signal ตรงๆ

ทุก​อย่าง​ใน​บท​นี้​ผ่าน cargo build / cargo run / cargo test / cargo clippy -- -D warnings บน target x86_64-unknown-linux-gnu แบบ zero-warnings (ยกเว้น version ที่​จงใจ​ถอด feature ออก​ใน​หัวข้อ RED feature-gate ซึ่ง​ตั้งใจ​ให้ compile ไม่​ผ่าน) และ ทุก​บรรทัด output ข้าง​ล่าง​คือ​ข้อความ​จริง​ที่​พิมพ์​ออก​มา ไม่ใช่​ของ​ที่​เขียน​ขึ้น​เอง

snippet ทุก​อัน​ของ​บท​นี้​ต่อ​กันลง​ใน src/main.rs file เดียว และ​นี่​คือ​หัว file ทั้งหมด​ที่​ต้อง​มี — ลอก​ไป​วาง​ได้​เลย ไม่มี use ตัว​ไหน​ซ่อน​อยู่​นอก block นี้:

use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{broadcast, mpsc};
use tokio::time::{Duration, MissedTickBehavior, interval, sleep, timeout};

เริ่ม​จาก​พิสูจน์​ให้​เห็น​ก่อน งาน​หนึ่ง​ชิ้น​ที่​นับ​ตอน​ถูก drop แข่ง​กับ timer สั้นๆ:

static CANCELLED: AtomicUsize = AtomicUsize::new(0);
/// งานยาวที่นับตอนถูก drop — Drop คือ "cleanup" แบบ sync อย่างเดียวที่ async Rust รับประกันให้
struct SlowJob {
name: &'static str,
}
impl Drop for SlowJob {
fn drop(&mut self) {
CANCELLED.fetch_add(1, Ordering::SeqCst);
println!(" Drop::drop -> job \"{}\" ถูกยกเลิก (cancel = drop)", self.name);
}
}
impl SlowJob {
async fn run(self) -> &'static str {
sleep(Duration::from_secs(30)).await;
println!(" job \"{}\" จบงาน (บรรทัดนี้จะไม่ถูกพิมพ์)", self.name);
self.name
}
}
async fn demo_cancel_is_drop() {
println!("--- 1. cancel คือการ drop future ---");
let slow = SlowJob { name: "rebuild-index" };
let winner = tokio::select! {
v = slow.run() => v,
() = sleep(Duration::from_millis(50)) => "timer",
};
println!(" winner = {winner} · cancelled_total = {}", CANCELLED.load(Ordering::SeqCst));
}

รัน​จริง​ได้​ตาม​นี้:

--- 1. cancel คือการ drop future ---
Drop::drop -> job "rebuild-index" ถูกยกเลิก (cancel = drop)
winner = timer · cancelled_total = 1

sleep(30s) ข้าง​ใน SlowJob::run ไม่มี​ทาง​จบ​ก่อน timer 50ms แน่ๆ ฉะนั้น​เมื่อ timer ชนะ future ของ run จะ​ถูก drop ทิ้ง​ทั้ง​ก้อน และ​เพราะ run(self) กิน SlowJob เข้าไป​เป็น​ส่วน​หนึ่ง​ของ state machine ตัว SlowJob จึง​ถูก drop ตาม​ไป​ด้วย → Drop::drop ยิง บรรทัด println! ที่​อยู่ หลัง .await ใน​นั้น​ไม่​เคย​รัน และ​ไม่มี​วัน​รัน

นี่​คือ​รูป​เต็ม​ของ cancellation-by-dropcancellation-by-dropการ​ยกเลิก async = drop future มัน​หยุด​ที่ `.await` ล่าสุด (รัน​แค่ `Drop` sync): การ​ยกเลิก​ใน async Rust คือ​การ​เลิก​ถือ future แล้ว​ปล่อย​ให้​มัน​ถูก​ทำลาย ผลลัพธ์​ที่​ตาม​มา​มี​สาม​ข้อ​ที่​ต้อง​จำ

  1. มัน​หยุด​ที่ .await ล่าสุด​เสมอ ไม่ใช่ “หยุด​กลาง​บรรทัด” — future เดิน​หน้า​ได้​เฉพาะ​ตอน​ถูก poll ฉะนั้น​จุด​ที่​มัน​ค้าง​อยู่​คือ suspension point ที่ compiler วาง​ไว้ คือ .await
  2. ไม่มี​การ​ฆ่า​แบบ​บังคับ ไม่มี Thread.Abort ไม่มี Task ที่​ถูก​ยิง​ทิ้ง​กลาง CPU-bound loop — ถ้า future ตัว​หนึ่ง​ไม่​เคย​คืน​การ​ควบคุม​กลับ​มา (เช่น​วน​คำนวณ​ยาวๆ โดย​ไม่มี .await) มัน​จะ ยกเลิก​ไม่​ได้ เพราะ​ไม่มี​จังหวะ​ให้​ใคร​มา​แตะ​มัน
  3. Drop เท่านั้น​ที่​รัน และ Drop เป็น sync — ไม่มี async destructor ใน Rust แปล​ว่า​ถ้า cleanup ของ​คุณ​ต้อง .await (ส่ง QUIT ปิด session, flush ลง disk แล้ว​รอ ack, คืน lease ให้ coordinator) คุณ​ทำ​ใน Drop ไม่​ได้ นี่​คือ​ช่องว่าง​จริง​ที่​ไม่มี​คำ​ตอบสวยๆ — ทาง​แก้​ที่​ใช้​กัน​คือ​ทำ cleanup ให้​เสร็จ ก่อน ปล่อย​ให้​ถูก cancel หรือ​ฝาก​งาน​ไว้​กับ task อื่น​ที่​รู้​ว่า​ตัวเอง​จะ​ไม่​โดน cancel
ประโยค​เดียว​ที่​ต้อง​จำ​จาก​บท​นี้

cts.Cancel() ของ .NET เป็น​แค่ คำขอ ที่ code ต้อง​เช็ค​เอง — ส่วน drop future ของ Rust เป็น การ​รับประกัน ว่า​จะ​ไม่มี progress อีก​หลัง .await ล่าสุด ราคา​ที่​จ่าย​คือ cleanup ได้​เฉพาะ Drop แบบ sync เท่านั้น

selectselectมาโคร​แข่ง future หลาย​ตัว​ใน task เดียว ตัว​ชนะ​อยู่ ตัว​แพ้​ถูก drop (cancel) คือ​มาโคร​ที่ poll future หลาย​ตัว​พร้อม​กัน บน task เดียวกัน แล้ว​คืน​ค่า​ทันที​ที่​ตัว​แรก​จบ เอกสาร​ของ tokio::select! ระ​บุตรงๆ ว่า​เมื่อ branch หนึ่ง​จบ​และ pattern match ผ่าน async expression ที่​เหลือ​ทั้งหมด​จะ​ถูก drop ก่อน​ที่ handler จะ​เริ่ม​รัน — นั่น​คือ​คือ “ยกเลิก​ตัว​แพ้” ที่​เรา​เพิ่ง​พิสูจน์​ไป​ใน​หัวข้อ​ที่​แล้ว

sequenceDiagram
    participant T as task เดียว ที่รัน select
    participant A as future A คือ accept
    participant B as future B คือ ctrl_c
    T->>A: poll รอบเดียวกัน
    A-->>T: Pending
    T->>B: poll รอบเดียวกัน
    B-->>T: Pending
    Note over T: task หลับรอ Waker ของทั้งสองตัว
    B-->>T: Ready คือ B ชนะ
    Note over T: drop future A ทันที ก่อนรัน handler ของ B
    T->>T: รัน handler ของ B แล้วออกจาก select
    Note over T,A: A ไม่ได้ถูกฆ่า แต่ถูกทำลาย ไม่มีใครถือ ไม่มีใคร poll

คำ​บรรยาย​ภาพ: กลไก​ของ select ทั้งหมด​อยู่​บน task เดียว ไม่มี​การ spawn เพิ่ม — มาโคร poll ทุก branch ใน​รอบ​เดียวกัน ใคร​คืน Ready ก่อน​คือ​ผู้​ชนะ จาก​นั้น future ของ branch ที่​เหลือ​ถูก drop ทิ้ง​ก่อน handler จะ​เริ่ม​ทำงาน การ drop นั้น​เอง​คือ​การ cancel เพราะ future ที่​ไม่มี​ใคร​ถือ​ย่อม​ไม่มี​ใคร poll และ​ไม่มี​ทาง​เดิน​หน้า​ต่อ​ได้​อีก

สาม​เรื่อง​เกี่ยว​กับ select! ที่​ต้อง​รู้​ก่อน​ใช้​จริง:

หนึ่ง — ลำดับ poll เป็น​แบบ​สุ่ม​โดย default มาโคร ไม่​ได้ ไล่​จาก​บน​ลง​ล่าง แต่​สุ่ม​ลำดับ​ใหม่​ทุก​ครั้ง​เพื่อ​ความ​ยุติธรรม กัน​ไม่​ให้ branch แรก​ที่​พร้อม​ตลอด​เวลา​อด​ตาย branch ล่างๆ ถ้า​คุณ​ต้องการ​ลำดับ​ตายตัว​จาก​บน​ลง​ล่าง ใส่ token biased; เป็น​บรรทัด​แรก​ใน​มาโคร:

// biased; = poll บนลงล่างตามลำดับที่เขียน
tokio::select! {
biased;
() = sleep(Duration::from_millis(0)) => println!("branch 1"),
() = sleep(Duration::from_millis(0)) => println!("branch 2"),
}

ลอง select! ที่2 branch พร้อมพอๆ กัน​ทั้ง​คู่ (sleep 0ms เท่า​กัน) แบบ biased; สาม​รอบ แล้ว​แบบ default หก​รอบ ได้​ผล​ตาม​นี้ (ครึ่ง​หลัง​คือ​ของ​สุ่ม — รัน​ใหม่​จะ​ได้​ลำดับ​อื่น):

branch 1
branch 1
branch 1
fair -> branch 2
fair -> branch 1
fair -> branch 1
fair -> branch 2
fair -> branch 2
fair -> branch 1

สอง — select! panic ถ้า​ทุก branch ถูก​ปิด แต่ละ branch ใส่ precondition , if <bool> ต่อ​ท้าย​ได้ ถ้า precondition ของ​ทุก branch เป็น​เท็จ​พร้อม​กัน​และ​คุณ​ไม่​ได้​เขียน else ไว้ มาโคร​จะ panic ทันที ไม่ใช่​ค้าง:

let ready = false;
tokio::select! {
() = sleep(Duration::from_millis(1)), if ready => println!("a"),
}
thread 'main' (97633) panicked at src/main.rs:8:9:
all branches are disabled and there is no else branch
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

สาม — ทุก​อย่าง​อยู่​บน task เดียว ไม่มี​การ spawn ซึ่ง​แปล​ว่า future ใน branch ไม่​ต้อง​เป็น Send และ share ตัวแปร​กับสโคป​รอบ​นอก​ได้​โดย​ไม่​ต้อง Arc (บท 6 จะ​ย้ำ​เรื่อง Send อีกรอบ​ตอน​ที่​มัน​กัด​จริง) แต่​ก็​แปล​ว่า​ถ้า branch ไหน block CPU ยาวๆ มัน​จะ block branch อื่น​ทั้งหมด​ด้วย

module tokio::time อยู่​ใต้ feature time ทั้งดุ้น มี​สาม​ตัว​ที่​ใช้​บ่อย​จน​ต้อง​ท่อง​ได้

sleep(Duration) คือ Task.Delay ตัว​ที่​ตรง​ที่สุด — คืน Sleep ที่​เป็น future (คู่​กัน​คือ sleep_until(Instant) ที่​รับ deadline แทน​ช่วง​เวลา) จุด​ที่​ต้อง​ระวัง: tokio::time::Duration คือ re-export ของ std::time::Duration ตัว​จริง ใช้​แทน​กัน​ได้ 100% แต่ tokio::time::Instant ไม่ใช่ std::time::Instant — มัน​เป็น​คนละ​ชนิด และ​นั่น​คือ​สาเหตุ​ที่​นาฬิกา​ปลอม​ใน​หัวข้อ​ถัด​ไป​ทำงาน​ได้

timeout(Duration, future) ห่อ future ตัว​ใด​ก็ได้​แล้ว​บังคับ​เส้นตาย โดย​ตัว​มัน​เอง​ก็​คือ cancel-by-drop อีก​ที: ครบ​เวลา​เมื่อไหร่​มัน drop future ข้าง​ใน​ทิ้ง ที่​สำคัญ​คือ มัน​คืน Result ไม่​ได้​โยน exception

async fn demo_timeout() {
println!("--- 2. timeout คืน Result ไม่ใช่ throw ---");
match timeout(Duration::from_millis(50), sleep(Duration::from_secs(30))).await {
Ok(()) => println!(" inner future จบทัน"),
Err(elapsed) => println!(" Err(Elapsed) -> {elapsed}"),
}
match timeout(Duration::from_secs(30), async { 7u32 }).await {
Ok(v) => println!(" Ok({v})"),
Err(_) => println!(" หมดเวลา"),
}
}
--- 2. timeout คืน Result ไม่ใช่ throw ---
Err(Elapsed) -> deadline has elapsed
Ok(7)

deadline has elapsed คือ​ข้อความ​ของ Elapsed จริงๆ ที่ tokio พิมพ์​ออก​มา ชนิด​ที่​ได้​คือ Result<F::Output, Elapsed> — สังเกต​ว่า​มัน ซ้อน​กัน​สอง​ชั้น เมื่อ future ข้าง​ใน​เอง​ก็​คืน Result อยู่​แล้ว เช่น read:

match timeout(Duration::from_secs(5), stream.read(&mut buf)).await {
Ok(Ok(n)) => { /* อ่านได้ n byte */ }
Ok(Err(e)) => return Err(e), // I/O พัง
Err(_elapsed) => { /* หมดเวลา: inner ถูก cancel ไปแล้ว */ }
}

สาม​แขน​นี้​คือ​สาม​เหตุการณ์​คนละ​เรื่อง​กัน และ​การ​ที่ Rust บังคับ​ให้​คุณ​เขียน​ครบ​ทั้ง​สาม​คือ​ข้อดี ไม่ใช่​ภาระ — ใน C# WaitAsync(timeout) โยน TimeoutException ออก​มา​ปน​อยู่​ใน catch เดียว​กับ exception อื่นๆ ทำให้​แยก​ยาก​กว่า

interval(Duration) คือ ticker สำหรับ​งาน periodic และ​มี​สอง​พฤติกรรม​ที่​สวน​สัญชาตญาณ​คน​ที่มา​จาก PeriodicTimer ของ .NET

let mut ticker = interval(Duration::from_millis(40));
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); // default = Burst
loop {
ticker.tick().await; // tick() เป็น cancel-safe ใส่ใน select! ได้สบายใจ
// งาน periodic
}
  • tick แรก​ยิง ทันที ไม่​รอ​ครบ period ก่อน ต่าง​จาก PeriodicTimer.WaitForNextTickAsync() ที่​รอ​รอบ​แรก​เสมอ ถ้า​คุณ​ไม่​ต้องการ​รอบ​ทันที ให้​กิน tick แรก​ทิ้ง​ก่อน​เข้า loop (เดี๋ยว​เรา​ทำ​แบบ​นั้น​จริง​ใน​หัวข้อ​ถัด​ไป) หรือ​ใช้ interval_at(start, period) กำหนด​จุด​เริ่ม​เอง
  • Duration::ZERO = panic ไม่ใช่ error ที่​คืน​กลับ​มา​ให้​จัดการ:
thread 'main' (98583) panicked at src/main.rs:12:21:
`period` must be non-zero.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
  • default MissedTickBehavior คือ Burst แปล​ว่า​ถ้า​งาน​ของ​คุณ​กิน​เวลา​นาน​กว่า period จน​พลาด​ไป​หลาย tick ticker จะ ยิง​รัว​ไล่​ให้​ทัน ทันที​ที่​ว่าง ซึ่ง​แทบ​ไม่ใช่​สิ่ง​ที่​คุณ​ต้องการ​สำหรับ​งาน​อย่าง health check เลย ตัว​เลือก​อีก​สอง​แบบ​คือ Delay (เลื่อน​ตาราง​ทั้ง​อัน​ออก​ไป) กับ Skip (ข้าม tick ที่​พลาด​แล้วกลับ​ไป​เข้า​ตาราง​เดิม) — งาน periodic ใน​โปร​ดัก​ชัน​ส่วน​ใหญ่​ควร​ตั้ง​เป็น Skip

feature test-util ปลด​ล็อก​สิ่ง​ที่ .NET ต้อง​พึ่ง FakeTimeProvider ถึง​จะ​ทำได้: หยุด​เวลา​ของ runtime แล้ว​เลื่อน​มัน​เอง เขียน #[tokio::test(start_paused = true)] แล้ว​นาฬิกา​ของ Tokio จะ​หยุด​นิ่ง และ​จะ กระโดด ไปหา timer ตัว​ถัด​ไป​เอง​อัตโนมัติ​ทุก​ครั้ง​ที่​ทุก task ใน runtime ว่าง​พร้อม​กัน ผล​คือ sleep, timeout, interval resolve ทันที​และ​ได้​ตัวเลข​เดิม​เป๊ะ​ทุก​ครั้ง

นี่​คือ​เหตุผล​ที่ tokio::time::Instant ต้อง​เป็น​คนละ​ชนิด​กับ std::time::Instant — ถ้า​มัน​เป็น​ตัว​เดียวกัน ก็​ไม่มี​ทาง​โกหก​มัน​ได้

#[cfg(test)]
mod tests {
use super::*;
use tokio::time::Instant; // Instant ใช้เฉพาะใน test ถ้ายกขึ้นไปหัว file จะโดน unused_imports
#[tokio::test(start_paused = true)]
async fn interval_ticks_at_0_2000_4000() {
let start = Instant::now();
let mut ticker = interval(Duration::from_secs(2));
let mut marks = Vec::new();
for _ in 0..3 {
ticker.tick().await;
marks.push(start.elapsed().as_millis());
}
assert_eq!(marks, vec![0, 2000, 4000]);
}
#[tokio::test(start_paused = true)]
async fn timeout_elapses_after_exactly_5s() {
let start = Instant::now();
let r = timeout(Duration::from_secs(5), sleep(Duration::from_secs(30))).await;
assert!(r.is_err());
assert_eq!(start.elapsed(), Duration::from_secs(5));
}
#[tokio::test(start_paused = true)]
async fn select_drops_the_loser() {
let before = CANCELLED.load(Ordering::SeqCst);
let slow = SlowJob { name: "test-job" };
let winner = tokio::select! {
v = slow.run() => v,
() = sleep(Duration::from_secs(1)) => "timer",
};
assert_eq!(winner, "timer");
assert_eq!(CANCELLED.load(Ordering::SeqCst), before + 1);
}
}

cargo test --target x86_64-unknown-linux-gnu ตอบ​กลับ​มา​ว่า:

running 3 tests
test tests::interval_ticks_at_0_2000_4000 ... ok
test tests::timeout_elapses_after_exactly_5s ... ok
test tests::select_drops_the_loser ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

(ลำดับ​สาม​บรรทัด test … ไม่​คงที่ — libtest รัน test ขนาน​กัน​แล้ว​รายงาน​ตาม ลำดับ​ที่​จบ ไม่ใช่​ลำดับ​ที่​เขียน​ไว้​ใน file รัน​ใหม่​แล้ว​สลับ​ได้​ทุกรอบ ส่วน​บรรทัด test result: อยู่​ท้าย​สุด​เสมอ)

อ่าน​บรรทัด​สุดท้าย​อีกรอบ: test สาม​ตัว​นี้ assert เวลา​รวม​กัน 9 วินาที (4 วินาที​ของ interval + 5 วินาที​ของ timeout) แต่ harness รายงาน​เวลา​จริง​ว่า 0.00s — และ​ตัวเลข 0, 2000, 4000 กับ Duration::from_secs(5) เป็น assert_eq! แบบ​เท่า​กัน​เป๊ะ ไม่ใช่ assert!(elapsed > 4900) แบบ​ที่​คุณ​เคย​เขียน​เวลา test timing ด้วย​นาฬิกา​จริง test ตัว​ที่​สาม​คือ​หลักฐาน​ว่า select! drop ตัว​แพ้​จริง: ตัว​นับ​ใน Drop ขยับ​ขึ้น​หนึ่ง​พอดี

ถ้า​ลืม test-util จะ​เป็น​ยังไง

start_paused = true ต้องการ ทั้ง feature macros (สำหรับ​ตัว #[tokio::test] เอง) และ test-util (สำหรับ​นาฬิกา​ปลอม) ถ้า​ลบ "test-util" ออก​จาก [dependencies] แล้ว​สั่ง cargo test มัน​ไม่​ได้​เงียบๆ กลาย​เป็น test ที่​ช้า​ลง แต่ compile ไม่​ผ่าน ตั้งแต่​ต้น เพราะ​มาโคร​กาง start_paused = true ออก​เป็นการ​เรียก method บน Builder ที่​ถูก gate ไว้ ได้ error หนึ่ง​อัน​ต่อ test หนึ่ง​ตัว:

error[E0599]: no method named `start_paused` found for struct `tokio::runtime::Builder` in the current scope

(บรรทัด --> ที่​ตาม​มา​จะ​ชี้​ไป​ที่​บรรทัด​กลางๆ ใน​ตัว test ไม่ใช่​ที่ attribute เพราะ span มา​จาก code ที่มาโครกางออก — อย่า​เสีย​เวลา​ไล่​หา​บรรทัด​นั้น ให้​ไป​ดู features ก่อน) นี่​เป็น​ตัวอย่าง​ที่​ดี​ของ feature ที่ ต้อง​เปิด​โดย​เจตนา ไม่ใช่​เพราะ "full" เปิด​ให้​โดยที่​คุณ​ไม่รู้

มา​ถึง​กับดัก​ที่​แพง​ที่สุด​ของ​บท ชื่อ​ของ​มัน​คือ cancellation-safetycancellation-safetyคุณสมบัติ future ที่ drop กลางคัน​แล้ว​ไม่​เสีย​ข้อมูล (recv/tick ปลอดภัย, read_exact ไม่​ปลอดภัย) และ​คำถาม​ที่​มัน​ถาม​คือ: “ถ้า future ตัว​นี้​ถูก drop กลางคัน มี​อะไร​หาย​ไป​ไหม”

future ที่ cancel-safe คือ​ตัว​ที่​ตอบ​ว่า “ไม่มี” — drop แล้ว​เหมือน​ไม่​เคย​เรียก เริ่ม​ใหม่​ได้​โดย​ไม่​เสีย​อะไร เอกสาร​ของ tokio::select! มี​รายการ​นี้​ไว้​ให้​ตรวจ และ​สอง​ตัว​ที่​คุณ​จะ​เจอ​บ่อย​ที่สุด​อยู่​คนละ​ฝั่ง​กัน: Receiver::recv() กับ Interval::tick() cancel-safe ส่วน read_exact กับ write_all ไม่ cancel-safe

ทำไม read_exact ถึง​ไม่​ปลอดภัย — เพราะ​มัน​ต้อง​อ่าน​ให้​ครบ​ตาม​ขนาด buffer ซึ่ง​อาจ​ต้อง​เรียก syscall หลาย​รอบ byte ที่​มัน​ดึง​ออก​จาก socket ไป​แล้ว​ใน​รอบก่อนๆ หาย​จาก socket ถาวร ถ้า​ตัว​มัน​ถูก drop ก่อน​ครบ byte เหล่า​นั้น​ก็​ตก​อยู่​ใน buffer ที่​ไม่มี​ใคร​สนใจ แล้ว​หาย​ไป​พร้อม​กับ future

ลอง​ของ​จริง — ฝั่ง​เขียน​ส่ง "ABCD" ทันที รอ 250ms แล้ว​ส่ง "EFGH" ส่วน​ฝั่ง​อ่าน​วน loop select! ระหว่าง read_exact กับ ticker ทุก 100ms:

/// เขียน "ABCD" ทันที แล้วรอ 250ms ค่อยเขียน "EFGH"
async fn split_writer(mut sock: TcpStream) {
sock.write_all(b"ABCD").await.unwrap();
sock.flush().await.unwrap();
sleep(Duration::from_millis(250)).await;
sock.write_all(b"EFGH").await.unwrap();
sock.flush().await.unwrap();
sleep(Duration::from_millis(400)).await;
}
async fn demo_read_exact_red(addr: std::net::SocketAddr, listener: &TcpListener) {
println!("--- 3a. RED: read_exact ใน loop select! ---");
let client = TcpStream::connect(addr).await.unwrap();
let (mut sock, _) = listener.accept().await.unwrap();
tokio::spawn(split_writer(client));
let mut ticker = interval(Duration::from_millis(100));
ticker.tick().await; // tick แรกยิงทันที กินทิ้งก่อนเข้า loop
let mut buf = [0u8; 8];
let mut ticks = 0;
loop {
tokio::select! {
r = sock.read_exact(&mut buf) => {
println!(" read_exact -> {r:?}");
break;
}
_ = ticker.tick() => {
ticks += 1;
println!(" tick #{ticks} ชนะ -> read_exact ถูก drop ทิ้งกลางคัน");
if ticks == 4 {
break;
}
}
}
}
println!(" buf = {:?}", String::from_utf8_lossy(&buf));
}

code นี้ compile ผ่านสบายๆ ไม่มี warning สัก​ตัว — และ​นี่​คือ​สิ่ง​ที่​มัน​ทำ​จริง:

--- 3a. RED: read_exact ใน loop select! ---
tick #1 ชนะ -> read_exact ถูก drop ทิ้งกลางคัน
tick #2 ชนะ -> read_exact ถูก drop ทิ้งกลางคัน
tick #3 ชนะ -> read_exact ถูก drop ทิ้งกลางคัน
tick #4 ชนะ -> read_exact ถูก drop ทิ้งกลางคัน
buf = "EFGH\0\0\0\0"

ไล่​ที​ละ​รอบ​ว่า​เกิด​อะไร​ขึ้น: รอบ​แรก read_exact ดูด "ABCD" ออก​จาก socket เข้า​มา​ใส่ 4 byte แรก​ของ buffer แล้ว​คืน Pending เพราะ​ยัง​ไม่​ครบ 8 — พอ tick ที่ 100ms ชนะ มัน​ถูก drop "ABCD" หาย​จาก socket ไป​แล้ว​และ​ไม่มี​ใคร​รู้ รอบ​สอง​ยัง​ไม่มี​ข้อมูล​เข้า​มา tick ที่ 200ms ชนะ​เฉยๆ รอบ​สาม "EFGH" มา​ถึงที่ 250ms read_exact ตัว ใหม่ ดูด​มัน​เข้า​มา​ใส่ 4 byte แรก ทับ "ABCD" เดิม แล้ว​รอ​ต่อ จน tick ที่ 300ms ชนะ drop อีกรอบ สรุป​คือ 8 byte ถูก​ดึง​ออก​จาก socket ครบ​ทุก byte แต่​ส่ง​ถึง​มือ​คุณ 0 byte และ buffer ที่​เหลือ​ไว้​คือ "EFGH" ตาม​ด้วย null สี่​ตัว — บรรทัด read_exact -> Ok(8) ที่​เรา​เขียน​รอ​ไว้​ไม่​เคย​ถูก​พิมพ์​เลย

นี่​คือ bug ที่​เลว​ร้าย​ที่สุด​ประเภท​หนึ่ง: ไม่มี panic ไม่มี error ไม่มี warning มี​แต่​ข้อมูล​ที่​หาย​ไป​เงียบๆ และ​เป็นๆ หายๆ ตาม​จังหวะ​เวลา

ทาง​แก้​คือ pin ตัว operation ไว้​แล้ว poll &mut op ซ้ำ แทนที่​จะ​สร้าง future ใหม่​ทุกรอบ — tokio::pin! ตรึง future ลง stack แล้ว​ให้​เรา​หยิบ &mut ของ​มัน​มา​ใส่ branch ได้ ผล​คือ​ทุกรอบ​ของ loop กลับ​ไป poll ตัว​เดิม ที่​จำ​สถานะ​ไว้​แล้ว มัน resume ไม่ใช่ restart:

async fn demo_read_exact_green(addr: std::net::SocketAddr, listener: &TcpListener) {
println!("--- 3b. GREEN: tokio::pin! แล้ว poll &mut op ---");
let client = TcpStream::connect(addr).await.unwrap();
let (mut sock, _) = listener.accept().await.unwrap();
tokio::spawn(split_writer(client));
let mut ticker = interval(Duration::from_millis(100));
ticker.tick().await;
let mut buf = [0u8; 8];
let mut ticks = 0;
{
let op = sock.read_exact(&mut buf);
tokio::pin!(op);
loop {
tokio::select! {
r = &mut op => {
println!(" read_exact -> {r:?}");
break;
}
_ = ticker.tick() => {
ticks += 1;
println!(" tick #{ticks} ชนะ -> op ยัง 'มีชีวิต' รอ resume");
}
}
}
}
println!(" buf = {:?}", String::from_utf8_lossy(&buf));
}
--- 3b. GREEN: tokio::pin! แล้ว poll &mut op ---
tick #1 ชนะ -> op ยัง 'มีชีวิต' รอ resume
tick #2 ชนะ -> op ยัง 'มีชีวิต' รอ resume
read_exact -> Ok(8)
buf = "ABCDEFGH"

ต่าง​กัน​สอง​บรรทัด​ของ code ผล​ต่าง​กัน​คือ​ข้อมูล​ครบ​กับ​ข้อมูล​หาย — และ​นี่​คือ​จุด​ที่ Pin จาก​บท 1 กลับ​มา กัด จริง​เป็น​ครั้ง​แรก จำ​ได้​ไหม​ว่า Future::poll รับ self: Pin<&mut Self> ไม่ใช่ &mut self การ​จะ poll future ตัว​เดิม​ซ้ำ​หลาย​รอบ​จาก​ใน loop จึง​ต้อง​มี Pin ที่​ตรึง​มัน​ไว้​กับ​ที่​ก่อน tokio::pin!(op) คือ​มาโคร​ที่​ทำงาน​นั้น​ให้ (ทำงาน​เหมือน std::pin::pin! แต่​มัน​เงา​บัง op ตัว​เดิม​ด้วย​ชื่อ​เดิม) และ &mut op ที่​ใส่​ใน branch ก็​คือ​การ reborrow แบบ​เดียว​กับ .as_mut() ที่​คุณ​เขียน​ใน block_on ของ​บท 1 เป๊ะๆ

หมายเหตุ​เรื่องสโคป: read_exact(&mut buf) ยืม buf แบบ mutable ไว้​ตลอด​อายุ​ของ op เรา​จึง​ห่อ loop ไว้​ใน block ปีกกา​เพื่อ​ให้​เห็นด้วย​ตา​ว่า op ตาย​ตรง​ไหน — ลอง​แล้ว​นะ ถอด​ปีกกา​ออก code ชุด​นี้​ก็​ยัง compile ผ่าน เพราะ NLL เห็น​ว่า​ไม่มี​ใคร​ใช้ op อีก​หลัง loop จึง​คืน borrow ให้​ทัน​ก่อน​บรรทัด println! ปีกกา​จึง​เป็น​เรื่อง​ความ​ชัดเจน ไม่ใช่​เรื่อง​บังคับ แต่​การ​ยืม​นั้น​มีอยู่จริงๆ: ลอง​แตะ buf ข้าง​ใน loop ตอน​ที่ op ยัง​มี​ชีวิต​ดู​สิ จะ​ได้ error ทันที

error[E0502]: cannot borrow `buf` as immutable because it is also borrowed as mutable
สรุป​ตาราง​ความ​ปลอดภัย​ที่​ต้อง​ท่อง

cancel-safe (ใส่ select! ตรงๆ ได้): mpsc::Receiver::recv() · broadcast::Receiver::recv() · watch::Receiver::changed() · TcpListener::accept() · AsyncReadExt::read() · Interval::tick()

ไม่ cancel-safe (ต้อง tokio::pin! หรือ​ย้าย​ออก​จาก select!): AsyncReadExt::read_exact() · AsyncReadExt::read_to_end() · AsyncWriteExt::write_all() และ​เพื่อนๆ ที่​ต้อง​ทำงาน​หลาย​ขั้น​ให้​ครบ

และ​ตัว​ที่​คน​เผลอ​เติม​เข้า​ฝั่ง​ซ้าย​บ่อย​ที่สุด​คือ sleep(d) — มัน ไม่​ได้​อยู่​ใน​รายการ cancel-safe ของ​เอกสาร เพราะ drop แล้ว​สร้าง​ใหม่ = ตั้ง​นาฬิกา​ใหม่​ตั้งแต่​ศูนย์ ถ้า​เขียน () = sleep(d) => … ไว้​ใน loop select! แล้ว​หวัง​ให้​มัน​เป็น​เส้นตาย​รวม​ของ​ทั้ง loop มัน​จะ​ไม่มี​วัน​ครบ​สัก​ที เส้นตาย​รวม​ต้อง tokio::pin! หรือ​ใช้ sleep_until(deadline) ส่วน Interval::tick() ปลอดภัย​เพราะ​ตาราง​เวลา​อยู่​ที่​ตัว Interval ไม่​ได้​อยู่​ใน​ตัว future ที่​ถูก drop

เวลา​ไม่​แน่ใจ เปิด​เอกสาร​ของ method นั้น​แล้ว​มอง​หา​หัวข้อ Cancel safety — tokio เขียน​กำกับ​ไว้​ให้​ทุก​ตัว​ที่​เกี่ยวข้อง นี่​ไม่ใช่​เรื่อง​ที่​เดา​เอา​ได้​จาก​ชื่อ method

ตาม​กติกา​ของ​คอร์ส​คือ​เปิด feature เท่า​ที่​ใช้​จริง แปล​ว่า​คุณ​จะ​เจอ2 error นี้​แน่ๆ ถ้า​ลอก [dependencies] มา​ไม่​ครบ ตัว​แรก — ลืม time แล้ว​เรียก sleep (path ของ registry ใน​ข้อความ​คือ​ของ​เครื่อง​ที่​รัน บน​เครื่อง​คุณ​จะ​เป็น​อีก path หนึ่ง):

error[E0432]: unresolved import `tokio::time`
--> src/main.rs:1:12
|
1 | use tokio::time::{Duration, sleep};
| ^^^^ could not find `time` in `tokio`
|
note: found an item that was configured out
--> /home/nook/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.53.1/src/lib.rs:581:13
|
581 | pub mod time;
| ^^^^
|
::: /home/nook/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.53.1/src/macros/cfg.rs:583:19
|
583 | #[cfg(feature = "time")]
| ---------------- the item is gated behind the `time` feature

สังเกต​ว่า rustc ใจดี​มาก มัน​ไม่​ได้​บอก​แค่​ว่า “หาไม่​เจอ” แต่​ชี้​ไป​ที่​บรรทัด #[cfg(feature = "time")] ใน​ซอร์ส​ของ tokio เอง​แล้ว​บอกว่า the item is gated behind the "time" feature — เพิ่ม "time" เข้าไป​ใน features แล้ว​เขียว​ทันที

ตัว​ที่​สอง — ลืม macros แล้ว​เรียก tokio::select!:

error[E0433]: cannot find `select` in `tokio`
--> src/main.rs:6:16
|
6 | tokio::select! {
| ^^^^^^ could not find `select` in `tokio`

อัน​นี้ error สั้น​กว่า​และ​ใบ้​น้อย​กว่า เพราะ​เป็น​มาโคร​ไม่ใช่ module จำ​ไว้​ว่า macros คือ feature ที่​แจก​ทั้ง #[tokio::main], #[tokio::test] และ select! ถ้า​เห็น cannot find select in tokio ให้​ไป​ดู​บรรทัด features ก่อน​อย่าง​อื่น​เสมอ (🔁 บท 2 เล่า​ฝั่ง​กลับ​ของ​กับดัก​นี้​ไป​แล้ว: macros ไม่​ได้​พา scheduler มา​ให้​เลย)

การ​ปิด server ให้​เรียบร้อย​แยก​เป็น​สาม​คำถาม​ที่​ไม่​เกี่ยว​กัน​เลย เอกสาร shutdown ของ Tokio วาง​ไว้​แบบ​นี้: (1) รู้​ว่า​เมื่อไหร่​ต้อง​ปิด → (2) บอก​ทุก task ให้​หยุด → (3) รอ​จน​ทุก task จบ​จริง

ส่วน​ที่ 1 คือ tokio::signal::ctrl_c() ใต้ feature signal — เป็น async fn ที่​คืน std::io::Result<()> และ​บน Unix มัน​ติดตั้ง signal handler ระดับ process ซึ่ง​แปล​ว่า​มัน​ทับ default behaviour ของ Ctrl-C ทั้ง​โปรแกรม (ถ้า​คุณ await มัน​แล้ว​ไม่​ทำ​อะไร​ต่อ Ctrl-C จะ​กลาย​เป็น​ปุ่ม​ที่​กด​แล้ว​ไม่มี​อะไร​เกิด​ขึ้น)

และ​ตรง​นี้​ต้อง​พูดตรงๆ ตาม​กติกา​ของ​คอร์ส: การ​กด Ctrl-C จริง​เป็นการ​โต้ตอบ​กับ​มนุษย์ ทดสอบ​อัตโนมัติ​ไม่​ได้ ฉะนั้น​สิ่ง​ที่​เรา ยืนยัน​ได้ คือ ctrl_c() compile และ​รัน​อยู่​ใน select! จริง ส่วนตัว trigger เรา​จำลอง​ด้วย mpsc — ไม่ใช่​การ​หลบ​เลี่ยง แต่​เป็นการ​แยก​ให้​ชัด​ว่า​อะไร​พิสูจน์​แล้ว อะไร​พิสูจน์​ไม่​ได้​ใน​กล่อง​นี้:

/// ตรวจจับสัญญาณ: ctrl_c ของจริง หรือ trigger จำลองผ่าน mpsc (CI กด key ไม่ได้)
async fn wait_for_signal(sim: &mut mpsc::Receiver<()>) {
tokio::select! {
r = tokio::signal::ctrl_c() => {
if let Err(e) = r {
eprintln!("ctrl_c handler ติดตั้งไม่สำเร็จ: {e}");
}
println!(" ได้รับ Ctrl-C จริง");
}
_ = sim.recv() => println!(" ได้รับ trigger จำลอง (แทนการกด Ctrl-C)"),
}
}

ส่วน​ที่ 2 คือ​การ แพร่ สัญญาณ ใช้ broadcast channel จาก feature sync เพราะ subscriber ทุก​ตัว​ได้​รับ​สำเนา​ครบ ส่วน​ที่ 3 คือ​กล​ที่​สวย​ที่สุด​ของ​บท: นับ task ที่​เหลือ​ด้วย mpsc sender ที่​ไม่​เคย​ส่ง​อะไร​เลย ยื่น drain_tx.clone() ให้​ทุก task ถือ​ไว้ แล้ว main ทิ้ง​ของ​ตัวเอง​ด้วย drop(drain_tx) — เมื่อ drain_rx.recv() คืน None แปล​ว่า sender ตัว​สุดท้าย ถูก drop ไป​แล้ว ซึ่ง​แปล​ว่า​ทุก task จบ​ครบ ไม่​ต้อง​นับ​เลข​เอง ไม่​ต้อง​เก็บ JoinHandle เป็นกอง

async fn demo_graceful_shutdown() {
println!("--- 4. graceful shutdown 3 ส่วน ---");
let (shutdown_tx, _) = broadcast::channel::<()>(1);
let (drain_tx, mut drain_rx) = mpsc::channel::<()>(1);
for id in 0..3u32 {
let mut shutdown_rx = shutdown_tx.subscribe();
let drain = drain_tx.clone();
tokio::spawn(async move {
let mut ticker = interval(Duration::from_millis(40));
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = shutdown_rx.recv() => break,
_ = ticker.tick() => {}
}
}
println!(" worker {id} ปิดตัวเรียบร้อย");
drop(drain);
});
}
let (sim_tx, mut sim_rx) = mpsc::channel::<()>(1);
tokio::spawn(async move {
sleep(Duration::from_millis(60)).await;
let _ = sim_tx.send(()).await;
});
wait_for_signal(&mut sim_rx).await;
let _ = shutdown_tx.send(());
drop(drain_tx);
let _ = drain_rx.recv().await;
println!(" drain_rx.recv() คืน None -> ทุก worker จบครบแล้ว ปิด process ได้");
}
--- 4. graceful shutdown 3 ส่วน ---
ได้รับ trigger จำลอง (แทนการกด Ctrl-C)
worker 0 ปิดตัวเรียบร้อย
worker 1 ปิดตัวเรียบร้อย
worker 2 ปิดตัวเรียบร้อย
drain_rx.recv() คืน None -> ทุก worker จบครบแล้ว ปิด process ได้

(ลำดับ 0 · 1 · 2 เป็น​ของ run นั้นๆ — อีก run หนึ่ง​บน​เครื่อง​เดียวกัน​ได้ 0 · 2 · 1 เพราะ worker ทั้ง​สาม​อยู่​บน multi-thread scheduler จึง​ปิด​ตัว​พร้อม​กัน​และ​ลำดับ​บรรทัด​สลับ​ได้​ทุก​ครั้ง​ที่​รัน ส่วน​บรรทัด​สุดท้าย​อยู่​ท้าย​สุด เสมอ เพราะ​นั่น​คือ​ทั้งหมด​ของ​กล​นี้)

drop(drain_tx) ใน​บรรทัด​รอง​สุดท้าย​คือ​บรรทัด​ที่​คน​ลืม​บ่อย​ที่สุด ถ้า​ไม่​ทิ้ง sender ของ main จะ​ยัง​มี sender ค้าง​อยู่​หนึ่ง​ตัว​เสมอ recv() จึง​ไม่มี​วัน​คืน None และ​โปรแกรม​จะ​ค้าง​รอ​ตัวเอง​ไป​ชั่ว​นิรันดร์

ถ้า​อยาก​ได้ token ทรง .NET

tokio-util 0.7.19 มี CancellationToken ที่​หน้าตา​ใกล้ CancellationTokenSource มาก: cancel() ยกเลิก​ทุก clone และ​ทุก child พร้อม​กัน, cancelled() เป็น future ที่ cancel-safe และ await ซ้ำ​ได้, child_token() ทำงาน​เหมือน CreateLinkedTokenSource แต่​มัน​เป็น crate เพิ่ม​อีก​ตัว ที่​คอร์ส​นี้​ไม่​ได้​ใส่​ใน [dependencies] — เรา​จึง​กล่าว​ถึง​เฉยๆ ไม่มี snippet ให้​เพราะ​ไม่​ได้ compile มัน​จริง ทาง​เลือก​ฝั่ง pure-tokio ที่​ทำงาน​เหมือน​กัน​คือ broadcast หรือ watch จาก feature sync คือ​สิ่ง​ที่​เรา​ใช้​ข้าง​บน

code ทั้ง​บท​คือ function demo ห้า​ตัว​ข้าง​บน (demo_cancel_is_drop, demo_timeout, demo_read_exact_red, demo_read_exact_green, demo_graceful_shutdown) บวก split_writer, wait_for_signal, SlowJob และ​หัว file use ชุด​แรก​สุด ต่อ​กันลง​ใน src/main.rs file เดียว โดย​มี main ตัว​นี้​เป็น​ตัว​ขับ:

#[tokio::main]
async fn main() {
demo_cancel_is_drop().await;
demo_timeout().await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
demo_read_exact_red(addr, &listener).await;
demo_read_exact_green(addr, &listener).await;
demo_graceful_shutdown().await;
}

#[tokio::main] ที่​ไม่​ใส่ flavor คือ multi_thread ซึ่ง​ใช้ได้​เพราะ [dependencies] ของ​บท​นี้​เปิด "rt-multi-thread" ไว้​แล้ว ส่วน test สาม​ตัว​ใส่​ไว้​ใน mod tests ท้าย file เดียวกัน รัน​ด้วย recipe เดิม:

Terminal window
export PATH="$HOME/.cargo/bin:$PATH"
cargo run --target x86_64-unknown-linux-gnu
cargo test --target x86_64-unknown-linux-gnu
cargo clippy --target x86_64-unknown-linux-gnu -- -D warnings
C# / .NETRust + Tokioจุด​ที่​ต่าง​จริง
CancellationToken + cts.Cancel()drop ตัว future ทิ้ง.NET เป็น cooperative — code ต้อง​เช็ค token เอง ไม่​เช็ค​ก็​วิ่ง​ต่อ; Rust เป็น structural — ไม่มี​ใคร​ถือ future ก็​ไม่มี​ใคร poll จึง​ไม่มี progress แน่นอน
Task.WhenAny(a, b)tokio::select! { … }WhenAny ปล่อยตัว​แพ้​วิ่ง​ต่อ (ต้อง​ส่ง token ไป​หยุด​เอง); select! drop ตัว​แพ้​ให้​อัตโนมัติ และ​ทุก​อย่าง​อยู่​บน task เดียว​ไม่มี​การ spawn
task.WaitAsync(timeout) / CancelAftertimeout(dur, fut).NET โยน TimeoutException; Rust คืน Result<_, Elapsed> ให้ match และ cancel inner จริง​ด้วย​การ drop
Task.Delay(TimeSpan)sleep(Duration)ตรง​กัน​เกือบ 100% (คู่ deadline คือ sleep_until)
PeriodicTimer + WaitForNextTickAsync()interval + tick().awaittick แรก​ของ interval ยิง​ทันที ไม่​รอ​รอบ​แรก และ​มี MissedTickBehavior ให้​เลือก ซึ่ง PeriodicTimer ไม่มี​ของ​เทียบ
finally { await Cleanup(); }❌ ไม่มี​ของ​เทียบRust มี​แต่ Drop ที่​เป็น sync — ไม่มี async destructor cleanup ที่​ต้อง await ต้อง​ทำ​ก่อน​โดน cancel หรือ​ฝาก​ไว้​กับ task อื่น
FakeTimeProvider#[tokio::test(start_paused = true)]ฝัง​มา​กับ runtime เอง เปิด​ด้วย feature test-util ไม่​ต้อง inject abstraction เข้าไป​ใน code โปร​ดัก​ชัน
stoppingToken ของ BackgroundServicebroadcast::Receiver ใน select!แนวคิด​เดียวกัน แต่​ฝั่ง Rust คุณ​ต่อ​สาย​เอง
Host.StopAsync รอ​ทุก service จบmpsc sender หมด ⇒ recv() == Noneไม่​ต้อง​นับ task เอง ใช้​กฎ ownership ของ channel นับ​ให้
สี่​ข้อ​ที่​ต้อง​ไม่ overclaim

A — “cancel แล้ว​หยุด​แน่นอน” จริง แต่​เฉพาะ​ที่ .await ไม่ใช่​หยุด​กลาง​คำ​สั่ง future ที่​ไม่มี suspension point เลย (วน​คำนวณ​ยาวๆ ไม่มี .await) จะ​ยกเลิก​ไม่​ได้​เลย​แม้แต่​นิด เพราะ​ไม่มี​จังหวะ​ให้​ใคร​เข้า​มา​แตะ​มัน นี่​คือ​คนละ​เรื่อง​กับ Thread.Abort ที่​ตาย​ไป​แล้ว​ใน .NET และ​เป็น​เหตุผล​ที่​บท 7 จะ​กลับ​มา​พูด​เรื่อง blocking บน runtime อีกรอบ

B — cleanup ที่​ต้อง await ทำ​ไม่​ได้ ไม่มี async destructor ใน Rust ณ ปี 2026 Drop เป็น sync เท่านั้น ใคร​บอกว่า “ใส่ Drop แล้ว​ปิด session ได้” คน​นั้น​กำลัง​พูด​ถึง cleanup ที่​ไม่​ต้อง await เท่านั้น เรื่อง​นี้​ไม่มี​ทาง​ออกสวยๆ มี​แต่​การ​ออกแบบ​ให้ cleanup ไม่​ต้อง await หรือ​ฝาก​งาน​ให้ task ที่​ไม่​โดน cancel

C — cancellation safety เดา​จาก​ชื่อ method ไม่​ได้ recv() ปลอดภัย read_exact() ไม่​ปลอดภัย ทั้ง​คู่ชื่อสั้นพอๆ กัน​และ​ทั้ง​คู่ compile ผ่าน​เหมือน​กัน ไม่มี lint ไม่มี type ไหน​กัน​คุณ​เลย ตาข่าย​เดียว​คือ ไป​อ่าน​หัวข้อ Cancel safety ใน​เอกสาร​ของ method นั้น — เรา​ถึง​ต้อง​โชว์ RED ให้​เห็น buf = "EFGH\0\0\0\0" กับ​ตา

D — สิ่ง​ที่​บท​นี้​พิสูจน์​ไม่​ได้ และ​ไม่​แกล้ง​ว่า​พิสูจน์​ได้ การ​กด Ctrl-C จริง​เป็น interactive เรา​จึง​ยืนยัน​ได้​แค่​ว่า ctrl_c() compile และ​รัน​อยู่​ใน select! จริง​ใต้ feature signal ส่วน trigger จำลอง​ด้วย mpsc · ตัวเลข​เวลา​ทั้งหมด​ใน​บท​นี้​มา​จาก นาฬิกา​ปลอม​ของ start_paused = true ไม่ใช่​การ​วัด wall-clock บน​เครื่อง​ใด​เครื่อง​หนึ่ง (0 / 2000 / 4000 ms และ 5 วินาที​เป๊ะ) เรา​ไม่​อ้าง​ตัวเลข throughput หรือ latency ใดๆ ทั้งสิ้น​ใน​บท​นี้ · และ​ลำดับ​บรรทัด​ของ worker ทั้ง​สาม​ใน demo สุดท้าย​สลับ​ได้​ทุก​ครั้ง​ที่​รัน เรา​จึง​บอกไว้ตรงๆ แทนที่​จะ​แกล้ง​ว่า​มัน​คงที่

การ​ยกเลิก​ใน async Rust คือ การ drop future ไม่ใช่​การ​ส่ง​สัญญาณ​ให้​ใคร​ไป​เช็ค ต่าง​จาก CancellationToken ที่​เป็น cooperative — ราคา​ที่​จ่าย​คือ cleanup ได้​เฉพาะ Drop แบบ sync เท่านั้น ไม่มี async destructor; select! แข่ง future หลาย​ตัว​บน task เดียว ตัว​ชนะ​อยู่ ตัว​แพ้​ถูก drop ก่อน handler จะ​รัน ซึ่ง​เรา​พิสูจน์​ด้วย​ตัว​นับ​ใน Drop ที่​ขยับ​ขึ้น​หนึ่ง​พอดี default poll เป็น​แบบ​สุ่ม​ยกเว้น​ใส่ biased; และ​มัน panic ว่า all branches are disabled and there is no else branch ถ้า​ปิด​ทุก branch โดย​ไม่มี else; tokio::time ให้ sleep, timeout ที่​คืน Result<_, Elapsed> แทน​การ​โยน exception และ interval ที่ tick แรก​ยิง​ทันที panic ถ้า period เป็น​ศูนย์ และ default MissedTickBehavior คือ Burst ที่​ยิง​รัว​ไล่​ให้​ทัน; #[tokio::test(start_paused = true)] ทำให้ assert เวลา​รวม 9 วินาที​จบ​โดย harness รายงาน 0.00s และ​ได้​ตัวเลข​เดิม​ทุก​ครั้ง; และ​กับดัก​ที่​แพง​ที่สุด​คือ cancellation safety — read_exact ใน loop select! กิน​ข้อมูล​หาย​เงียบ​จน​ได้ buf = "EFGH\0\0\0\0" แก้​ด้วย tokio::pin! แล้ว poll &mut op ให้​มัน resume ไม่ใช่ restart ปิด​ท้าย​ด้วย​โครง graceful shutdown สาม​ส่วน​ที่​ใช้ ctrl_c + broadcast + mpsc-drain

บท 6 เรา​จะ​เปิด​กล่อง​ที่​หลาย task ต้อง​แตะ​ข้อมูล​ก้อน​เดียวกัน: select! ใน​บท​นี้​รอดตัว​เพราะ​ทุก branch อยู่​บน task เดียว​จึง​ไม่​ต้อง​เป็น Send แต่​พอ​เริ่ม spawn หลาย​ตัว​มา share state เรื่อง​จะ​เปลี่ยน​ทันที บท​หน้า​ตอบ​คำถาม​ที่​สวน​สัญชาตญาณ​ที่สุด​ของ Tokio: ทำไม std Mutex ถึง​เป็น​ค่า​เริ่มต้น ไม่ใช่ tokio::sync::Mutex และ​ทำไม​การ​ถือ guard ข้าม .await ถึง compile ไม่​ผ่าน


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

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

  • tokio::select! — docs.rs/tokio/1.53.1 (เข้าถึง 2026-07-27) — มาโครอยู่​ใต้ feature macros, รัน​ทุก branch พร้อม​กัน​บน task ปัจจุบัน, คืน​ค่า​เมื่อ branch แรก​จบ​และ cancel (drop) branch ที่​เหลือ, default poll สุ่ม​แบบ fair ส่วน biased; ทำให้ poll บน​ลง​ล่าง​ตาม​ลำดับ​ที่​เขียน, panic ถ้า​ทุก branch disabled และ​ไม่มี else และ​หน้า​นี้​คือ​ที่มา​ของ​รายการ cancellation safety ที่​บอกว่า recv()/accept()/read() ปลอดภัย​แต่ read_exact/read_to_end/write_all ไม่ (ส่วน Interval::tick() ไม่​ได้​อยู่​ใน​รายการ​หน้า​นี้ — cancel safety ของ​มัน​เขียน​ไว้​ที่​หน้า method ตัวเอง)
  • Tokio Tutorial — Select (เข้าถึง 2026-07-27) — เมื่อ branch หนึ่ง​จบ​และ match pattern สำเร็จ async expression ที่​เหลือ​ทั้งหมด​ถูก drop ก่อน​รัน handler และ pattern tokio::pin! + poll &mut op เพื่อ​ให้ operation resume แทน restart
  • tokio::time — docs.rs/tokio/1.53.1 (เข้าถึง 2026-07-27) — ทั้ง module อยู่​ใต้ feature time; Duration เป็น pub use std::time::Duration; ตัว​จริง แต่ tokio::time::Instant เป็น​คนละ​ชนิด​กับ std::time::Instant
  • tokio::time::timeout — docs.rs/tokio/1.53.1 (เข้าถึง 2026-07-27) — timeout<F>(duration, future) -> Timeout<F::IntoFuture> where F: IntoFuture; await แล้ว​ได้ Result<F::Output, Elapsed> และ into_inner() กู้ future คืน​ได้​ก่อน await
  • tokio::time::interval — docs.rs/tokio/1.53.1 (เข้าถึง 2026-07-27) — tick แรก complete ทันที และ panic ถ้า period เป็น​ศูนย์
  • tokio::time::Interval — docs.rs/tokio/1.53.1 (เข้าถึง 2026-07-27) — pub async fn tick(&mut self) -> Instant พร้อม​หัวข้อ Cancel safety ที่​ระบุ​ว่า “This method is cancel safe. If tick is used as a branch in tokio::select! and another branch completes first, then no tick has been consumed.” (คำ​อธิบาย cancel safety อยู่​ที่​หน้า method ของ struct นี้ ไม่​ได้​อยู่​ที่​หน้า fn.interval) และ​เป็น​ที่​อยู่​ของ set_missed_tick_behavior
  • MissedTickBehavior — docs.rs/tokio/1.53.1 (เข้าถึง 2026-07-27) — สาม​ค่า Burst (default), Delay, Skip พร้อม​คำ​อธิบาย​ว่า​ต่าง​กัน​อย่างไร​เมื่อ​พลาด tick
  • tokio::signal::ctrl_c — docs.rs/tokio/1.53.1 (เข้าถึง 2026-07-27) — pub async fn ctrl_c() -> std::io::Result<()> ใต้ feature signal, ใช้ได้​ทั้ง Unix และ Windows และ​บน Unix ติดตั้ง handler ระดับ process
  • Tokio Topics — Graceful Shutdown (เข้าถึง 2026-07-27) — โครง​สาม​ส่วน: รู้​ว่า​เมื่อไหร่​ต้อง​ปิด, บอก​ทุก task, แล้ว​รอ​จน​ทุก task จบ พร้อม​กล mpsc-drain ที่ recv() คืน None เมื่อ sender ตัว​สุดท้าย​ถูก drop
  • tokio_util::sync::CancellationToken — docs.rs/tokio-util/0.7.19 (เข้าถึง 2026-07-27) — crate เสริม ที่​คอร์ส​นี้​ไม่​ได้​ใส่​ใน dependencies (จึง​กล่าว​ถึง​อย่าง​เดียว ไม่มี snippet): cancel() ยกเลิก​ทุก clone และ child, cancelled() เป็น future ที่ cancel-safe และ await ซ้ำ​ได้

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

ข้อ 1 / 3

เพื่อนที่มาจาก .NET ถามว่า CancellationToken ของ Rust อยู่ไหน คำตอบที่ถูกต้องที่สุดคือข้อใด?