รู้จัก Tokio runtime — executor, reactor และ timer ที่คุณต้องประกอบเอง
บท 1 จบลงด้วยข้อเท็จจริงที่ควรจะน่าอึดอัดกว่าที่คุณรู้สึกตอนนั้น: block_on ที่เราเขียนเองราว 15 บรรทัดคือ executor ที่ทำงานได้ จริง — มันหมุน future จนจบและพา thread ไปหลับด้วย park เป็น แต่มันหมุนได้ทีละ1 future เท่านั้น ไม่รู้จัก socket สักตัว และไม่รู้จักเวลาเลย ถ้าคุณเอา kaen-kvstore ของ #22 มาเสียบเข้าไปตอนนี้ มันจะรับได้หนึ่งคอนเนกชันแล้วจบ
บทนี้คือบทที่เราหยุดเขียน executor เองแล้วไปใช้ของจริง — และประเด็นสำคัญที่สุดของทั้งบทมีประโยคเดียว: Tokio runtime ไม่ใช่สิ่งที่ “มีอยู่แล้ว” มันคือออบเจ็กต์ที่คุณสร้างขึ้นมาเอง ถือไว้ในตัวแปร แล้วมันตายเมื่อคุณ drop มันทิ้ง สัญชาตญาณจาก .NET จะพาคุณหลงตรงจุดนี้แรงที่สุดในคอร์ส เพราะใน C# คุณไม่เคย “สร้าง runtime” ThreadPool กับ TaskScheduler เป็น ambient — มันอยู่ตรงนั้นตั้งแต่ process เริ่ม โดยที่คุณไม่ต้องขอ
คอร์สนี้ ต่อยอด repo kaen-kvstore จาก #22 (code ตัวอย่างกำลังจัดทำ) — ตลอด 8 บทเราจะยก store ตัวเดิมจาก ThreadPool ขนาดคงที่ 4 worker ของ #22 (หนึ่งคอนเนกชันยึด worker ไว้ทั้งเส้น) ขึ้นไปอยู่บน Tokio จนถึง capstone ที่มี graceful shutdown ครบวงจร บทนี้คือบทที่เรา “ติดตั้งเครื่องยนต์” — pattern manual Builder ที่ประกอบในบทนี้คือโครงเดียวกับที่ capstone บท 8 ใช้จริง เพราะ server ที่ต้องคุม shutdown เองไม่ควรซ่อน runtime ไว้ใต้ attribute
ทุก snippet pin ที่ rustc 1.97.1 (8bab26f4f 2026-07-14) และ edition = “2024” บน tokio 1.53.1 (ปล่อย 2026-07-20, MSRV 1.71):
[dependencies]tokio = { version = "1.53.1", features = ["rt", "rt-multi-thread", "macros", "time"] }ห้าม features = ["full"] เด็ดขาดทั้งคอร์ส ด้วย scope สี่ตัวนี้ Cargo.lock มี package รวม 8 รายการ คือ crate ของเราเอง + tokio 1.53.1, tokio-macros 2.7.1, pin-project-lite 0.2.17 และ build dep ของมาโครอีกสี่ (proc-macro2, quote, syn, unicode-ident) — ฐานนับตรงนี้คือ จำนวนรายการใน Cargo.lock ซึ่งนับ crate ของเราเองเข้าไปด้วย ถ้านับเฉพาะ dependency ที่ compile จริงบน Linux จะได้ 7 crate ซึ่งเป็นตัวเลขที่บท 1 กับบท 3 ใช้ — ยังไม่มี mio, libc, socket2 เลยแม้แต่ตัวเดียว ซึ่งเป็นหลักฐานตรงๆ ว่า reactor จริงยังไม่ถูกดึงเข้ามา (มันมากับ "net" ในบท 4 — feature scope ของบทนั้นดัน Cargo.lock ขึ้นเป็น 15 รายการ ของใหม่คือ mio, libc, socket2, bytes บวกรายการฝั่ง wasi/windows ที่ไม่ได้ compile บน Linux — ฐานนับเดียวกันกับ 8 ข้างบน) ทุก snippet ในบทนี้ผ่าน cargo build/run/clippy -- -D warnings บน target x86_64-unknown-linux-gnu ยกเว้น snippet ที่ติดป้าย ❌ ซึ่งตั้งใจให้พัง — ของพวกนั้นเราลงข้อความ error หรือ panic จริงของมันไว้แทน และ output ทุกบรรทัดข้างล่างคือข้อความจริงที่พิมพ์ออกมา
project ตรวจสอบของบทนี้ใช้ layout หลาย binary — snippet ที่ตั้งใจให้ panic ถูกแยกไปคนละ file ใต้ src/bin/ ซึ่งเป็นที่มาของชื่อ file ที่โผล่ในข้อความ panic ข้างล่าง คือ src/bin/e_no_timer.rs (ลืม enable_time) กับ src/bin/f_workers0.rs (worker_threads(0)) ชื่อ file และเลขบรรทัดของสองก้อนนั้นตรงกับ file ที่แสดงไว้เป๊ะ ส่วน panic อีกสองก้อนชี้เข้าไปในซอร์สของ tokio เอง จึงขึ้นเป็น path ของ registry แทน
runtime คือสามบริการที่รวมร่างกัน ไม่ใช่ “ตัวรัน” เฉยๆ
หัวข้อที่มีชื่อว่า “runtime คือสามบริการที่รวมร่างกัน ไม่ใช่ “ตัวรัน” เฉยๆ”คำว่า runtimeruntime`Runtime` ของ Tokio = executor + reactor + timer รวมกันเป็นออบเจ็กต์ที่คุณสร้างเอง ในโลก Tokio หมายถึงของสามชิ้นที่ถูกมัดรวมไว้ในออบเจ็กต์เดียว และการแยกมันออกจากกันในหัวคือสิ่งที่ทำให้ error ครึ่งหนึ่งของบทนี้อ่านออกทันที:
- task scheduler — ตัวที่ทำหน้าที่ executor ในความหมายของบท 1 คือคนที่เดินมา
pollfuture ซ้ำๆ ต่างกันตรงที่ของ Tokio หมุน task ได้เป็นแสนตัวพร้อมกัน ไม่ใช่ตัวเดียว - I/O driver หรือที่เรียกกันว่า reactorreactorตัวฟังเหตุการณ์ I/O (epoll ผ่าน mio) ที่ปลุก task เมื่อ socket พร้อม — ตัวที่นั่งอยู่บน
epollของ Linux ผ่าน crate ชื่อ mio คอยลงทะเบียน socket ทุกตัวไว้ แล้วเมื่อ kernel บอกว่า fd ตัวไหนอ่านได้ มันจะไปเรียกwake()ของ task ที่นอนรอ fd นั้นอยู่ — คือ handshakeWakerตัวเดียวกับบท 1 เป๊ะๆ เพียงแต่คนกดปุ่มปลุกเปลี่ยนจากthread::unparkมาเป็น kernel - timer — คิวเวลาที่ทำให้
tokio::time::sleepเป็นไปได้โดยไม่ต้องกิน thread ทิ้งไว้หนึ่งตัวต่อหนึ่งการรอ
flowchart TB
APP["code async ของคุณ คือ task หลายตัว"]
subgraph RT["Runtime หนึ่งออบเจ็กต์ ที่คุณสร้างเอง"]
SCHED["task scheduler คือ executor<br/>เดินมา poll task ซ้ำ ๆ"]
IO["I O driver คือ reactor<br/>นั่งบน epoll ผ่าน mio<br/>เปิดด้วย enable io และ feature net"]
TIME["timer<br/>คิวเวลาสำหรับ sleep และ timeout<br/>เปิดด้วย enable time และ feature time"]
end
KERNEL["kernel คือ epoll และ นาฬิกาของระบบ"]
APP -->|"block on หรือ spawn"| SCHED
SCHED -->|"poll"| APP
IO -->|"wake ปลุก task ที่รอ fd นี้"| SCHED
TIME -->|"wake เมื่อครบเวลา"| SCHED
KERNEL -->|"fd พร้อมแล้ว"| IO
KERNEL -->|"tick"| TIME
คำบรรยายภาพ: Tokio runtime คือออบเจ็กต์เดียวที่ห่อสามบริการไว้ด้วยกัน task scheduler ทำหน้าที่ executor คอยเดินมา poll task, I/O driver หรือ reactor นั่งบน epoll ผ่าน mio และคอยเรียก wake เมื่อ fd พร้อม ส่วน timer ทำแบบเดียวกันเมื่อครบเวลา ทั้งสองตัวหลังจะไม่ถูกเปิดให้อัตโนมัติ ต้องสั่ง enable เองตอนสร้าง runtime มิฉะนั้นจะได้ panic ตอนรัน ไม่ใช่ตอน compile
จุดที่ต้องขีดเส้นใต้คือ สองตัวหลังไม่ได้เปิดมาให้เอง คุณต้องสั่งเปิดตอนสร้าง runtime และถ้าลืม โปรแกรมจะ compile ผ่านสวยงามแล้วไป panic เอาตอนรัน — เรื่องนี้กินสองหัวข้อข้างล่าง
ลอกเปลือก #[tokio::main] ออกด้วยมือ
หัวข้อที่มีชื่อว่า “ลอกเปลือก #[tokio::main] ออกด้วยมือ”เริ่มจากรูปที่คุณเห็นในทุก README ของโลก Rust:
#[tokio::main]async fn main() { println!("hello from async main"); println!("flavor = multi_thread (default)");}hello from async mainflavor = multi_thread (default)attribute ตัวนี้ไม่ได้ทำอะไรลึกลับเลย มันเป็น syntactic sugar ล้วนๆ ที่แปลง async fn main ของคุณให้กลายเป็น fn main ธรรมดา แล้วยัด code สี่บรรทัดนี้ครอบไว้ให้ (เอกสารของ attr.main เขียน expansion นี้ไว้ตรงๆ):
Builder::new_multi_thread() .enable_all() .build() .unwrap() .block_on(async { /* body เดิมของคุณ */ })เขียนเองก็ได้ และนี่คือรูปที่ capstone จะใช้ เพราะพอ runtime เป็นตัวแปรที่คุณถืออยู่จริง คุณจะสั่ง shutdown มันเองได้:
use tokio::runtime::{Builder, Runtime};
fn main() { // current_thread — feature "rt" ก็พอ, ไม่มี background thread let rt = Builder::new_current_thread() .enable_all() .build() .unwrap(); let n: i32 = rt.block_on(async { 42 }); println!("current_thread block_on -> {n}");
// multi_thread — feature "rt-multi-thread", work-stealing pool let rt2 = Builder::new_multi_thread() .worker_threads(4) .thread_name("kaen-worker") .enable_all() .build() .unwrap(); let who = rt2.block_on(async { tokio::spawn(async { std::thread::current().name().unwrap_or("?").to_string() }) .await .unwrap() }); println!("multi_thread task ran on thread -> {who}");
// Runtime::new() == new_multi_thread().enable_all().build() let rt3 = Runtime::new().unwrap(); println!("Runtime::new block_on -> {}", rt3.block_on(async { 7 }));}current_thread block_on -> 42multi_thread task ran on thread -> kaen-workerRuntime::new block_on -> 7บรรทัดกลางคือของแถมที่คุ้มค่าอ่าน: thread_name("kaen-worker") ตั้งชื่อ worker thread แล้ว task ที่เรา spawn ก็รายงานกลับมาว่ามันรันบน thread ชื่อนั้นจริง — มันไม่ได้รันบน main นี่คือ multi_thread scheduler ทำงานให้เห็นกับตา ส่วน Runtime::new() เป็นทางลัดที่แปลว่า multi_thread + เปิด driver ทุกตัว พอดีเป๊ะ
อ่าน signature สำคัญไว้ให้ครบ (tokio 1.53.1):
pub fn new_current_thread() -> Builder— อยู่ใต้ feature"rt"pub fn new_multi_thread() -> Builder— อยู่ใต้ feature"rt-multi-thread"pub fn worker_threads(&mut self, val: usize) -> &mut Self— default คือจำนวน CPU core และ มีผลเฉพาะ multi_threadenable_all()/enable_io()(ต้องมี feature ที่เปิด I/O driver — ในคอร์สนี้คือ"net"แต่"process"หรือ"signal"บน unix ก็เปิดให้เหมือนกัน) /enable_time()(ต้องมี"time")pub fn build(&mut self) -> std::io::Result<Runtime>— คืนResultฉะนั้น.unwrap()ที่เห็นใน expansion ไม่ใช่ของประดับpub fn block_on<F: Future>(&self, future: F) -> F::Output— รับ&selfไม่ใช่selfเรียกซ้ำได้pub fn new() -> std::io::Result<Runtime>— อยู่ใต้"rt-multi-thread"เช่นกัน
#[tokio::main] ไม่ใช่เวทมนตร์ มันคือ Builder + block_on ที่ถูกเขียนแทนให้ — และเพราะ runtime เป็น ออบเจ็กต์ที่คุณถือ ไม่ใช่ ambient service แบบ CLR ThreadPool คุณจึงเป็นคนตัดสินใจเองว่ามันมีกี่ thread เปิด driver ตัวไหน และตายเมื่อไหร่
2 flavor และกับดัก feature-gate ที่ทำให้ทุกคนตกม้าตายรอบแรก
หัวข้อที่มีชื่อว่า “2 flavor และกับดัก feature-gate ที่ทำให้ทุกคนตกม้าตายรอบแรก”Tokio มี scheduler สองแบบ และมันไม่ได้ต่างกันแค่จำนวน thread:
current_thread | multi_thread | |
|---|---|---|
| feature ที่ต้องเปิด | "rt" | "rt-multi-thread" |
| thread ที่ใช้ | thread ที่เรียก block_on เท่านั้น | worker pool ตามจำนวน core |
| เดินตอนไหน | เฉพาะตอนอยู่ใน block_on ออกจาก block_on แล้ว task ที่ค้างอยู่หยุดนิ่ง | เดินตลอดเวลาเบื้องหลัง |
| algorithm | คิวเดียว | work-stealing — worker แต่ละตัวมีคิวของตัวเอง ใครว่างไปขโมยงานเพื่อน |
| เทียบ .NET | single-thread SynchronizationContext แบบ UI thread | default ThreadPool ที่เป็น work-stealing อยู่แล้ว |
ทีนี้มาถึงกับดัก ลอง scope แคบๆ ที่ดูสมเหตุสมผลมากอันนี้ — บท 1 ปิดท้ายด้วย ["rt", "macros"] พอดี:
[dependencies]tokio = { version = "1.53.1", features = ["rt", "macros"] }แล้วเขียน ❌ version ดิบ ที่ลอกมาจาก README ทั่วไปตรงๆ:
#[tokio::main]async fn main() { println!("hello");}cargo build ตอบกลับมาแบบนี้:
error: The default runtime flavor is `multi_thread`, but the `rt-multi-thread` feature is disabled. --> src/main.rs:1:1 |1 | #[tokio::main] | ^^^^^^^^^^^^^^ | = note: this error originates in the attribute macro `tokio::main` (in Nightly builds, run with -Z macro-backtrace for more info)เหตุผลคือ feature graph ของ tokio 1.53.1 ตรงๆ: rt = [], rt-multi-thread = ["rt"], macros = ["tokio-macros"] — อ่านบรรทัดที่สามช้าๆ "macros" แจกมาแค่ตัวมาโคร มันไม่ได้พา scheduler มาให้เลยสักตัว และ default flavor ของ #[tokio::main] คือ multi_thread ซึ่งอยู่คนละ feature กับ "rt" ที่คุณเปิดไว้
แก้ได้สองทาง เลือกตามว่าคุณอยากได้อะไรจริงๆ — ถ้าอยากได้ scheduler thread เดียวก็บอกมันตรงๆ ไม่ต้องเพิ่ม dependency อะไรเลย:
#[tokio::main(flavor = "current_thread")]async fn main() { println!("hello");}helloถ้าอยากได้ multi_thread จริงก็เติม "rt-multi-thread" เข้าไปใน features — ซึ่งคือสิ่งที่ scope ของบทนี้ทำ และเป็นเหตุผลที่ snippet แรกสุดของหัวข้อที่แล้ว compile ผ่าน
นี่คือรูปธรรมของกติกา ห้าม "full" ที่ประกาศไว้ตั้งแต่บท 1: ถ้าคุณเปิด "full" error บรรทัดนี้จะหายไป พร้อมกับความเข้าใจว่าอะไรมาจากไหน และราคาที่จ่ายก็วัดได้จริง — โปรแกรม hello-world ก้อนเดียวกัน (#[tokio::main] + tokio::time::sleep หนึ่งบรรทัด) build ด้วย cargo build --release ได้ binary 767,880 byte ด้วย scope 4 feature ของบทนี้ แต่ 885,144 byte ด้วย "full" — โตขึ้น 117,264 byte (~115 KiB) จาก code ที่ไม่ได้ถูกเรียกใช้เลยสักบรรทัด (เลขคู่นี้คือของ rustc 1.97.1 + tokio 1.53.1 บนแซนด์บ็อกซ์นี้ ไม่ใช่ค่าคงที่ วัดของคุณเองได้ด้วย ls -l target/release/…)
block_on คือประตูบานเดียว และเดินผิดทางแล้ว panic
หัวข้อที่มีชื่อว่า “block_on คือประตูบานเดียว และเดินผิดทางแล้ว panic”block_on คือจุดเชื่อมระหว่างโลก sync กับโลก async — ฝั่งนอกเป็น function ธรรมดาที่ block thread รอ ฝั่งในเป็น future ที่ถูก poll จนได้ Ready เทียบกับ C# มันคือ .GetAwaiter().GetResult() เป๊ะๆ ทั้งในแง่ท่าและในแง่ อันตราย
แต่ความอันตรายออกหน้าไม่เหมือนกัน และนี่คือย่อหน้าที่ .NET dev ต้องอ่านสองรอบ: ใน C# การเรียก .Result บน SynchronizationContext thread เดียว (คลาสสิกคือ UI thread) ทำให้เกิด deadlock — โปรแกรมค้างเงียบๆ ไม่มีข้อความ ไม่มี stack trace หาสาเหตุกันเป็นวัน ส่วน Tokio เลือกอีกทาง มัน panic ทันทีพร้อมประโยคที่บอกสาเหตุตรงๆ
❌ version ดิบ ที่คนเขียนกันบ่อยมากตอนพยายามเรียก code async จากใน function ที่ตัวเองไม่รู้ว่าเป็น async อยู่แล้ว:
use tokio::runtime::Runtime;
fn main() { let outer = Runtime::new().unwrap(); outer.block_on(async { // ❌ version ดิบ: สร้าง runtime ซ้อนใน async context let inner = Runtime::new().unwrap(); inner.block_on(async { println!("never printed") }); });}compile ผ่านสบายๆ แล้วระเบิดตอนรัน (exit code 101):
thread 'main' (83010) panicked at /home/nook/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.53.1/src/runtime/scheduler/multi_thread/mod.rs:91:9:Cannot start a runtime from within a runtime. This happens because a function (like `block_on`) attempted to block the current thread while the thread is being used to drive asynchronous tasks.note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceอ่านประโยคที่สองให้ดี มันอธิบายกลไกครบ: thread ที่คุณยืนอยู่ กำลังทำหน้าที่ขับ task อยู่ การไป block มันคือการฆ่าคนขับ Tokio จึงตรวจเจอแล้วยอมตายเสียงดังแทนที่จะค้างเงียบ — ในแง่ประสบการณ์ debug นี่คือข้อได้เปรียบชัดๆ เหนือ deadlock แบบ .NET
กฎที่ตามมาคือ block_on ใช้ได้จากโลก sync เท่านั้น ถ้าคุณอยู่ในโลก async อยู่แล้วและอยากรออะไรสักอย่าง คำตอบคือ .await ไม่ใช่ block_on และถ้าสิ่งที่คุณอยากรอมันเป็นงาน blocking (อ่าน file แบบ sync, เรียก library ที่ไม่มี async) คำตอบคือ spawn_blocking ซึ่งเป็นของบท 3
driver ไม่ได้เปิดมาให้เอง: enable_all ที่ลืมแล้ว panic ตอนรัน
หัวข้อที่มีชื่อว่า “driver ไม่ได้เปิดมาให้เอง: enable_all ที่ลืมแล้ว panic ตอนรัน”นี่คือ footgun ตัวที่สองของบท และมันเจ็บกว่าตัวแรกเพราะ compiler ช่วยคุณไม่ได้เลย — code ถูกทุกบรรทัดในสายตา type system มันแค่ผิดตอนรัน
❌ version ดิบ: สร้าง Builder แล้วลืม enable_all()
use std::time::Duration;use tokio::runtime::Builder;
fn main() { // ❌ version ดิบ: ไม่มี enable_all() -> ไม่มี timer driver let rt = Builder::new_multi_thread().build().unwrap(); rt.block_on(async { tokio::time::sleep(Duration::from_millis(1)).await; println!("never printed"); });}thread 'main' (83112) panicked at src/bin/e_no_timer.rs:8:9:A Tokio 1.x context was found, but timers are disabled. Call `enable_time` on the runtime builder to enable timers.note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceข้อความนี้ออกแบบมาดีมาก มันแยกสองความล้มเหลวที่หน้าตาเหมือนกันออกจากกันชัดเจน: “A Tokio 1.x context was found” แปลว่า runtime มีอยู่จริง ปัญหาไม่ใช่ว่าคุณลืมสร้าง runtime — ปัญหาคือ runtime ตัวนั้นถูกประกอบมาโดยไม่มี timer
GREEN คือเติมบรรทัดเดียว จะใช้ enable_time() เจาะจงหรือ enable_all() ที่ครอบให้ทั้งหมดก็ได้:
use std::time::Duration;use tokio::runtime::Builder;
fn main() { // GREEN: enable_time() เปิดเฉพาะ timer driver (enable_all() ก็ครอบให้) let rt = Builder::new_multi_thread().enable_time().build().unwrap(); rt.block_on(async { tokio::time::sleep(Duration::from_millis(1)).await; println!("timer driver is on -> slept 1ms"); });}timer driver is on -> slept 1msเรื่องเดียวกันเป๊ะเกิดกับ reactor ด้วย และนั่นคือกรณีที่คุณจะเจอจริงตอนย้าย kaen-kvstore ขึ้น Tokio — สังเกตว่า snippet ข้างล่างต้องเติม "net" เข้า features ชั่วคราว (เป็น scope ของบท 4 ไม่ใช่ของบทนี้ เพราะ tokio::net ทั้ง module อยู่ใต้ feature นั้น) เรารันมันใน project แยกเพื่อให้เห็น panic ของจริง:
use tokio::runtime::Builder;
fn main() { let rt = Builder::new_multi_thread().build().unwrap(); rt.block_on(async { let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); println!("never printed {:?}", l.local_addr()); });}thread 'main' (84624) panicked at /home/nook/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.53.1/src/net/tcp/listener.rs:304:22:A Tokio 1.x context was found, but IO is disabled. Call `enable_io` on the runtime builder to enable IO.note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceโครงประโยคเดียวกับ timer ทุกประการ เปลี่ยนแค่คำว่า timers เป็น IO และคำแนะนำจาก enable_time เป็น enable_io จำ pattern ของข้อความนี้ไว้ — ถ้าคุณเห็นคำว่า “A Tokio 1.x context was found, but … is disabled” คำตอบอยู่ที่ Builder ของคุณเสมอ ไม่ใช่ที่ code async ที่เพิ่งเขียน
ปิดท้ายหัวข้อนี้ด้วยของแถมสั้นๆ ที่ควรเคยเห็นสักครั้งแล้วลืมได้เลย: worker_threads(0) — นี่คือ src/bin/f_workers0.rs ทั้ง file เลขบรรทัดในข้อความ panic ข้างล่างจึงตรงกับ file นี้พอดี:
use tokio::runtime::Builder;
fn main() { let rt = Builder::new_multi_thread() .worker_threads(0) .enable_all() .build() .unwrap(); println!("never printed -> {}", rt.block_on(async { 1 }));}thread 'main' (83182) panicked at src/bin/f_workers0.rs:5:10:Worker threads cannot be set to 0note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceสังเกตว่ามัน panic ที่ตัว builder เอง ไม่ได้คืน Err มาให้ — และ panic ตัวนี้เกิดตั้งแต่ตอน worker_threads(0) ยังไม่ทันถึง build() ด้วยซ้ำ ถ้าคุณตั้งใจจะได้ runtime thread เดียว สิ่งที่คุณต้องการคือ new_current_thread() ไม่ใช่ worker_threads(0)
runtime คือคนผลิต Waker และ Context ให้คุณ
หัวข้อที่มีชื่อว่า “runtime คือคนผลิต Waker และ Context ให้คุณ”ย้อนกลับไปที่บท 1 อีกรอบ แล้วถามคำถามที่ตอนนั้นเรายังตอบไม่ครบ: cx ที่ถูกส่งเข้า poll มาจากไหน?
ในบท 1 คำตอบคือ เราสร้างมันเอง — Arc::new(ThreadWaker(thread::current())).into() แล้วห่อด้วย Context::from_waker(&waker) ใน block_on ของเราเอง พอมาถึงบทนี้ คำตอบเปลี่ยนเจ้าของแต่ไม่เปลี่ยนรูป: runtime คือคนผลิต Waker และห่อมันเป็น Context ให้ทุกครั้งที่มัน poll task ของคุณ waker ที่ Tokio ใส่มาให้ไม่ได้ไป unpark thread เหมือนของเรา แต่ไป ยัด task ตัวนั้นกลับเข้าคิวของ scheduler ให้ถูก poll รอบใหม่ — ซึ่งเป็นเหตุผลว่าทำไมมันหมุน task ได้เป็นแสนตัวขณะที่ของเราหมุนได้ตัวเดียว model pull ของบท 1 ไม่ได้หายไปไหนเลย มันแค่ถูกทำให้ใหญ่ขึ้น
ทีนี้ ถ้า Waker/Context ถูกผลิตโดย runtime แปลว่า code ที่จะเรียก API ของ Tokio ได้ต้อง “อยู่ใน” runtime ตัวใดตัวหนึ่งเสมอ — นั่นคือสิ่งที่ข้อความ “A Tokio 1.x context was found” กำลังพูดถึง และ Tokio ให้เครื่องมือสองชิ้นสำหรับจัดการเรื่องนี้จากโลก sync:
use tokio::runtime::{Builder, Handle};
fn main() { let rt = Builder::new_multi_thread() .worker_threads(2) .enable_all() .build() .unwrap();
// handle() = ตั๋วอ้างถึง runtime ที่ clone ข้าม thread ได้ let handle: &Handle = rt.handle(); let h = handle.spawn(async { 1 + 1 }); println!("handle.spawn -> {}", rt.block_on(h).unwrap());
// enter() ผูก "runtime context" เข้ากับ thread ปัจจุบัน โดยยัง *ไม่* รันอะไร { let _guard = rt.enter(); let task = tokio::spawn(async { "spawned from a sync fn" }); println!("inside enter() -> {}", rt.block_on(task).unwrap()); }
rt.shutdown_timeout(std::time::Duration::from_secs(1)); println!("runtime shut down");}handle.spawn -> 2inside enter() -> spawned from a sync fnruntime shut downrt.handle()คืน&Handle— ตั๋วอ้างอิงถึง runtime ที่clone()แล้วส่งข้าม thread ได้ ใช้handle.spawn(...)โยนงานเข้า runtime จากที่ไหนก็ได้rt.enter()คืนEnterGuardที่ผูก runtime context เข้ากับ thread ปัจจุบันตราบเท่าที่ guard ยังมีชีวิต ทำให้tokio::spawnเปล่าๆ (ที่ปกติต้องเรียกจากในโลก async) ใช้ได้จาก function sync — แต่ย้ำว่าenter()ไม่ได้รันอะไรทั้งนั้น มันแค่บอกว่า “ถ้ามีใครถามหา runtime ตอนนี้ ให้ตอบว่าตัวนี้”rt.shutdown_timeout(Duration)กินselfไปเลย คือปิด runtime แล้วรอ task ที่ยังค้างอยู่ไม่เกินเวลาที่กำหนด — นี่คือเมล็ดพันธุ์ของ graceful shutdown ที่บท 8 จะปลูกเต็มต้น
แผนที่คำศัพท์จาก C#/.NET
หัวข้อที่มีชื่อว่า “แผนที่คำศัพท์จาก C#/.NET”| C# / .NET | Tokio | จุดที่ต่างจริง |
|---|---|---|
CLR ThreadPool + TaskScheduler เป็น ambient มากับ process | Runtime เป็นออบเจ็กต์ที่คุณสร้างและถือเอง | คุณเลือกจำนวน thread เลือก driver ที่เปิด และมัน ตาย เมื่อถูก drop |
entry point ที่ compiler สร้างให้ async Task Main() | #[tokio::main] | ท่าเดียวกัน แต่ Tokio เปิดฝาให้ลอกออกเป็น Builder + block_on ด้วยมือ |
.Result / .Wait() / GetAwaiter().GetResult() | block_on | ประตูบานเดียวกัน แต่เรียกผิดที่แล้ว C# deadlock เงียบ ส่วน Tokio panic พร้อมประโยคอธิบาย |
| default ThreadPool (work-stealing อยู่แล้ว) | flavor multi_thread | Tokio ให้คุณตั้ง worker_threads และ thread_name ต่อ runtime ไม่ใช่ต่อ process |
| single-thread SynchronizationContext แบบ UI thread | flavor current_thread | ของ Tokio เดินเฉพาะตอนอยู่ใน block_on ออกมาแล้ว task ที่ค้างหยุดนิ่ง |
ThreadPool.SetMinThreads / SetMaxThreads | Builder::worker_threads | scope แคบกว่ามาก คุมเฉพาะ async worker ของ runtime ตัวนั้น |
| ไม่มีของเทียบ — I/O มากับ framework เสมอ | enable_io / enable_time | ใน Tokio ลืมเปิดแล้ว panic ตอนรัน ไม่ใช่ตอน compile |
แถวสุดท้ายไม่มีคู่เทียบใน .NET เพราะแนวคิดมันไม่มีอยู่ตรงนั้น — และแถวนั้นแหละคือแถวที่จะกัดคุณจริง
runtime เป็นออบเจ็กต์ ไม่ใช่บริการที่มีอยู่แล้ว: ทุกบรรทัด .await ในโปรแกรมคุณต้องการ runtime สักตัวที่กำลังขับมันอยู่ ถ้าไม่มี มันจะไม่รัน — และถ้ามีแต่ประกอบมาไม่ครบ (ลืม enable_io/enable_time) มันจะ panic ตอนรัน ไม่มี compiler ตัวไหนกันคุณตรงนี้ได้ นี่คือราคาที่จ่ายแลกกับการที่ std ไม่ยัด runtime มาให้
สิ่งที่บทนี้ยังไม่ได้แตะเลย: เรายังไม่ได้ spawn งานหลายตัวจริงจัง ยังไม่ได้แตะ socket สักตัว (เพราะ "net" ยังไม่เปิด) และยังไม่ได้พูดถึงว่าเกิดอะไรขึ้นถ้า task ตัว1 panic ระหว่างทาง สามเรื่องนี้เป็นของบท 3 กับบท 4
ตัวเลข 8 package ในกล่อง cargo ข้างบนคือ Cargo.lock ของ แซนด์บ็อกซ์นี้ ณ วันที่เขียน ไม่ใช่ค่าคงที่ของจักรวาล — ถ้าคุณเปิด feature เพิ่ม (หรือ tokio ออกรุ่นใหม่) มันเปลี่ยนได้ วิธีเช็กของคุณเองคือ cargo tree ประเด็นที่ตัวเลขนี้พิสูจน์คือ สัดส่วน ไม่ใช่ตัวเลข: "net" เพียง feature เดียวลากของเพิ่มเข้ามาอีกเกือบเท่าตัว และ "full" ลากทุกอย่าง
เลข pid/thread-id ในหัว panic ก็เป็นของเฉพาะรอบที่รันเหมือนกัน: (83010) · (83112) · (83182) · (84624) ที่เห็นข้างบนคือเลขจริงของรอบที่เราสั่งรันในแซนด์บ็อกซ์นี้ คุณรันเองแล้วจะได้คนละเลขทุกครั้ง สิ่งที่ต้องอ่านคือ ข้อความ กับ ชื่อ file:บรรทัด:column ไม่ใช่ตัวเลขในวงเล็บ
คำเตือนซ้ำจากบท 1 ที่ยังใช้ได้: ทั้งคอร์สนี้ตรวจด้วย recipe เดียวคือ build/run/clippy บน target native x86_64-unknown-linux-gnu กลเม็ด musl + rust-lld ที่ #22/#23 ใช้ได้เพราะเป็น zero-crate ปลดระวางไปแล้วทั้งคอร์ส เพราะ tokio ลาก mio/libc/socket2 ซึ่งเป็น native dependency จริงเข้ามา
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”Tokio runtime คือออบเจ็กต์เดียวที่ห่อสามบริการไว้: task scheduler ที่ทำหน้าที่ executor, I/O driver หรือ reactor ที่นั่งบน epoll ผ่าน mio และ timer — และมันเป็นของที่ คุณสร้าง ไม่ใช่ของที่มีอยู่แล้วแบบ CLR ThreadPool; #[tokio::main] เป็น sugar ล้วนที่ลอกออกเป็น Builder::new_multi_thread().enable_all().build().unwrap().block_on(...) ได้ตรงๆ ซึ่งเป็นรูปที่ capstone จะใช้; 2 flavor คือ current_thread (feature "rt", เดินเฉพาะตอนอยู่ใน block_on) กับ multi_thread (feature "rt-multi-thread", work-stealing) และ "macros" ไม่ได้พา scheduler มาให้เลย ซึ่งพิสูจน์แล้วด้วย error จริงที่ชี้ตรงไปที่ rt-multi-thread; block_on คือประตูบานเดียวจาก sync สู่ async และเรียกซ้อนใน async context แล้ว panic ว่า Cannot start a runtime from within a runtime. — ต่างจาก C# ที่ deadlock เงียบ; driver ไม่ได้เปิดมาให้เอง ลืม enable_time/enable_io แล้วได้ panic ตอนรันที่ขึ้นต้นด้วย A Tokio 1.x context was found, but …; และ Handle กับ enter() คือทางที่โลก sync ใช้เอื้อมเข้าไปหา runtime ทั้งหมดนี้ compile zero-warnings ผ่าน cargo clippy -- -D warnings และรันได้ output ตามที่เห็นทุกบรรทัด — ยกเว้น snippet ที่ติดป้าย ❌ ซึ่งตั้งใจให้พัง และเราลง error หรือ panic จริงของมันไว้แทน
บท 3 เราจะเริ่มใช้ runtime ตัวนี้จริงๆ: tokio::spawn สร้าง task ที่กินหน่วยความจำราว 64 byte จนโยนเป็นล้านตัวได้ แต่มันเปลี่ยน future จาก lazy เป็น eager ทันทีที่ spawn — และ JoinHandle ที่ได้กลับมาก็ไม่ได้คืน T ให้ตรงๆ แบบ Task<T> ของ C# มันคืน Result ที่คุณต้องตรวจเอง
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- tokio 1.53.1 บน crates.io API (เข้าถึง 2026-07-27) — รุ่นนี้ปล่อย 2026-07-20 ไม่ถูก yank และ MSRV คือ 1.71 · feature graph ที่เป็นแกนของบทนี้:
rt = [],rt-multi-thread = ["rt"],macros = ["tokio-macros"]— จึงยืนยันว่า"macros"ไม่ได้ดึง scheduler ตัวใดมาให้ #[tokio::main]— docs.rs/tokio/1.53.1 (เข้าถึง 2026-07-27) — attribute ต้องการ"macros"+"rt", default flavor คือ multi_thread ซึ่งต้องการ"rt-multi-thread"เพิ่ม และหน้านี้เขียน expansion เป็นBuilder::new_multi_thread().enable_all().build().unwrap().block_on(...)ไว้ตรงๆtokio::runtime::Runtime— docs.rs/tokio/1.53.1 (เข้าถึง 2026-07-27) —Runtime::new()เท่ากับ multi-thread scheduler + เปิด driver ทุกตัว (gatert-multi-thread) ·block_onpanic ทั้งกรณี future panic และกรณีถูกเรียกจากใน async execution context ·enter(),handle(),shutdown_timeouttokio::runtime::Builder— docs.rs/tokio/1.53.1 (เข้าถึง 2026-07-27) —new_current_thread/new_multi_thread/worker_threads(default = จำนวน CPU core, ตั้งเป็น 0 แล้ว panic, มีผลเฉพาะ multi_thread) /enable_all/enable_io/enable_time/build- Making the Tokio scheduler 10x faster — tokio.rs, 2019-10-13 (เข้าถึง 2026-07-27) — ที่มาของคำว่า work-stealing ใน flavor multi_thread: worker แต่ละตัวมี local run queue ของตัวเอง มี global queue ร่วม และ worker ที่ว่างจะไปขโมยงานจากเพื่อน
- Bridging with sync code — tokio.rs, 2024-01-01 (เข้าถึง 2026-07-27) — คู่มือฝั่งที่บอกว่าการเชื่อมโลก sync เข้าโลก async ควรใช้
current_threadเพราะมันไม่มี background thread และเดินเฉพาะตอนอยู่ในblock_on
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3Cargo.toml ประกาศ tokio ด้วย features = [rt, macros] แล้ว code เขียน #[tokio::main] เปล่าๆ ผลที่ได้จริงคืออะไร และเพราะอะไร?