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

ทำไม 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 บรรทัด

📦 kaen-kvstore

คอร์ส​นี้ ต่อยอด 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 อ้างอิง​ต้นทาง​ให้​ทุก​บท​เดิน​ตาม

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

ทุก snippet pin ที่ rustc 1.97.1 (8bab26f4f 2026-07-14) และ edition = “2024” — เช็ก​เอง​ด้วย rustc --version ได้​เสมอ [dependencies] ของ​บท​นี้​ว่างเปล่า code หลัก​ทั้ง file เป็น std ล้วน และ compile ได้​โดย​ไม่​ต้อง​มี Cargo ด้วย​ซ้ำ:

Terminal window
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 คือ แผน ไม่ใช่ งาน​ที่​กำลัง​ทำ​อยู่

ตัว​เทรต​เอง​เล็ก​จน​น่า​ตกใจ นี่​คือ​รูป​ทั้งหมด​ของ​มัน (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 ไม่​ได้ mark unsafe กฎ​ปกติ​ของ 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 ออก​มา

ลบ wake() บรรทัด​เดียว โปรแกรม​ค้าง​ถาวร

ลอง​คอมเมนต์ 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 แล้ว​เจอ 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

มา​ถึง​ชิ้น​ที่​สนุก​ที่สุด​ของ​บท ถ้า​คุณ​คิด​ว่า 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 ครบ​ตั้งแต่​แรก

บรรทัด​ที่​คน​พลาด​กัน​มาก​ที่สุด​ใน 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 loop
17 | 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 หลาย​ตัว​มา​แข่ง​กัน

บาง​ครั้ง​ใน 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 ตัว​นี้​ไม่​ปลุก​ใคร คุณ​จะ​ไม่มี​ทาง​ได้​ค่า​กลับ​มา​เลย

ตอน​นี้​พอ​จะ​วาง​ทับ​กัน​ได้​ครบ​แล้ว สัญชาตญาณ “async คือ state machine ที่ compiler สร้าง​ให้” โอน​มาได้ตรงๆ ส่วน​ที่​ต้อง ถอด ออก​คือ​ตาราง​นี้:

C# / .NETRustจุด​ที่​ต่าง​จริง
Task และ Task<T> เป็น hot/eagerFuture เป็น 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) ของ awaiterWakerRust เก็บ​แค่ สัญญาณ poll-again ไม่ใช่ closure ที่​จะ​ถูกรัน​จริง
.Result และ GetAwaiter().GetResult() และ .Wait()block_onประตู​เดียวกัน​คือ​ทาง​เข้า​จาก​โลก sync สู่​โลก async
await.awaitไวยากรณ์​ต่าง​กัน​แค่​จุด แต่​ความหมาย​ฝั่ง Rust คือ desugar เป็น poll loop
CLR ThreadPool กับ TaskScheduler ฝัง​มา​กับ runtime เสมอstd ไม่​แจก executor มา​ให้​เลยหัวข้อ​ถัด​ไป​ทั้ง​หัวข้อ

แถว​สุดท้าย​คือ​ความ​จริง​ข้อ​ที่​สอง​ของ​บท​นี้ และ​เป็น​เหตุผล​ว่า​ทำไม​คอร์ส​นี้​ถึง​ชื่อ “ด้วย Tokio”

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 ให้ เรา​ลอก​เปลือก​มัน​ออก​ด้วย​มือ​ใน​บท​หน้า

กับดัก feature-gate ตัว​แรก (เจอ​เต็มๆ ในบท 2)

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 เอง​ด้วย​มือ

honesty spine 4 เส้น ที่​ทั้ง​คอร์ส​ยึด (บท​นี้​ตั้ง precedent ครบ​ทั้ง​สี่)

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 คืน​ค่าที่ implement Future โดย 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 Waker stable ใน 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 ไม่ใช่ Sync
  • std::task::Waker — เอกสาร std (เข้าถึง 2026-07-27) — Waker::noop() -> &'static Waker stable ใน 1.85.0 สำหรับ poll ครั้ง​เดียว​ใน test
  • std::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?