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 จะยกไปใช้ทั้งดุ้น
คอร์สนี้ ต่อยอด 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
ทุก 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 ข้างล่างคือข้อความจริงที่พิมพ์ออกมา ไม่ใช่ของที่เขียนขึ้นเอง
cancel คือการ drop — และ Drop คือ cleanup ก้อนเดียวที่คุณได้
หัวข้อที่มีชื่อว่า “cancel คือการ drop — และ Drop คือ cleanup ก้อนเดียวที่คุณได้”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 = 1sleep(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 แล้วปล่อยให้มันถูกทำลาย ผลลัพธ์ที่ตามมามีสามข้อที่ต้องจำ
- มันหยุดที่
.awaitล่าสุดเสมอ ไม่ใช่ “หยุดกลางบรรทัด” — future เดินหน้าได้เฉพาะตอนถูก poll ฉะนั้นจุดที่มันค้างอยู่คือ suspension point ที่ compiler วางไว้ คือ.await - ไม่มีการฆ่าแบบบังคับ ไม่มี
Thread.Abortไม่มีTaskที่ถูกยิงทิ้งกลาง CPU-bound loop — ถ้า future ตัวหนึ่งไม่เคยคืนการควบคุมกลับมา (เช่นวนคำนวณยาวๆ โดยไม่มี.await) มันจะ ยกเลิกไม่ได้ เพราะไม่มีจังหวะให้ใครมาแตะมัน 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 เท่านั้น
select! — แข่ง future บน task เดียว ตัวแพ้ถูก drop
หัวข้อที่มีชื่อว่า “select! — แข่ง future บน task เดียว ตัวแพ้ถูก drop”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 1branch 1branch 1fair -> branch 2fair -> branch 1fair -> branch 1fair -> branch 2fair -> branch 2fair -> 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 branchnote: 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 อื่นทั้งหมดด้วย
tokio::time: sleep, timeout ที่คืน Result, และ interval ที่ tick แรกยิงทันที
หัวข้อที่มีชื่อว่า “tokio::time: sleep, timeout ที่คืน Result, และ interval ที่ tick แรกยิงทันที”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 = Burstloop { 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
นาฬิกาปลอม: test timing ที่ไม่ flaky และไม่กินเวลาจริง
หัวข้อที่มีชื่อว่า “นาฬิกาปลอม: test timing ที่ไม่ flaky และไม่กินเวลาจริง”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 teststest tests::interval_ticks_at_0_2000_4000 ... oktest tests::timeout_elapses_after_exactly_5s ... oktest 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 ขยับขึ้นหนึ่งพอดี
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" เปิดให้โดยที่คุณไม่รู้
❌ version ดิบ: read_exact ใน loop select! แล้วข้อมูลหายเงียบ
หัวข้อที่มีชื่อว่า “❌ version ดิบ: read_exact ใน loop select! แล้วข้อมูลหายเงียบ”มาถึงกับดักที่แพงที่สุดของบท ชื่อของมันคือ 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 mutablecancel-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
RED 2 feature-gate ที่พลาดกันบ่อยที่สุดในบทนี้
หัวข้อที่มีชื่อว่า “RED 2 feature-gate ที่พลาดกันบ่อยที่สุดในบทนี้”ตามกติกาของคอร์สคือเปิด 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 มาให้เลย)
graceful shutdown สามส่วน — โครงที่บท 8 จะยกไปทั้งดุ้น
หัวข้อที่มีชื่อว่า “graceful shutdown สามส่วน — โครงที่บท 8 จะยกไปทั้งดุ้น”การปิด 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 และโปรแกรมจะค้างรอตัวเองไปชั่วนิรันดร์
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 คือสิ่งที่เราใช้ข้างบน
ประกอบ file แล้วรันเอง
หัวข้อที่มีชื่อว่า “ประกอบ file แล้วรันเอง”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 เดิม:
export PATH="$HOME/.cargo/bin:$PATH"cargo run --target x86_64-unknown-linux-gnucargo test --target x86_64-unknown-linux-gnucargo clippy --target x86_64-unknown-linux-gnu -- -D warningsแผนที่คำศัพท์จาก C#/.NET
หัวข้อที่มีชื่อว่า “แผนที่คำศัพท์จาก C#/.NET”| C# / .NET | Rust + 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) / CancelAfter | timeout(dur, fut) | .NET โยน TimeoutException; Rust คืน Result<_, Elapsed> ให้ match และ cancel inner จริงด้วยการ drop |
Task.Delay(TimeSpan) | sleep(Duration) | ตรงกันเกือบ 100% (คู่ deadline คือ sleep_until) |
PeriodicTimer + WaitForNextTickAsync() | interval + tick().await | tick แรกของ 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 ของ BackgroundService | broadcast::Receiver ใน select! | แนวคิดเดียวกัน แต่ฝั่ง Rust คุณต่อสายเอง |
Host.StopAsync รอทุก service จบ | mpsc sender หมด ⇒ recv() == None | ไม่ต้องนับ task เอง ใช้กฎ ownership ของ channel นับให้ |
honesty spine ของบทนี้
หัวข้อที่มีชื่อว่า “honesty spine ของบทนี้”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) — มาโครอยู่ใต้ featuremacros, รันทุก 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 อยู่ใต้ featuretime;Durationเป็นpub use std::time::Duration;ตัวจริง แต่tokio::time::Instantเป็นคนละชนิดกับstd::time::Instanttokio::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 คืนได้ก่อน awaittokio::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. Iftickis used as a branch intokio::select!and another branch completes first, then no tick has been consumed.” (คำอธิบาย cancel safety อยู่ที่หน้า method ของ struct นี้ ไม่ได้อยู่ที่หน้าfn.interval) และเป็นที่อยู่ของset_missed_tick_behaviorMissedTickBehavior— docs.rs/tokio/1.53.1 (เข้าถึง 2026-07-27) — สามค่าBurst(default),Delay,Skipพร้อมคำอธิบายว่าต่างกันอย่างไรเมื่อพลาด ticktokio::signal::ctrl_c— docs.rs/tokio/1.53.1 (เข้าถึง 2026-07-27) —pub async fn ctrl_c() -> std::io::Result<()>ใต้ featuresignal, ใช้ได้ทั้ง 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 อยู่ไหน คำตอบที่ถูกต้องที่สุดคือข้อใด?