ทำไม Future ถึงขี้เกียจ — และ executor 15 บรรทัดที่คุณเขียนเอง
คุณผ่าน #21 (rust-from-scratch), #22 (rust-kvstore) และ #23 (rust-distributed-systems) มาแล้ว — สามคอร์สนั้น จงใจ ไม่แตะ async เลยแม้แต่บรรทัดเดียว kaen-kvstore ที่คุณสร้างรับคอนเนกชันด้วย ThreadPool ขนาดคงที่ 4 worker ที่เขียนเอง — หนึ่งคอนเนกชัน ยึด worker ไว้ตลอดอายุสาย มันชัดเจน debug ง่าย และ เพดานมันต่ำ: คอนเนกชันที่ 5 ต้องรอจนกว่าจะมีคนวางสายก่อน (#22 บท 5 ปฏิเสธการ spawn thread ต่อหนึ่งคอนเนกชันดื้อๆ ไปแล้วตั้งแต่ต้น ปัญหาจึงไม่ใช่ “thread เยอะเกิน”) ปัญหาจริงคือ worker ถูก block ทิ้งไว้เฉยๆ ระหว่างรอ I/O — นั่งรอ byte ที่ยังไม่มาถึงโดยไม่ได้ทำอะไรเลย แต่ก็ไม่มีใครเอาไปใช้ต่อได้ และนั่นคือปัญหาที่ async แก้ตรงๆ คอร์สนี้คือคอร์สที่เปิดประตูนั้น (ถ้าคุณมาจาก #22 คุณจะเคยเห็นบรรทัดที่บอกว่า async รออยู่ที่ #23 — แผนเปลี่ยนระหว่างทาง #23 เลี้ยวไปทาง distributed แทน ของจริงมาอยู่ที่คอร์สนี้)
แต่ถ้าคุณเขียน async/await ใน C# ทุกวัน ประตูบานนี้จะหลอกคุณตั้งแต่ก้าวแรก เพราะคำสองคำนั้นหน้าตาเหมือนกันเป๊ะในสองภาษา แต่ สิ่งที่มันหมายถึง ต่างกันคนละเรื่อง และความต่างข้อแรกคือสิ่งที่บทนี้ทั้งบทพูดถึง: ใน Rust เรียก async fn แล้วไม่มีอะไรรันเลย ไม่ใช่ “รันช้าลง” ไม่ใช่ “รันบน thread อื่น” แต่คือ ไม่มี code ในนั้นทำงานสักบรรทัด จนกว่าจะมีใครสักคนไปหมุนมัน และในบทนี้ “ใครสักคน” นั้นคือคุณเอง — เราเขียน executor ทั้งตัวด้วย std ล้วนราว 15 บรรทัด
คอร์สนี้ ต่อยอด repo kaen-kvstore จาก #22 (code ตัวอย่างกำลังจัดทำ) — ตลอด 8 บทเราจะยก store ตัวเดิมจาก ThreadPool ขนาดคงที่ 4 worker ของ #22 (หนึ่งคอนเนกชันยึด worker ไว้ทั้งเส้น) ขึ้นไปอยู่บน Tokio runtime จนถึง capstone ที่มี graceful shutdown ครบวงจร บทนี้เป็นบทเดียวในคอร์สที่ยังเป็น pure std — เราจะรื้อดูว่า “async” ข้างในเป็นอะไรกันแน่ ก่อนจะยอมให้ crate ไหนมาทำงานนี้แทน สิ่งที่วางในบทนี้ (Future · poll · Poll · Waker · Pin · executor) คือคำศัพท์ที่บท 2–8 ใช้ซ้ำทุกบท และตั้ง precedent ของ honesty spine กับ 🔗 callout อ้างอิงต้นทางให้ทุกบทเดินตาม
ทุก snippet pin ที่ rustc 1.97.1 (8bab26f4f 2026-07-14) และ edition = “2024” — เช็กเองด้วย rustc --version ได้เสมอ [dependencies] ของบทนี้ว่างเปล่า code หลักทั้ง file เป็น std ล้วน และ compile ได้โดยไม่ต้องมี Cargo ด้วยซ้ำ:
rustc --edition 2024 async_foundations.rs -o out && ./outมีเพียง snippet สุดท้าย ของบท (สะพานไป Tokio) ที่แตะ crate:
[dependencies]tokio = { version = "1.53.1", features = ["rt", "macros"] }ห้ามใช้ features = ["full"] เด็ดขาดทั้งคอร์ส — เราเปิด feature เท่าที่บทนั้นใช้จริงเสมอ ด้วย scope แค่นี้ cargo resolve มาแค่ 7 crate (tokio, tokio-macros, pin-project-lite + proc-macro2/quote/syn/unicode-ident ที่เป็น build dep ของ macro) — ตัวเลข 7 นี้ นับเฉพาะ dependency ที่ compile จริงบน Linux ไม่รวม crate ของเราเอง ถ้าไปนับบรรทัดใน Cargo.lock จะได้ 8 รายการเพราะมันนับ crate ของเราด้วย (บท 2 ใช้ฐาน Cargo.lock จึงเขียนว่า 8 — คนละฐานนับ ไม่ใช่คนละข้อเท็จจริง) ยังไม่มี mio/socket2/libc เพราะเรายังไม่เปิด "net" ทุกอย่างในบทนี้ compile แบบ zero-warnings ผ่าน cargo clippy -- -D warnings และ รันจริง (ยกเว้น snippet ที่ติดป้าย ❌ ซึ่งตั้งใจให้พัง — ของพวกนั้นเราโชว์ข้อความ error จริงของมันแทน) — ทุกบรรทัด output ที่เห็นข้างล่างคือข้อความจริงที่พิมพ์ออกมา ไม่ใช่ของที่เขียนขึ้นเอง
ข้อเท็จจริงที่กัดที่สุด: สร้าง future แล้วไม่มีอะไรเกิดขึ้น
หัวข้อที่มีชื่อว่า “ข้อเท็จจริงที่กัดที่สุด: สร้าง future แล้วไม่มีอะไรเกิดขึ้น”เอาให้เห็นด้วยตาก่อนอธิบาย โปรแกรมนี้สร้าง async block ที่ข้างในมี println! แล้วพิมพ์ข้อความ หลัง บรรทัดที่สร้างมัน:
fn main() { println!("[1] before creating the future"); let lazy = async { println!("[3] body IS running now (only because block_on polled it)"); 42 }; println!("[2] future created, body has NOT run yet"); let answer = block_on(lazy); println!("[4] block_on -> {answer}");}รันจริงได้ผลตามนี้:
[1] before creating the future[2] future created, body has NOT run yet[3] body IS running now (only because block_on polled it)[4] block_on -> 42ลำดับ [2] ก่อน [3] คือทั้งหมดของหัวข้อนี้ ถ้าคุณเอา code ทรงเดียวกันไปเขียนใน C# — var lazy = Work(); Console.WriteLine("[2] ..."); — บรรทัด [3] จะพิมพ์ ก่อน [2] เสมอ เพราะ Task ของ .NET เป็น hot/eager: ทันทีที่คุณเรียก async method ตัว body เริ่มเดินทันทีจนถึง await แรก (เดินอยู่บน thread ที่เรียกนั่นคือ — ยังไม่มี scheduler เข้ามาเกี่ยวจนกว่าจะไปติด await ที่ยังไม่เสร็จ ส่วน Task.Run ต่างหากที่โยนงานเข้า ThreadPool ตั้งแต่วินาทีแรก)
ใน Rust async { … } ไม่ได้เริ่มอะไรทั้งนั้น มันเป็นแค่ นิพจน์ที่สร้างค่า ค่าหนึ่งขึ้นมา — compiler แปลง body ทั้งก้อนเป็น state machine แล้วยัดใส่ struct นิรนามที่ implement เทรต FutureFuturestate แมชชีนแบบ lazy ที่ไม่ทำงานจนกว่าจะถูก poll ค่าที่ได้ นิ่งสนิท ไม่มี thread ไหนถือมันอยู่ ไม่มี scheduler ไหนรู้จักมัน ถ้าคุณสร้างแล้วทิ้งลงพื้น (เขียน async { … }; ลอยๆ เป็น statement) code ข้างในนั้น ตายเงียบ ไม่เคยรันเลย — ตาข่ายนิรภัยชิ้นเดียวที่คุณมีคือ lint #[must_use] ที่ขึ้นว่า warning: unused implementer of Future that must be used พร้อม note futures do nothing unless you .await or poll them และตาข่ายผืนนี้ ขาดทันที ถ้าคุณเขียน let _ = async { … }; เพราะ let _ คือคำสั่งปิดปาก lint ตัวนั้นตรงๆ ไม่มีอะไรเตือนคุณอีกเลย
นี่ไม่ใช่รายละเอียดปลีกย่อย แต่เป็นการตัดสินใจออกแบบระดับราก และมันเป็นที่มาของทุกอย่างที่เหลือในบทนี้: ถ้า future ไม่หมุนเอง แปลว่า ต้องมีใครสักคนหมุนมัน ตัวที่ทำหน้าที่หมุนเรียกว่า executorexecutorตัวหมุน `poll` ให้ future เดินหน้า (Tokio หรือ `block_on` ที่เขียนเอง) — และเราเขียนมันเองในอีกสามหัวข้อถัดไป
ใน C# Task หมุนเอง; ใน Rust คุณ (หรือ Tokio) ต้องเป็นคนหมุนมัน — Future คือ แผน ไม่ใช่ งานที่กำลังทำอยู่
poll คือการ pull ไม่ใช่ push
หัวข้อที่มีชื่อว่า “poll คือการ pull ไม่ใช่ push”ตัวเทรตเองเล็กจนน่าตกใจ นี่คือรูปทั้งหมดของมัน (stable มาตั้งแต่ Rust 1.36.0 · 2019-07-04):
pub trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;}method pollpollmethod ที่ executor เรียกเพื่อขอความคืบหน้า คืน `Ready(v)` หรือ `Pending` คือ คำถาม ที่ executor ยิงใส่ future ว่า “เดินหน้าต่อได้หรือยัง” คำตอบมีสองแบบเท่านั้น อยู่ใน enum PollPollenum ผลลัพธ์: `Poll::Ready(v)` หรือ `Poll::Pending`:
Poll::Ready(v)— จบแล้ว นี่คือค่า ห้าม poll ซ้ำหลังจากนี้ (เอกสาร std เขียนไว้ว่ามันอาจ panic อาจค้างถาวร หรือให้ผลอะไรก็ได้ เพราะเทรตไม่ได้กำหนดอะไรไว้เลย — แต่ ไม่ใช่ undefined behavior เพราะpollไม่ได้ markunsafeกฎปกติของ Rust จึงยังคุ้มครองอยู่)Poll::Pending— ยังไปต่อไม่ได้ ไว้ค่อยมาถามใหม่
สังเกตว่า ไม่มีช่องให้ future ส่ง callback กลับไปเลย นี่คือความต่างเชิงสถาปัตยกรรมข้อที่สองจาก .NET: model ของ C# เป็น push — เมื่อ I/O เสร็จ ระบบ ผลัก continuation ที่คุณลงทะเบียนไว้เข้าคิว scheduler ให้รันต่อเอง ส่วน model ของ Rust เป็น pull — executor เดินมาถามเองว่า “พร้อมยัง” แล้ว future ตอบว่า “ยัง แต่ปลุกฉันผ่านตัวนี้นะ”
“ตัวนี้” คือ WakerWakerสัญญาณ "ปลุกให้ poll ใหม่" ที่ future เก็บก่อนคืน `Pending` ที่ห่อมาใน cx parameter ตัวที่สองของ poll คือ ContextContextตัวห่อ `Waker` ที่ถูกส่งเข้า `poll` (ไม่ใช่ Send ไม่ใช่ Sync) — ตัวห่อบางๆ ที่หน้าที่หลักคือพก Waker เข้ามาให้ (มันไม่ใช่ Send และไม่ใช่ Sync โดยเจตนา) กติกาของ future ที่ประพฤติดีคือ: ก่อนคืน Pending ต้องเก็บ cx.waker().clone() ไว้ แล้วเมื่อไปต่อได้จริงค่อยเรียก wake() เพื่อบอก executor ว่า “กลับมา poll ฉันได้แล้ว”
เขียน future เองสักตัวให้เห็นรูปธรรม — Countdown ตอบ Pending ตามจำนวนรอบที่กำหนด แล้วค่อยคืนค่า ห้าบรรทัด use ข้างล่างคือหัว file async_foundations.rs ทั้งหมดที่บทนี้ต้องใช้ (snippet std ทุกอันของบทต่อท้ายกันลงใน file เดียวนี้):
use std::future::Future;use std::pin::{pin, Pin};use std::sync::Arc;use std::task::{Context, Poll, Wake, Waker};use std::thread::{self, Thread};
struct Countdown { remaining: u32, value: i32,}
impl Future for Countdown { type Output = i32;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<i32> { if self.remaining == 0 { return Poll::Ready(self.value); } self.remaining -= 1; println!(" Countdown::poll -> Pending (remaining={})", self.remaining); // เดินหน้าได้ทันที จึงปลุกตัวเองให้ถูก poll รอบใหม่ // ถ้าไม่มีบรรทัดนี้ executor จะไม่มีทางรู้เลยว่าต้องกลับมาเมื่อไหร่ cx.waker().wake_by_ref(); Poll::Pending }}block_on(Countdown { remaining: 3, value: 7 }) ให้ผลตามนี้:
--- Countdown --- Countdown::poll -> Pending (remaining=2) Countdown::poll -> Pending (remaining=1) Countdown::poll -> Pending (remaining=0)Ready(7)สี่บรรทัดนี้คือ poll loop ที่จับต้องได้: สามครั้งแรก future ตอบ Pending แล้วปลุกตัวเอง ครั้งที่สี่ remaining เหลือ 0 จึงคืน Ready(7) และ block_on คืนค่า 7 ออกมา
ลองคอมเมนต์ cx.waker().wake_by_ref(); ทิ้งแล้วรันใหม่ ผลที่ได้จริงคือโปรแกรม หยุดนิ่งตรงนั้น และไม่จบเลย ต้องฆ่าด้วย timeout (exit code 124 หลังรอ 5 วินาที) โดยพิมพ์ได้ถึงแค่บรรทัดนี้:
--- Countdown --- Countdown::poll -> Pending (remaining=2)เพราะ executor ของเราไป thread::park() รออยู่ และไม่มีใครมา unpark ให้ ประโยคของ async-book ตรงจุดนี้พูดชัด: “Without wake(), the executor would have no way of knowing when a particular future could make progress” — ทางเลือกเดียวที่เหลือถ้าไม่มี wake() คือ executor ต้อง busy-poll ทุก future ทิ้งเวลา CPU ไปเปล่าๆ ซึ่งเป็นสิ่งที่ model pull ออกแบบมาเพื่อหลีกเลี่ยงตั้งแต่แรก
สร้าง Waker แบบปลอดภัย: impl Wake ไม่ใช่ RawWaker
หัวข้อที่มีชื่อว่า “สร้าง Waker แบบปลอดภัย: impl Wake ไม่ใช่ RawWaker”ถ้าคุณเปิดเอกสาร Waker แล้วเจอ RawWaker กับ RawWakerVTable — ปิดหน้านั้นทิ้ง นั่นคือ API ระดับล่างที่ต้องเขียน vtable ของ function pointer เองพร้อม unsafe ทั้ง block และตั้งแต่ Rust 1.51.0 เป็นต้นมาเราไม่ต้องแตะมันอีกแล้ว เพราะ std มีเทรต Wake กับ impl<W: Wake + Send + Sync + 'static> From<Arc<W>> for Waker มาให้ ทางที่ปลอดภัยเหลือแค่สองก้าว: implement Wake แล้วแปลง Arc ของมันเป็น Waker ด้วย .into()
Waker ของเราเรียบง่ายที่สุดเท่าที่จะเป็นไปได้ — มันจำแค่ว่า “thread ไหนกำลังหลับรอฉันอยู่” แล้วปลุก thread นั้น:
struct ThreadWaker(Thread);
impl Wake for ThreadWaker { fn wake(self: Arc<Self>) { self.0.unpark(); }}สังเกต signature fn wake(self: Arc<Self>) — เทรตนี้รับ self เป็น Arc ตรงๆ ไม่ใช่ &self เพราะ Waker ต้อง clone ข้าม thread ได้และมีอายุยืนกว่า stack frame ที่สร้างมัน ส่วน method คู่กัน Wake::wake_by_ref(self: &Arc<Self>) มี default implementation ที่ clone Arc แล้วเรียก wake ให้อยู่แล้ว เราจึงไม่ต้องเขียนเอง
ฝั่งผู้ใช้งานก็มีสองรูปคู่กันเช่นกัน: Waker::wake(self) ที่กินตัว waker ไป กับ Waker::wake_by_ref(&self) ที่แค่ ยืม มาปลุก — Countdown ข้างบนเรียก cx.waker().wake_by_ref() เพราะ cx.waker() คืน &Waker มาให้ยืมเฉยๆ ถ้าจะเก็บ waker ไว้ใช้ทีหลัง (ซึ่งเป็นสิ่งที่ future ที่รอ I/O จริงต้องทำ) ต้อง cx.waker().clone() เก็บลงในตัว future ก่อนคืน Pending
block_on ~15 บรรทัด คือ executor ทั้งตัว
หัวข้อที่มีชื่อว่า “block_on ~15 บรรทัด คือ executor ทั้งตัว”มาถึงชิ้นที่สนุกที่สุดของบท ถ้าคุณคิดว่า executor เป็นของลึกลับที่ต้องมีทีมงานเขียน — มันไม่ใช่ อย่างน้อยรุ่น thread เดียวที่รัน future ตัวเดียวจนจบ (สิ่งที่เรียกว่า block_onblock-onประตูเดียวจากโลก sync เข้าโลก async รัน future จนเสร็จ) เขียนได้ครบใน 15 บรรทัด และนี่คือทั้งหมด:
fn block_on<T>(fut: impl Future<Output = T>) -> T { let mut fut = pin!(fut); // ตรึงบน stack ไม่ heap alloc let waker: Waker = Arc::new(ThreadWaker(thread::current())).into(); let mut cx = Context::from_waker(&waker); loop { match fut.as_mut().poll(&mut cx) { // .as_mut() ทุกรอบ ไม่งั้น move Poll::Ready(res) => return res, Poll::Pending => thread::park(), } }}อ่านทีละบรรทัด:
pin!(fut)— มาโครที่ stable ตั้งแต่ 1.68.0 (2023-03-09) ตรึง future ไว้กับ stack frame นี้แล้วคืนPinที่ยืมมันมา สำคัญคือ ไม่มีการ allocate บน heap เลย ซึ่งเป็นเหตุผลที่ future ของ Rust compose กันได้โดยไม่ต้องจ่ายค่า allocation ระหว่างทางArc::new(ThreadWaker(thread::current())).into()—thread::current()คือแฮนเดิลของ thread ที่กำลังรันblock_onอยู่ เราฝากมันไว้ใน waker เพื่อให้ปลุกกลับมาถูกตัวContext::from_waker(&waker)— ห่อ waker ให้อยู่ในรูปที่pollรับได้ (เป็นconst fnตั้งแต่ 1.82.0)- ใน loop: ถ้าได้
Ready(res)ก็คืนค่าออกไปเลย ถ้าได้Pendingก็thread::park()— thread หลับสนิท ไม่กิน CPU จนกว่าจะมีคนเรียกunparkคือThreadWaker::wakeที่เราเพิ่งเขียน
จับคู่กันแล้วจะเห็นว่า park/unpark คือ handshake หลับ-ปลุกที่ครบวงจรพอดี:
sequenceDiagram
participant E as executor คือ block_on
participant F as Future คือ Countdown
E->>F: poll cx รอบที่ 1
F-->>E: Pending พร้อมเก็บ Waker จาก cx เอาไว้
Note over E: thread park thread หลับ ไม่กิน CPU
F->>E: wake จึงเรียก unpark ปลุก thread
E->>F: poll cx รอบที่ 2
F-->>E: Ready v จบงาน block_on คืนค่า v ออกไป
Note over E,F: ไม่มี callback ที่ไหนเลย มีแต่ executor เดินมาถามซ้ำ
คำบรรยายภาพ: วงจร poll กับ wake ที่ขับ async ทั้งระบบ — executor ถามด้วย poll, future ตอบ Pending พร้อมเก็บ Waker ไว้, executor พา thread ไปหลับด้วย park, เมื่อ future ไปต่อได้จึงเรียก wake ซึ่งไป unpark ปลุก thread ให้กลับมาถามซ้ำ วนจนได้ Ready ทั้งวงจรนี้เป็น pull ล้วน ไม่มี callback ถูก push กลับมาแม้แต่ตัวเดียว
รวม snippet std ทุกอันของบทนี้ (Countdown + ThreadWaker + block_on + snippet Waker::noop() ที่จะถึงในอีกสองหัวข้อ) ไว้ใน async_foundations.rs file เดียว แล้วให้ main เรียกทั้งหมดตามลำดับ รันครั้งเดียวได้ผลตามนี้:
[1] before creating the future[2] future created, body has NOT run yet[3] body IS running now (only because block_on polled it)[4] block_on -> 42--- Countdown --- Countdown::poll -> Pending (remaining=2) Countdown::poll -> Pending (remaining=1) Countdown::poll -> Pending (remaining=0)Ready(7)noop poll -> Ready(3)ทั้งหมดนี้ std ล้วน ไม่มี crate ใดๆ — Future, Poll, Context, Waker, Wake, pin! อยู่ใน std ครบตั้งแต่แรก
❌ version ดิบ: ลืม .as_mut() แล้ว compile ไม่ผ่าน
หัวข้อที่มีชื่อว่า “❌ version ดิบ: ลืม .as_mut() แล้ว compile ไม่ผ่าน”บรรทัดที่คนพลาดกันมากที่สุดใน code ข้างบนคือ .as_mut() มันดูเหมือนของประดับ ลองลบทิ้งดูแล้วจะได้ ❌ version ดิบ หน้าตาแบบนี้ (บรรทัดที่ 16–21 ของ file src/main.rs):
loop { match fut.poll(&mut cx) { Poll::Ready(res) => return res, Poll::Pending => thread::park(), } }cargo build ตอบกลับมาแบบนี้ (ตัดเหลือบรรทัดที่มีน้ำหนัก):
error[E0382]: use of moved value: `fut` --> src/main.rs:17:15 |13 | let mut fut = pin!(fut); | ------- move occurs because `fut` has type `Pin<&mut impl Future<Output = T>>`, which does not implement the `Copy` trait...16 | loop { | ---- inside of this loop17 | match fut.poll(&mut cx) { | ^^^ ------------- `fut` moved due to this method call, in previous iteration of loop |note: `poll` takes ownership of the receiver `self`, which moves `fut`help: consider reborrowing the `Pin` instead of moving itย้อนกลับไปดู signature อีกรอบ: fn poll(self: Pin<&mut Self>, …) — ตัวรับคือ Pin แบบกินความเป็นเจ้าของ ไม่ใช่ &mut self และ Pin ที่ pin! คืนมาไม่ได้ implement Copy ฉะนั้นการเรียก fut.poll(...) รอบแรกก็ ย้าย fut เข้าไปใน method ไปแล้ว รอบที่สองของ loop จึงไม่มีอะไรเหลือให้เรียก — และนี่คือ error[E0382] ธรรมดาที่คุณเจอมาตั้งแต่ #21 ไม่ใช่ error พิเศษของ Pin เลย
ทางแก้คือสิ่งที่ compiler บอกเองในบรรทัด help: reborrow Pin::as_mut() คืน Pin ตัวใหม่ที่ยืม Pin เดิมมาแบบชั่วคราว ให้ไปโดน move ในรอบนั้นๆ ส่วนตัวจริงยังอยู่ครบ ใส่ .as_mut() กลับเข้าไปแล้วเขียวทันที นี่คือ pattern ที่คุณจะเห็นซ้ำทุกที่ที่มี poll loop เขียนมือ
Pin คืออะไร และทำไมยังไม่ต้องกลัวมันตอนนี้
หัวข้อที่มีชื่อว่า “Pin คืออะไร และทำไมยังไม่ต้องกลัวมันตอนนี้”self: Pin<&mut Self> เป็นจุดที่คนอ่าน code async ของ Rust ครั้งแรกแล้วสะดุดที่สุด สัญชาตญาณจาก C# ไม่ช่วยเลยเพราะ .NET ไม่มีของแบบนี้ (GC ย้ายอ็อบเจ็กต์ได้ตามใจแล้วแก้ reference ให้เอง) แต่เหตุผลของมันเข้าใจได้ในย่อหน้าเดียว
เมื่อ compiler แปลง async fn เป็น state machine ตัวแปรที่ยังมีชีวิตอยู่ข้าม .await จะกลายเป็น field ของ struct นั้น และถ้า code ของคุณมี reference ชี้ไปหาตัวแปรอื่นใน function เดียวกัน (เช่น let s = String::new(); let r = &s; something(r).await;) struct ที่ได้จะมี field หนึ่ง ชี้ไปหาอีก field หนึ่งในตัวมันเอง — นี่คือ self-referential struct ทีนี้ถ้าใครย้ายมันในหน่วยความจำ (ซึ่งใน Rust การ move คือ memcpy ธรรมดา) pointer ตัวนั้นจะยังชี้ไปที่อยู่เดิมที่ไม่มีอะไรอยู่แล้วทันที
PinPinการตรึงตำแหน่งหน่วยความจำของ future ที่อ้างถึงตัวเอง ห้ามย้ายหลัง poll แรก คือคำสัญญาระดับ type ว่า “หลังจาก poll ครั้งแรก ค่านี้จะไม่ถูกย้ายอีก” — นั่นคือคือเหตุผลเดียวที่ poll รับ Pin<&mut Self> แทน &mut Self สำหรับ future ที่ คุณเขียนเอง อย่าง Countdown ที่ไม่มี self-reference เลย Pin แทบไม่มีความหมายอะไรนอกจากเป็น signature ที่ต้อง thread ผ่าน (สังเกตว่าเราเขียน mut self: Pin<&mut Self> แล้วใช้ self.remaining -= 1 ได้ตรงๆ เพราะ Countdown เป็น Unpin โดยอัตโนมัติ) เรากลับมาเจอ Pin แบบที่ กัด จริงๆ อีกทีในบท 5 ตอนที่ future หลายตัวมาแข่งกัน
poll ครั้งเดียวโดยไม่มี executor เลยด้วย Waker::noop()
หัวข้อที่มีชื่อว่า “poll ครั้งเดียวโดยไม่มี executor เลยด้วย Waker::noop()”บางครั้งใน test คุณอยาก poll future แค่ครั้งเดียวเพื่อดูว่าคืนอะไร โดยไม่อยากตั้ง executor ทั้งตัว ตั้งแต่ Rust 1.85.0 std มี Waker::noop() ที่คืน &'static Waker ตัวที่ wake() แล้วไม่ทำอะไรเลย:
let waker = Waker::noop();let mut cx = Context::from_waker(waker);let mut fut = pin!(async { 3 });if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) { println!("noop poll -> Ready({v})");}noop poll -> Ready(3)ข้อจำกัดที่ต้องรู้: กลนี้ใช้ได้ เฉพาะ future ที่จบได้ทันทีในการ poll ครั้งเดียว ถ้าเอา Waker::noop() ไปใช้กับ Countdown มันจะคืน Pending แล้วจบเรื่อง — เพราะ waker ตัวนี้ไม่ปลุกใคร คุณจะไม่มีทางได้ค่ากลับมาเลย
แผนที่คำศัพท์จาก C#/.NET
หัวข้อที่มีชื่อว่า “แผนที่คำศัพท์จาก C#/.NET”ตอนนี้พอจะวางทับกันได้ครบแล้ว สัญชาตญาณ “async คือ state machine ที่ compiler สร้างให้” โอนมาได้ตรงๆ ส่วนที่ต้อง ถอด ออกคือตารางนี้:
| C# / .NET | Rust | จุดที่ต่างจริง |
|---|---|---|
Task และ Task<T> เป็น hot/eager | Future เป็น cold/lazy | เรียก async method ใน C# แล้ว body เดินทันทีถึง await แรก ใน Rust ไม่มีอะไรเดินจนกว่าจะมีคน poll |
| model push — continuation ถูก schedule เมื่อ op เสร็จ | model pull — executor เดินมาถามเอง | Rust ไม่มีช่องให้ future ส่ง callback กลับเลย มีแต่ Poll::Ready กับ Poll::Pending |
OnCompleted(continuation) ของ awaiter | Waker | Rust เก็บแค่ สัญญาณ poll-again ไม่ใช่ closure ที่จะถูกรันจริง |
.Result และ GetAwaiter().GetResult() และ .Wait() | block_on | ประตูเดียวกันคือทางเข้าจากโลก sync สู่โลก async |
await | .await | ไวยากรณ์ต่างกันแค่จุด แต่ความหมายฝั่ง Rust คือ desugar เป็น poll loop |
| CLR ThreadPool กับ TaskScheduler ฝังมากับ runtime เสมอ | std ไม่แจก executor มาให้เลย | หัวข้อถัดไปทั้งหัวข้อ |
แถวสุดท้ายคือความจริงข้อที่สองของบทนี้ และเป็นเหตุผลว่าทำไมคอร์สนี้ถึงชื่อ “ด้วย Tokio”
std แจก trait กับ syntax แต่ไม่แจก runtime
หัวข้อที่มีชื่อว่า “std แจก trait กับ syntax แต่ไม่แจก runtime”async/.await เข้า stable ใน Rust 1.39.0 (2019-11-07) พร้อมเทรต Future ที่มาก่อนหน้านั้นใน 1.36.0 — แต่ std หยุดอยู่แค่นั้น ไม่มี executor ไม่มี reactor ที่คุยกับ epoll ไม่มี timer ไม่มีอะไรเลย เอกสาร async-book ระบุไว้ชัดว่า std ให้มาแค่ พื้นฐาน ส่วนตัวรันจริงเป็นของ ecosystem — คุณต้อง เลือก runtime มาใส่เอง
เทียบกับ .NET แล้วนี่คือความต่างที่ใหญ่ที่สุดในเชิงประสบการณ์ทำงาน: ใน C# คุณไม่เคย “ติดตั้ง runtime” เพราะ CLR ThreadPool กับ TaskScheduler มาพร้อม framework เสมอ ส่วนใน Rust ถ้าคุณเขียน async fn main() เปล่าๆ มันจะไม่ compile ด้วยซ้ำ — เพราะไม่มีใครรู้ว่าใครจะมาหมุนมัน
block_on 15 บรรทัดของเราคือ executor ที่ทำงานได้จริง แต่มันหมุนได้ทีละ future เดียว รู้จักแค่ park/unpark และมองไม่เห็น socket สักตัว Tokio คือ block_on ตัวจริง ที่เอาสามอย่างมารวมกัน (scheduler + reactor ที่คุย epoll ผ่าน mio + timer) ปล่อยรุ่น 1.0 เมื่อ 2020-12-23 พร้อมสัญญาว่า 1.x จะไม่ break อย่างน้อยห้าปี และนี่คือบรรทัด [dependencies] แรกของคอร์สนี้:
[dependencies]tokio = { version = "1.53.1", features = ["rt", "macros"] }// current_thread ใช้ scheduler thread เดียว จึงต้องการแค่ feature "rt" ไม่ต้องมี "rt-multi-thread"#[tokio::main(flavor = "current_thread")]async fn main() { println!("{}", async { 42 }.await);}42โปรแกรมสี่บรรทัดนี้ทำสิ่งเดียวกับ block_on(async { 42 }) ของเราเป๊ะ — #[tokio::main] เป็นแค่ syntactic sugar ที่สร้าง runtime ขึ้นมาแล้วเรียก block_on ให้ เราลอกเปลือกมันออกด้วยมือในบทหน้า
flavor = "current_thread" ในบรรทัดนั้น ไม่ใช่ของประดับ ถ้าเขียน #[tokio::main] เปล่าๆ ด้วย scope ["rt", "macros"] ชุดนี้ cargo build จะตอบกลับมาว่า:
error: The default runtime flavor is `multi_thread`, but the `rt-multi-thread` feature is disabled.เพราะ default flavor คือ multi_thread ซึ่งอยู่ใต้ feature "rt-multi-thread" คนละตัวกับ "rt" และ "macros" ก็ ไม่ได้ ดึง scheduler ตัวไหนมาให้เลย มันแจกแค่ตัวมาโคร นี่คือเหตุผลที่กติกาของทั้งคอร์สคือ ห้าม features = ["full"] — "full" ทำให้กับดักแบบนี้หายไปพร้อมกับความเข้าใจว่าอะไรมาจากไหน 🔁 บท 2 จะลงรายละเอียดทั้ง2 flavor และวิธีประกอบ Runtime เองด้วยมือ
A — Future ขี้เกียจจริง ไม่ใช่สำนวนพูด: สร้าง future แล้วไม่ .await หรือไม่ spawn = code ข้างในไม่เคยรัน ตายเงียบ ไม่มี exception ไม่มี log ตาข่ายชิ้นเดียวคือ lint #[must_use] เราพิสูจน์ด้วย output จริงที่บรรทัด [2] พิมพ์ก่อน [3] — สัญชาตญาณจาก C# ที่ Task เป็น hot/eager จะทำให้คุณอ่าน code ผิดตั้งแต่บรรทัดแรก
B — std ไม่มี runtime มาให้: std แจกเทรต Future + syntax async/.await แล้วหยุด ไม่มี executor ไม่มี reactor ไม่มี timer คุณต้องเอา Tokio มาเอง หรือเขียน block_on เองแบบในบทนี้ ต่างจาก .NET ที่ CLR ThreadPool ฝังมากับ framework เสมอ
C — function colouring จริง และแรงกว่าใน C#: async ติดเชื้อขึ้นทั้ง call stack — function sync เรียก async ไม่ได้ถ้าไม่มี runtime (block_on คือประตูเดียว) และใน Rust มันติดถึง ระดับ type ไม่ใช่แค่ signature ผลคือ ecosystem แตกเป็นสองโลก sync กับ async ที่ crate คนละชุด เราเก็บเรื่องนี้เต็มๆ ในบท 7
D — แซนด์บ็อกซ์เปลี่ยนกติกาที่บทนี้: พูดให้ตรง — #22 กับ #23 เป็น zero-crate จริงๆ ส่วน #21 มี clap/serde/thiserror อยู่แล้ว แต่ทั้งสามคอร์ส จงใจไม่แตะ async เลย สิ่งที่ใหม่ในคอร์สนี้คือ crate dependency กลายเป็น แกนของทุกบท และ tokio ลาก mio/libc/socket2 เข้ามาด้วย ซึ่งต้องใช้ system linker (gcc) — กลเม็ด musl + bundled rust-lld ที่ #22/#23 ใช้รันแบบไม่ต้องพึ่ง gcc จึง ปลดระวางทั้งคอร์ส ทั้ง 8 บทตรวจด้วย recipe เดียวกันคือ build/run/test/clippy บน target native x86_64-unknown-linux-gnu — บทที่ 1 นี้เป็นบทเดียวของคอร์สที่ [dependencies] ยังว่าง และเพราะไม่มี crate เลย มันจึงยังรันซ้ำได้ด้วย rustc เปล่าๆ โดยไม่ต้องมี Cargo ด้วยซ้ำ ที่แลกมาคือ: ตลอด 8 บทของคอร์สนี้ ไม่มีอะไรเป็นไดอะแกรมอย่างเดียว ทุก snippet compile และรันได้จริง — ยกเว้น snippet ที่ติดป้าย ❌ ซึ่ง ตั้งใจ ให้พัง และเราลงข้อความ error จริงของมันไว้แทน output ต่างจาก #23 ที่คงเรื่อง consensus ไว้เป็นกล่องไดอะแกรม
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”บทนี้วางคำศัพท์ที่อีกเจ็ดบทข้างหน้าใช้ซ้ำทุกบท: async fn และ async { … } ไม่ได้ เริ่ม อะไร มันแค่ สร้าง state machine ที่ implement Future แล้วนิ่งสนิท — พิสูจน์ด้วย [2] ที่พิมพ์ก่อน [3]; model เป็น pull ไม่ใช่ push โดย poll คืน Poll::Ready(v) หรือ Poll::Pending และ future ที่ประพฤติดีต้องเก็บ Waker จาก cx ไว้ก่อนคืน Pending ไม่งั้น executor ค้างถาวร (เราลบ wake_by_ref() ให้ดูแล้วโปรแกรมหยุดนิ่งจริง); สร้าง Waker ด้วยทางปลอดภัย impl Wake + Arc::new(w).into() เท่านั้น อย่าแตะ RawWaker; block_on ที่เป็น executor ครบตัวยาวแค่ 15 บรรทัดด้วย pin! + poll loop + park/unpark; .as_mut() ใน loop ไม่ใช่ของประดับ เพราะ poll กินความเป็นเจ้าของ Pin ที่ไม่ Copy ลบทิ้งแล้วได้ error[E0382] ทันที; Pin มีอยู่เพราะ future ที่ compiler สร้างอาจ self-referential แต่กับ future ที่เขียนเองมันเป็นแค่ signature ที่ thread ผ่าน; และความจริงข้อสุดท้าย — std แจก trait กับ syntax แต่ไม่แจก runtime ทุก snippet ในบทนี้ (ยกเว้น snippet ที่ติดป้าย ❌ ซึ่งตั้งใจให้พัง) compile zero-warnings บน rustc 1.97.1 / edition 2024 / std ล้วน และรันได้ output ตามที่เห็นทุกบรรทัด
บท 2 เราเลิกเขียน executor เองแล้วไปใช้ของจริง: block_on ของเราหมุนได้ทีละ future เดียวและมองไม่เห็น socket สักตัว บทหน้าเราจะรื้อ Tokio runtime ออกดูว่ามันคือ scheduler + I/O driver + timer ประกอบกันอย่างไร ลอกเปลือก #[tokio::main] ออกด้วยมือเป็น Builder แล้วเจอกับดัก feature-gate เต็มๆ ว่าทำไม "macros" ถึงไม่พา scheduler มาด้วย
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
std::future::Future— เอกสาร std (เข้าถึง 2026-07-27) — รูปของเทรตทั้งหมด:type Outputกับfn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>เทรตนี้ stable ใน Rust 1.36.0 (2019-07-04) ตาม ประกาศรุ่น 1.36.0- Async-await on stable Rust — Rust Blog, 2019-11-07 (เข้าถึง 2026-07-27) —
async/.awaitเข้า stable ใน 1.39.0 และประโยคที่เป็นแกนของบทนี้: การเรียกasync fnคืนค่าที่ implementFutureโดย body ยังไม่ทำงานจนกว่าจะถูก poll std::task::Poll— เอกสาร std (เข้าถึง 2026-07-27) —pub enum Poll<T> { Ready(T), Pending }และกติกาว่าห้าม poll ซ้ำหลังได้Readyแล้วstd::task::Wake— เอกสาร std (เข้าถึง 2026-07-27) — เทรตWakeพร้อมimpl From<Arc<W>> for Wakerstable ใน 1.51.0 คือทางสร้างWakerแบบ safe ที่ไม่ต้องแตะRawWaker; ตัวอย่างblock_onในบทนี้เดินตามโครงในหน้านี้std::task::Context— เอกสาร std (เข้าถึง 2026-07-27) —Context::from_wakerและContext::wakerเป็นconst fnตั้งแต่ 1.82.0 และContextไม่ใช่Sendไม่ใช่Syncstd::task::Waker— เอกสาร std (เข้าถึง 2026-07-27) —Waker::noop() -> &'static Wakerstable ใน 1.85.0 สำหรับ poll ครั้งเดียวใน teststd::pin::pin!— เอกสาร std (เข้าถึง 2026-07-27) — มาโคร stack-pin stable ใน 1.68.0 (2023-03-09) ที่ตรึง future ได้โดยไม่ allocate บน heap- Async Book — Task Wakeups with Waker (เข้าถึง 2026-07-27) — ที่มาของประโยค “Without wake(), the executor would have no way of knowing when a particular future could make progress” และคำอธิบายว่า future compose กันได้ “without needing intermediate allocations”
- Async Book — The Async Ecosystem (เข้าถึง 2026-07-27) — std แจกเทรตกับ syntax แต่ ไม่มี runtime มาให้ คุณต้องเลือก runtime เอง
- Announcing Tokio 1.0 — tokio.rs, 2020-12-23 (เข้าถึง 2026-07-27) — Tokio 1.0 ปล่อย 2020-12-23 พร้อมสัญญาความเสถียรของ 1.x อย่างน้อยห้าปี นี่คือ
block_onตัวจริงที่เราจะใช้ตลอด 7 บทถัดไป
เช็กความเข้าใจ — บทที่ 1
ข้อ 1 / 3ในโปรแกรมของบทนี้ ทำไมบรรทัด [2] future created, body has NOT run yet ถึงพิมพ์ออกมา *ก่อน* บรรทัด [3] ที่อยู่ใน body ของ async block?