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

รู้จัก 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 เริ่ม โดยที่​คุณ​ไม่​ต้อง​ขอ

📦 kaen-kvstore

คอร์ส​นี้ ต่อยอด 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

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

ทุก 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 ครึ่ง​หนึ่ง​ของ​บท​นี้​อ่าน​ออก​ทันที:

  1. task scheduler — ตัว​ที่​ทำ​หน้าที่ executor ใน​ความหมาย​ของ​บท 1 คือ​คน​ที่​เดิน​มา poll future ซ้ำๆ ต่าง​กัน​ตรง​ที่​ของ Tokio หมุน task ได้​เป็น​แสน​ตัว​พร้อม​กัน ไม่ใช่​ตัว​เดียว
  2. I/O driver หรือ​ที่​เรียก​กัน​ว่า reactorreactorตัว​ฟัง​เหตุการณ์ I/O (epoll ผ่าน mio) ที่​ปลุก task เมื่อ socket พร้อม — ตัว​ที่นั่ง​อยู่​บน epoll ของ Linux ผ่าน crate ชื่อ mio คอย​ลง​ทะเบียน socket ทุก​ตัว​ไว้ แล้ว​เมื่อ kernel บอกว่า fd ตัว​ไหน​อ่าน​ได้ มัน​จะ​ไป​เรียก wake() ของ task ที่นอน​รอ fd นั้น​อยู่ — คือ handshake Waker ตัว​เดียว​กับ​บท 1 เป๊ะๆ เพียง​แต่​คน​กด​ปุ่ม​ปลุก​เปลี่ยน​จาก thread::unpark มา​เป็น kernel
  3. 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 เอา​ตอน​รัน — เรื่อง​นี้​กิน​สอง​หัวข้อ​ข้าง​ล่าง

เริ่ม​จาก​รูป​ที่​คุณ​เห็น​ใน​ทุก README ของ​โลก Rust:

#[tokio::main]
async fn main() {
println!("hello from async main");
println!("flavor = multi_thread (default)");
}
hello from async main
flavor = 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 -> 42
multi_thread task ran on thread -> kaen-worker
Runtime::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_thread
  • enable_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_threadmulti_thread
feature ที่​ต้อง​เปิด"rt""rt-multi-thread"
thread ที่​ใช้thread ที่​เรียก block_on เท่านั้นworker pool ตาม​จำนวน core
เดิน​ตอน​ไหนเฉพาะ​ตอน​อยู่​ใน block_on ออก​จาก block_on แล้ว task ที่​ค้าง​อยู่​หยุด​นิ่งเดิน​ตลอด​เวลา​เบื้องหลัง
algorithmคิว​เดียวwork-stealing — worker แต่ละ​ตัว​มี​คิว​ของ​ตัวเอง ใคร​ว่าง​ไป​ขโมย​งาน​เพื่อน
เทียบ .NETsingle-thread SynchronizationContext แบบ UI threaddefault 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 คือ​จุด​เชื่อม​ระหว่าง​โลก 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 0
note: 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)

ย้อน​กลับ​ไป​ที่​บท 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 -> 2
inside enter() -> spawned from a sync fn
runtime shut down
  • rt.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# / .NETTokioจุด​ที่​ต่าง​จริง
CLR ThreadPool + TaskScheduler เป็น ambient มา​กับ processRuntime เป็นออบ​เจ็กต์ที่​คุณ​สร้าง​และ​ถือ​เองคุณ​เลือก​จำนวน 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_threadTokio ให้​คุณ​ตั้ง worker_threads และ thread_name ต่อ runtime ไม่ใช่​ต่อ process
single-thread SynchronizationContext แบบ UI threadflavor current_threadของ Tokio เดิน​เฉพาะ​ตอน​อยู่​ใน block_on ออก​มา​แล้ว task ที่​ค้าง​หยุด​นิ่ง
ThreadPool.SetMinThreads / SetMaxThreadsBuilder::worker_threadsscope แคบ​กว่า​มาก คุม​เฉพาะ async worker ของ runtime ตัว​นั้น
ไม่มี​ของ​เทียบ — I/O มา​กับ framework เสมอenable_io / enable_timeใน Tokio ลืม​เปิด​แล้ว panic ตอน​รัน ไม่ใช่​ตอน compile

แถว​สุดท้าย​ไม่มี​คู่​เทียบ​ใน .NET เพราะ​แนวคิด​มัน​ไม่มี​อยู่​ตรง​นั้น — และ​แถว​นั้น​แหละ​คือ​แถว​ที่​จะ​กัด​คุณ​จริง

honesty spine — สิ่ง​ที่​บท​นี้​ยัง​ไม่​ได้​ให้

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 ทุก​ตัว (gate rt-multi-thread) · block_on panic ทั้ง​กรณี future panic และ​กรณี​ถูก​เรียก​จาก​ใน async execution context · enter(), handle(), shutdown_timeout
  • tokio::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 / 3

Cargo.toml ประกาศ tokio ด้วย features = [rt, macros] แล้ว code เขียน #[tokio::main] เปล่าๆ ผลที่ได้จริงคืออะไร และเพราะอะไร?