task และ spawn — green thread 64 byte ที่ spawn เป็นล้านตัวได้
บท 2 จบลงตรงที่คุณประกอบ Runtime ขึ้นมาได้เองด้วยมือแล้ว — มี scheduler มี I/O driver มี timer ครบ แต่ runtime ที่ไม่มีงานให้ทำก็เป็นแค่ loop ว่างๆ บทนี้คือบทที่เราเริ่มโยนงานเข้าไปจริงๆ และหน่วยของงานที่ Tokio รู้จักมีชื่อเดียวคือ task
ถ้าคุณมาจาก kaen-kvstore ของ #22 หน่วยงานที่คุ้นมือคือ OS thread — ThreadPool ขนาดคงที่ 4 worker ที่เขียนเอง โดยหนึ่งคอนเนกชัน ยึด worker หนึ่งตัวไว้ตลอดอายุสาย แต่ละ worker ขอ stack จาก kernel เป็นเมกะ byte การสลับระหว่างมันต้องผ่าน kernel ทุกครั้ง และที่แพงที่สุดคือ worker ที่กำลัง block รอ byte อยู่ก็ยังกิน stack ก้อนนั้นเต็มๆ ทั้งที่ไม่ได้ทำงานอะไรเลย เอกสารของ Tokio เรียก task ว่า green thread ที่มีต้นทุน “a single allocation and 64 bytes of memory” — ตัวเลขนั้นคือทั้งหมดที่คุณต้องรู้ว่าทำไม model ถึงเปลี่ยน: ของที่เมื่อก่อนคุณ spawn ได้หลักพันแล้วเริ่มเจ็บ ตอนนี้ spawn ได้หลักล้านโดยที่ยังไม่รู้สึกอะไร
และมีความจริงข้อหนึ่งในบทนี้ที่จะ กลับหัว สิ่งที่บท 1 สอนคุณมา: future ของ Rust ขี้เกียจก็จริง แต่ tokio::spawn ทำให้มันขยันทันที ตั้งแต่วินาทีที่คุณเรียก — ยังไม่ต้อง .await อะไรเลยด้วยซ้ำ
คอร์สนี้ ต่อยอด repo kaen-kvstore จาก #22 (code ตัวอย่างกำลังจัดทำ) บทนี้คือบทที่ปลด ThreadPool ที่คุณเขียนเองใน #22 ออกจากตำแหน่ง — worker.join() กับ Arc<Mutex<Receiver<Job>>> ทั้งชุดถูกแทนด้วย tokio::spawn บรรทัดเดียว สิ่งที่วางในบทนี้ (task · spawn · JoinHandle · spawn_blocking · join!) คือหน่วยงานที่บท 4 จะเอาไปแปะกับหนึ่งคอนเนกชันต่อ1 task และบท 8 จะเอาไปนับตอน drain ระหว่าง graceful shutdown
ทุก snippet pin ที่ rustc 1.97.1 (8bab26f4f 2026-07-14) · edition = “2024” · tokio 1.53.1 และ [dependencies] ของบทนี้คือบรรทัดนี้เท่านั้น:
[dependencies]tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "time"] }ห้าม features = ["full"] เด็ดขาดทั้งคอร์ส ด้วย scope สามตัวนี้ยังมี dependency ที่ compile จริงบน Linux แค่ 7 crate เท่าเดิมกับบท 1 (tokio, tokio-macros 2.7.1, pin-project-lite 0.2.17 + proc-macro2/quote/syn/unicode-ident) — ถ้านับเป็นรายการใน Cargo.lock จะได้ 8 เพราะมันนับ crate ของเราเองด้วย ซึ่งเป็นฐานนับที่บท 2 ใช้ — ยังไม่มี mio/socket2/libc เพราะเรายังไม่เปิด "net" แม้แต่ "rt-multi-thread" กับ "time" ก็ไม่ได้ลากอะไรเพิ่มเข้ามา
กับดัก feature-gate สามข้อที่บทนี้แตะครบ: "rt" ให้ scheduler thread เดียว · "rt-multi-thread" ให้ scheduler หลาย thread ซึ่งเป็น ค่า default ของ #[tokio::main] เปล่าๆ · และ "macros" ไม่ได้พา scheduler ตัวไหนมาเลย มันแจกแค่ตัวมาโคร ซึ่งรวมถึง join! กับ try_join! ของบทนี้ด้วย เราจงใจถอด "macros" ออกให้เห็น error จริงก่อนจบบท
ตรวจทุก snippet ด้วย recipe เดียวของทั้งคอร์ส คือ target native x86_64-unknown-linux-gnu:
export PATH="$HOME/.cargo/bin:$PATH"cargo build --target x86_64-unknown-linux-gnucargo run --target x86_64-unknown-linux-gnucargo test --target x86_64-unknown-linux-gnucargo clippy --target x86_64-unknown-linux-gnu -- -D warningsทุก snippet ผ่านครบทั้งสี่คำสั่ง ยกเว้น snippet ที่ติดป้าย ❌ ซึ่งตั้งใจให้พัง — ของพวกนั้นเรา compile แล้วลอกข้อความ error จริงมาแสดงแทน ทุกบรรทัด output ในบทนี้คือข้อความจริงที่พิมพ์ออกมา ไม่ใช่ของที่เขียนขึ้นเอง
spawn คือจุดที่ future เลิกขี้เกียจ
หัวข้อที่มีชื่อว่า “spawn คือจุดที่ future เลิกขี้เกียจ”บท 1 พิสูจน์ให้เห็นกับตาว่า async { … } ไม่ได้เริ่มอะไรเลย มันแค่สร้าง state machine ขึ้นมาแล้วนิ่งสนิท จนกว่าจะมีคน poll แต่ spawnspawnส่ง future ให้ runtime รันเป็น task ทันที คืน `JoinHandle` เป็นข้อยกเว้นที่ต้องจำแยก: มันไม่ได้ รอ ให้คุณ .await — มันเอา future ตัวนั้นยัดใส่คิวของ scheduler เดี๋ยวนั้น แล้วคืน JoinHandleJoinHandleแฮนเดิลของ task; `.await` ได้ `Result<T, JoinError>`; drop = detach ไม่ใช่ cancel มาให้คุณถือ ส่วน worker thread ก็เริ่มหมุน poll ให้เองเบื้องหลังทันที
หน่วยงานที่เกิดขึ้นจาก spawn เรียกว่า tasktaskgreen thread ของ Tokio ~64 byte/1 allocation สร้างด้วย `spawn` — future หนึ่งตัว + ที่อยู่ในคิวของ scheduler + สถานะการรัน รวมกันเป็นก้อนเดียว
เริ่มจาก file src/main.rs หัว file ทั้งหมดที่บทนี้ต้องใช้มีแค่นี้ (ทุก snippet ในหัวข้อ 1–7 ต่อท้ายกันลงใน file เดียวนี้ ภายใต้ async fn main ตัวเดียว):
use std::sync::Arc;use std::sync::atomic::{AtomicU32, Ordering};use std::time::Duration;
async fn a() -> i32 { 1 }async fn b() -> &'static str { "two" }async fn ok1() -> Result<i32, String> { Ok(1) }async fn fails() -> Result<i32, String> { Err("bad".into()) }
#[tokio::main]async fn main() { // หัวข้อ 1–7 ต่อกันตรงนี้}หัวข้อแรก — พิสูจน์ว่า task เริ่มรันก่อนที่ใครจะ .await มัน:
// --- 1. spawn = lazy -> eager ------------------------------------- println!("[1] before spawn"); let handle = tokio::spawn(async { println!("[2] task is ALREADY running, nobody awaited the handle yet"); 1 + 2 }); tokio::time::sleep(Duration::from_millis(50)).await; println!("[3] main is only now about to await the handle"); let out: i32 = handle.await.unwrap(); println!("[4] handle.await -> {out}");รันจริงได้ผลตามนี้:
[1] before spawn[2] task is ALREADY running, nobody awaited the handle yet[3] main is only now about to await the handle[4] handle.await -> 3[2] พิมพ์ออกมา ก่อน [3] ทั้งที่ตอนนั้น handle ยังนอนอยู่ในตัวแปรเฉยๆ ยังไม่มีใครแตะ นี่คือภาพกลับหัวจากบท 1 พอดี: ที่นั่นบรรทัดใน async block ไม่ยอมรันจนกว่า block_on จะ poll ที่นี่มันรันไปแล้วโดยไม่ต้องขออนุญาตใคร
ให้จำเป็นกฎสองข้อคู่กันจะไม่สับสน: future เปล่าๆ ขี้เกียจ · task ขยัน — spawn คือเส้นแบ่งระหว่างสองสถานะนั้น และสำหรับคนที่มาจาก C# นี่คือจุดที่สัญชาตญาณเดิมกลับมาใช้ได้พอดี เพราะ Task.Run(() => …) ก็ส่งงานเข้า ThreadPool ทันทีเหมือนกัน
.await คือการ รอผล ไม่ใช่การ สั่งเริ่ม — สิ่งที่สั่งเริ่มคือ spawn และมันสั่งไปเรียบร้อยแล้วตั้งแต่บรรทัดที่คุณเรียก
await JoinHandle แล้วได้ Result ไม่ใช่ T
หัวข้อที่มีชื่อว่า “await JoinHandle แล้วได้ Result ไม่ใช่ T”นี่คือจุดที่ .NET dev สะดุดหนักที่สุดในบทนี้ signature ของ spawn เป็นแบบนี้เป๊ะ:
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>where F: Future + Send + 'static, F::Output: Send + 'static,JoinHandle<T> เองก็เป็น future ตัวหนึ่ง — แต่ Output ของมัน ไม่ใช่ T:
impl<T> Future for JoinHandle<T> { type Output = Result<T, JoinError>;}Result ตรงนั้นไม่ใช่ของประดับ มันคือช่องทางเดียวที่คุณจะรู้ว่า task ตายไปแล้วหรือยัง เพราะ panic ที่เกิดข้างใน task ไม่ล้มโปรแกรม และไม่ถูก re-throw ให้คุณ มันถูกจับไว้แล้วแปลงเป็น Err(JoinError) เงียบๆ:
// --- 2. panic becomes Err ------------------------------------------ let handle = tokio::spawn(async { panic!("boom"); }); match handle.await { Ok(()) => println!("ok"), Err(e) if e.is_panic() => println!("[5] task panicked, is_panic = {}", e.is_panic()), Err(e) if e.is_cancelled() => println!("[5] task aborted, is_cancelled = {}", e.is_cancelled()), Err(_) => unreachable!(), } println!("[6] main is still running after the task panicked");stdout ที่ได้:
[5] task panicked, is_panic = true[6] main is still running after the task panickedส่วนบน stderr มีข้อความ panic โผล่มาให้เห็น (เลข thread id ในวงเล็บเปลี่ยนทุกรอบที่รัน):
thread 'tokio-rt-worker' (19289) panicked at src/main.rs:25:9:boomnote: run with `RUST_BACKTRACE=1` environment variable to display a backtraceอ่านสองก้อนนี้คู่กันแล้วจะเห็นภาพชัด: runtime พิมพ์ panic ให้ดูก็จริง แต่ process ไม่ได้ตาย และ main เดินต่อไปที่บรรทัด [6] ตามปกติ ถ้าคุณเขียน handle.await.unwrap() แทนที่จะ match ตัว unwrap() ต่างหากที่จะทำให้ panic ลามมาถึงคุณ — และถ้าคุณ ไม่ await เลย panic ก้อนนั้นก็หายไปกับสายลม เหลือแค่บรรทัดบน stderr ที่ไม่มีใครอ่าน
เทียบกับ C# ตรงนี้คือความต่างที่ต้องถอดสัญชาตญาณเก่าทิ้ง: await someFaultedTask ใน .NET จะ re-throw exception ตัวเดิม ขึ้นมาที่จุด await โดยอัตโนมัติ คุณเลยเขียน try/catch ครอบแล้วจบ ส่วนใน Rust compiler จะบังคับให้คุณ เห็น Result แต่ไม่บังคับให้คุณ ทำอะไร กับมัน — let _ = handle.await; compile ผ่านสบายมาก
JoinError มีสาเหตุได้แค่สองอย่างเท่านั้น และแยกด้วย2 method นี้:
is_panic()— task panic ระหว่างทาง · ถ้าอยากได้ payload จริงคืนinto_panic()ที่คืนBox<dyn Any + Send + 'static>มาให้ downcast เองis_cancelled()— task ถูกabort()หรือถูก runtime ล้มตอน shutdown
drop คือ detach ไม่ใช่ cancel — abort() ต่างหากที่ยกเลิก
หัวข้อที่มีชื่อว่า “drop คือ detach ไม่ใช่ cancel — abort() ต่างหากที่ยกเลิก”กับดักถัดไปดักคนที่เพิ่ง เข้าใจ Rust ดีเสียด้วยซ้ำ เพราะสัญชาตญาณ RAII บอกว่า “drop แล้วของหาย” และในโลก async ของ Rust การ drop future = cancel ก็จริงตามนั้น (บท 5 จะพูดเรื่องนี้เต็มๆ) แต่ JoinHandle ไม่ใช่ future ตัวนั้น มันเป็นแค่ แฮนเดิล ที่ชี้ไปหา task ที่นั่งอยู่ในคิว scheduler แล้ว การทิ้งแฮนเดิลจึงเท่ากับ detach — task รันต่อจนจบ แต่ผลลัพธ์ไม่มีใครรับ
// --- 3. drop = detach, not cancel ---------------------------------- let counter = Arc::new(AtomicU32::new(0)); let c = Arc::clone(&counter); let detached = tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(20)).await; c.fetch_add(1, Ordering::SeqCst); }); drop(detached); tokio::time::sleep(Duration::from_millis(100)).await; println!("[7] after dropping the handle, counter = {}", counter.load(Ordering::SeqCst));
// --- 4. abort = cancel --------------------------------------------- let c = Arc::clone(&counter); let aborted = tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(3600)).await; c.fetch_add(100, Ordering::SeqCst); }); aborted.abort(); match aborted.await { Err(e) if e.is_cancelled() => println!("[8] abort -> is_cancelled = {}", e.is_cancelled()), _ => unreachable!(), } println!("[9] after abort, counter = {}", counter.load(Ordering::SeqCst));[7] after dropping the handle, counter = 1[8] abort -> is_cancelled = true[9] after abort, counter = 1สามบรรทัดนี้ตอบครบทั้งสองฝั่ง: [7] บอกว่า counter ขึ้นเป็น 1 ทั้งที่เรา drop(detached) ไปแล้ว — task รันต่อจริงๆ ส่วน [8] กับ [9] บอกว่า task ที่โดน abort() ตายก่อนแตะ counter (ไม่งั้นเลขต้องเป็น 101) และ await แฮนเดิลนั้นคืน Err ที่ is_cancelled() เป็นจริง
สังเกตว่า abort(&self) รับ &self ไม่ใช่ self เราจึงยัง await แฮนเดิลตัวเดิมต่อได้หลังสั่ง abort เพื่อ ยืนยัน ว่ามันตายแล้วจริง และมีข้อยกเว้นข้อเดียวที่ต้องรู้ไว้: เอกสาร spawn_blocking ระบุชัดว่า abort() ไม่มีผล ถ้างาน blocking เริ่มรันไปแล้ว เพราะไม่มีจุด .await ให้ runtime แทรกเข้าไปหยุดมันได้
❌ version ดิบ: ยัดของที่ไม่ Send เข้าไปใน task
หัวข้อที่มีชื่อว่า “❌ version ดิบ: ยัดของที่ไม่ Send เข้าไปใน task”Send + 'static ใน signature ของ spawn เป็นข้อกำหนดที่ กัด จริง ลองเขียน code ที่ดูไร้พิษภัยแบบนี้ลงใน src/bin/red_send2.rs:
use std::rc::Rc;use std::time::Duration;
async fn work() -> usize { let cache = Rc::new(vec![1, 2, 3]); tokio::time::sleep(Duration::from_millis(10)).await; cache.len()}
#[tokio::main]async fn main() { let handle = tokio::spawn(work()); println!("{}", handle.await.unwrap());}cargo build --target x86_64-unknown-linux-gnu --bin red_send2 ตอบกลับมาแบบนี้ (ตัด note required by a bound in tokio::spawn ที่ท้ายสุดออก เพราะเป็น path ในเครื่อง):
error: future cannot be sent between threads safely --> src/bin/red_send2.rs:12:31 | 12 | let handle = tokio::spawn(work()); | ^^^^^^ future returned by `work` is not `Send` | = help: within `impl Future<Output = usize>`, the trait `Send` is not implemented for `Rc<Vec<i32>>`note: future is not `Send` as this value is used across an await --> src/bin/red_send2.rs:6:51 | 5 | let cache = Rc::new(vec![1, 2, 3]); | ----- has type `Rc<Vec<i32>>` which is not `Send` 6 | tokio::time::sleep(Duration::from_millis(10)).await; | ^^^^^ await occurs here, with `cache` maybe used laterสังเกตสองอย่างที่คนมักจำผิด ข้อแรก: error ก้อนนี้ไม่มีรหัส ไม่ใช่ error[E0277] แต่เป็น error: เปล่าๆ ที่ rustc 1.97.1 ออกแบบข้อความไว้เฉพาะสำหรับกรณีนี้ ข้อที่สอง: บรรทัดที่มีค่าที่สุดคือ future is not Send as this value is used across an await — compiler ไม่ได้บ่นว่า Rc ไม่ดี มันบ่นว่า Rc ยังมีชีวิตอยู่คร่อมจุด .await
เหตุผลอยู่ที่บท 1 พูดไว้แล้วเรื่อง state machine: ตัวแปรที่มีชีวิตข้าม .await จะกลายเป็น field ของ struct ที่ compiler สร้าง และ Tokio ย้าย task ข้าม worker thread ได้ทุกจุดที่มัน suspend ฉะนั้นทั้ง struct ต้อง Send Rc เป็นตัว refcount แบบไม่ atomic จึงตกทันที (Arc ต่างกันตรงนี้จุดเดียว)
ทางแก้ตรงไปตรงมา — src/bin/green_send.rs:
use std::sync::Arc;use std::time::Duration;
async fn work(cache: Arc<Vec<i32>>) -> usize { tokio::time::sleep(Duration::from_millis(10)).await; cache.len()}
#[tokio::main]async fn main() { let cache = Arc::new(vec![1, 2, 3]); let handle = tokio::spawn(work(Arc::clone(&cache))); println!("green: len = {}", handle.await.unwrap());}green: len = 3ส่วน 'static เป็นกับดักคู่แฝดที่เจอบ่อยกว่าเสียอีก เพราะ task อาจมีอายุยืนกว่า function ที่ spawn มัน มันจึงยืม local ไม่ได้เลย file src/bin/red_static.rs ที่ลืมใส่ move:
#[tokio::main]async fn main() { let name = String::from("kaen"); let handle = tokio::spawn(async { println!("{}", name.len()); }); handle.await.unwrap();}error[E0373]: async block may outlive the current function, but it borrows `name`, which is owned by the current function --> src/bin/red_static.rs:4:31 |4 | let handle = tokio::spawn(async { | ^^^^^ may outlive borrowed value `name`5 | println!("{}", name.len()); | ---- `name` is borrowed here | = note: async blocks are not executed immediately and must either take a reference or ownership of outside variables they usehelp: to force the async block to take ownership of `name` (and any other referenced variables), use the `move` keyword |4 | let handle = tokio::spawn(async move { | ++++อันนี้มีรหัส E0373 และ compiler ใบ้คำตอบมาให้ครบ: เติม move แล้วเขียว บรรทัด note ที่ว่า “async blocks are not executed immediately” คือธีมของบท 1 ที่กลับมาปรากฏในข้อความ error ของจริง
ประตูหนีไฟชื่อ spawn_blocking
หัวข้อที่มีชื่อว่า “ประตูหนีไฟชื่อ spawn_blocking”ก่อนจะไปหัวข้อถัดไปที่แสดงให้เห็นว่างาน blocking ทำลาย concurrency ได้อย่างไร ต้องรู้จักประตูหนีไฟบานนี้ก่อน: ถ้ามีงานที่ ต้อง block จริงๆ (คำนวณหนัก, เรียก C library ที่ไม่มี async API, อ่าน file ด้วย std::fs) อย่ายัดมันลง task ธรรมดา — ย้ายไป spawn-blockingspawn-blockingย้ายงาน blocking/CPU ไป blocking pool แยก (default cap ~512 threads) ที่ Tokio มี pool แยกไว้ให้ต่างหาก โดยมี max_blocking_threads ค่า default = 512 ตามเอกสาร runtime::Builder (ต่างจาก worker_threads ที่ default = จำนวน core)
spawn_blocking รับ FnOnce() -> R (closure ธรรมดา ไม่ใช่ future) จึงเขียน .await ข้างในไม่ได้ แต่ตัวมันเองคืน JoinHandle<R> ที่ await ได้ตามปกติ นี่คือหัวข้อ 5 ที่ต่อลงใน src/main.rs และเป็นที่มาของบรรทัด [10]:
// --- 5. spawn_blocking --------------------------------------------- let bytes: u64 = tokio::task::spawn_blocking(|| { std::thread::sleep(Duration::from_millis(50)); 42 }) .await .unwrap(); println!("[10] spawn_blocking -> {bytes}");concurrency ไม่ใช่ parallelism: join! แทรกบน task เดียว
หัวข้อที่มีชื่อว่า “concurrency ไม่ใช่ parallelism: join! แทรกบน task เดียว”join! เป็นมาโครที่หน้าตาเหมือน Task.WhenAll มากจนคนใช้ผิดกันประจำ มันรับ future หลายตัวแล้วคืน tuple ของผลลัพธ์:
// --- 6. join! / try_join! ------------------------------------------- let (x, y) = tokio::join!(a(), b()); assert_eq!((x, y), (1, "two")); println!("[11] join! -> ({x}, {y})");
let r: Result<(i32, i32), String> = tokio::try_join!(ok1(), fails()); assert_eq!(r, Err("bad".to_string())); println!("[12] try_join! -> {r:?}");[11] join! -> (1, two)[12] try_join! -> Err("bad")try_join! ต่างที่มัน short-circuit — คืนทันทีที่เจอ Err ตัวแรก ไม่รอให้ครบทุก branch ซึ่งคนละพฤติกรรมกับ Task.WhenAll ที่รอครบแล้วค่อยโยน AggregateException
แต่ประโยคที่แพงที่สุดของหัวข้อนี้อยู่ในเอกสาร join! เอง: มันรัน branch ทั้งหมด บน task เดียวกัน ฉะนั้นถ้า branch ไหน block thread branch อื่นไปต่อไม่ได้เลย ทดสอบให้เห็นด้วย src/bin/conc_vs_par.rs:
use std::time::Duration;
async fn hog() { std::thread::sleep(Duration::from_millis(100)); // blocking! ยึด thread ไว้ทั้งก้อน println!("hog done");}
async fn polite() { tokio::time::sleep(Duration::from_millis(10)).await; // yield ให้ scheduler println!("polite done");}
#[tokio::main]async fn main() { println!("--- join! (task เดียว) ---"); tokio::join!(hog(), polite());
println!("--- spawn (multi_thread) ---"); let h1 = tokio::spawn(hog()); let h2 = tokio::spawn(polite()); let _ = tokio::join!(h1, h2);}รันซ้ำห้าครั้งได้ผลเดิมทุกครั้ง:
--- join! (task เดียว) ---hog donepolite done--- spawn (multi_thread) ---polite donehog doneครึ่งบนคือ concurrency ที่ล้มเหลว: polite() ต้องการเวลาแค่ 10ms แต่ต้องรอ hog() ที่ยึด thread ไว้ 100ms จนครบ เพราะทั้งคู่แทรกกันอยู่บน task เดียว thread เดียว ครึ่งล่างคือ parallelism ของจริง: พอ spawn แยกเป็น2 task บน multi_thread runtime (เครื่องที่รันตัวอย่างนี้มี 8 core จึงมี 8 worker — จำนวน worker ของคุณจะเท่ากับจำนวน core ของ เครื่องคุณ ตาม default ของ worker_threads ในบท 2 ขอแค่มากกว่า 1 ผลก็ออกมาแบบเดียวกัน) scheduler เอา polite ไปวางบน worker อื่นได้ มันจึงพิมพ์ก่อน
จำเส้นแบ่งนี้ให้แม่น: join! ให้ concurrency · spawn บน multi_thread runtime เท่านั้นที่ให้ parallelism และเงื่อนไขของ concurrency คือทุก branch ต้องยอม yield ที่ .await
ทางแก้ของครึ่งบนคือย้าย hog() ที่ block ไปไว้บน blocking pool ด้วย spawn_blocking จากหัวข้อที่แล้ว — นี่คือ src/bin/conc_fixed.rs ทั้ง file:
use std::time::Duration;
async fn hog_fixed() { tokio::task::spawn_blocking(|| { std::thread::sleep(Duration::from_millis(100)); // ย้ายไป blocking pool }) .await .unwrap(); println!("hog done");}
async fn polite() { tokio::time::sleep(Duration::from_millis(10)).await; println!("polite done");}
#[tokio::main(flavor = "current_thread")]async fn main() { println!("--- join! + spawn_blocking (current_thread runtime!) ---"); tokio::join!(hog_fixed(), polite());}--- join! + spawn_blocking (current_thread runtime!) ---polite donehog doneหัวข้อนี้ใช้ #[tokio::main(flavor = "current_thread")] โดยเจตนา — เพื่อแสดงว่าต่อให้มี worker thread แค่ ตัวเดียว polite ก็ยังพิมพ์ก่อน เพราะ hog_fixed ไม่ได้ยึด worker ไว้แล้ว มันไปนอนอยู่บน blocking pool ต่างหาก
flowchart TD
A[async block คือ future ที่ lazy ยังไม่มีอะไรรัน] -->|tokio spawn| B[task เข้าคิว scheduler และเริ่มรันทันที]
A -.->|ไม่ spawn ไม่ await| Z[ตายเงียบ ไม่เคยรันเลย]
B --> W[worker thread หมุน poll ให้เองเบื้องหลัง]
B --> H[JoinHandle ที่คุณถืออยู่ในมือ]
H -->|await| R[Result ของ T กับ JoinError]
H -->|drop| D[detach คือ task รันต่อ แต่ผลลัพธ์หายไป]
H -->|abort| X[cancel คือ task หยุดที่ await ล่าสุด]
R --> OK[Ok v คืองานสำเร็จ]
R --> ER[Err e ที่ต้องเช็ค is_panic หรือ is_cancelled เอง]
X --> ER
คำบรรยายภาพ: เส้นทางชีวิตของ task หนึ่งตัว — async block เปล่าๆ ยังขี้เกียจอยู่ ถ้าไม่ spawn และไม่ await มันตายเงียบ แต่พอ spawn มันเข้าคิว scheduler และ worker เริ่มหมุน poll ให้ทันที สิ่งที่คุณได้กลับมาคือ JoinHandle ซึ่งทำได้สามอย่าง คือ await เพื่อรอผลที่มาในรูป Result, drop ซึ่งเท่ากับ detach ให้ task รันต่อโดยผลหายไป, หรือ abort ซึ่งยกเลิก task แล้วทำให้ await ได้ Err ที่ is_cancelled เป็นจริง
❌ version ดิบ ที่สอง: "macros" ที่ลืมเปิด
หัวข้อที่มีชื่อว่า “❌ version ดิบ ที่สอง: "macros" ที่ลืมเปิด”join! กับ try_join! ไม่ได้อยู่ใต้ "rt" และไม่ได้ติดมากับ scheduler — ใน source ของ tokio 1.53.1 มันอยู่ใน block cfg_macros! ทั้งคู่ ซึ่งแปลว่าอยู่ใต้ feature "macros" ตัวเดียว ลองตัดออกดู:
[dependencies]tokio = { version = "1.53.1", features = ["rt-multi-thread"] }async fn a() -> i32 { 1 }async fn b() -> &'static str { "two" }
fn main() { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async { let (x, y) = tokio::join!(a(), b()); println!("{x} {y}"); });}(สังเกตว่าต้องประกอบ runtime ด้วยมือแบบบท 2 เพราะ #[tokio::main] เองก็อยู่ใต้ "macros" เหมือนกัน) ผลที่ได้:
error[E0433]: cannot find `join` in `tokio` --> src/main.rs:7:29 |7 | let (x, y) = tokio::join!(a(), b()); | ^^^^ could not find `join` in `tokio`เติม "macros" กลับเข้าไปแล้วเขียวทันที พิมพ์ 1 two ออกมา ข้อความ error ตรงนี้เป็นตัวอย่างที่ดีของเหตุผลว่าทำไมคอร์สนี้ถึงห้าม features = ["full"]: มันบอกแค่ว่า “ไม่มี join ใน tokio” ไม่ได้ใบ้เลยสักคำว่าต้องไปเปิด feature ไหน ถ้าคุณไม่เคยเห็นตารางว่าอะไรอยู่ใต้อะไร คุณจะเสียเวลาไล่หาอยู่นาน — และถ้าใช้ "full" คุณก็จะไม่มีวันได้เห็นตารางนั้นเลย
แผนที่คำศัพท์จาก C#/.NET
หัวข้อที่มีชื่อว่า “แผนที่คำศัพท์จาก C#/.NET”| C# / .NET | Rust + Tokio | จุดที่ต่างจริง |
|---|---|---|
Task.Run(() => …) | tokio::spawn(async { … }) | เจตนาเหมือนกันคือ “เอาไปรันเลย” — และสัญชาตญาณ eager ของ C# ใช้ได้ตรงนี้พอดี ต่างจาก future เปล่าๆ ที่ยังขี้เกียจ |
Task<T> | JoinHandle<T> | await แล้วได้ Result<T, JoinError> ไม่ใช่ T |
| await task ที่ faulted แล้ว exception เด้งขึ้นมาเอง | Err(e) ที่ e.is_panic() เป็นจริง | Rust ไม่ re-throw ให้ คุณต้องตรวจ Result เอง และ let _ = handle.await; ก็ compile ผ่าน |
ทิ้ง reference ของ Task ไว้เฉยๆ | drop(handle) = detach | ผลลัพธ์เหมือนกันคือ task รันต่อ — แต่ใน Rust ต้องระวังเป็นพิเศษ เพราะ drop future คือ cancel ส่วน drop JoinHandle ไม่ใช่ |
cts.Cancel() แล้วรอให้ code เช็ค token เอง | handle.abort() | บท 5 จะลงรายละเอียดว่าทำไมของ Rust ถึงหยุดได้จริงโดยไม่ต้องพึ่งความร่วมมือ |
Task.WhenAll(a, b) | tokio::join!(a, b) | เจตนาเดียวกัน กลไกคนละเรื่อง — WhenAll รวม hot Task ที่ เริ่มเดินไปแล้ว ก่อนถูกรวม (ถ้าเป็น Task.Run งาน CPU-bound ก็ขนานจริงบน ThreadPool ส่วนงาน I/O-bound แค่ concurrent ไม่กิน thread ของ pool เลย) ส่วน join! แทรก cold future บน task เดียว thread เดียวเสมอ |
WhenAll ที่รอครบแล้วโยน AggregateException | tokio::try_join!(a, b) | try_join! คืนที่ Err ตัวแรกทันที ไม่รอ branch ที่เหลือ |
Task.Run สำหรับงาน CPU-bound | tokio::task::spawn_blocking | บทเรียนเดียวกับ thread-pool starvation จาก .Result/.Wait() ใน ASP.NET แต่ pool ของ async worker ใน Tokio กว้างแค่จำนวน core — block ตัวเดียวจึงเจ็บกว่ามาก (ส่วน blocking pool ที่ spawn_blocking ใช้เป็นคนละ pool และกว้าง 512 ตาม default) |
| ไม่มีของเทียบ | Send + 'static ใน signature ของ spawn | C# ไม่เคยห้ามคุณ capture อะไรเข้า Task.Run เลย ส่วน Rust ปฏิเสธตั้งแต่ compile |
แถวสุดท้ายคือแถวที่จะกลับมาหลอกหลอนคุณในบท 6 ตอนที่ MutexGuard ของ std ซึ่งไม่ Send ไปโผล่คร่อม .await
ที่รันมาทั้งบท — output เต็มและ test
หัวข้อที่มีชื่อว่า “ที่รันมาทั้งบท — output เต็มและ test”หัวข้อสุดท้ายที่ยังไม่ได้วางคือหัวข้อ 7 — spawn ก่อนเพื่อให้ได้ parallelism แล้วค่อย join! ตัว JoinHandle:
// --- 7. join! over JoinHandles -------------------------------------- let h1 = tokio::spawn(async { 10 }); let h2 = tokio::spawn(async { 20 }); let (r1, r2) = tokio::join!(h1, h2); println!("[13] join! over handles -> ({}, {})", r1.unwrap(), r2.unwrap());พอต่อหัวข้อ 1–7 ครบใน src/main.rs แล้ว รันครั้งเดียวได้ stdout ตามนี้:
[1] before spawn[2] task is ALREADY running, nobody awaited the handle yet[3] main is only now about to await the handle[4] handle.await -> 3[5] task panicked, is_panic = true[6] main is still running after the task panicked[7] after dropping the handle, counter = 1[8] abort -> is_cancelled = true[9] after abort, counter = 1[10] spawn_blocking -> 42[11] join! -> (1, two)[12] try_join! -> Err("bad")[13] join! over handles -> (10, 20)[13] คือ pattern ที่คุณจะใช้บ่อยที่สุดใน code จริง: spawn ก่อนเพื่อให้ได้ parallelism แล้วค่อย join! ตัว JoinHandle ทั้งหมดเพื่อรอผล — สังเกตว่าแต่ละช่องของ tuple ที่ได้เป็น Result จึงต้อง unwrap() (หรือ match) ทีละตัว
ข้อยืนยันฝั่ง test ก็อยู่ใน file เดียวกัน — ห้าตัวครอบ Result-ไม่ใช่-T, panic เป็น is_panic, abort เป็น is_cancelled, tuple ของ join! และการ short-circuit ของ try_join!:
#[cfg(test)]mod tests { use super::*;
#[tokio::test] async fn join_handle_output_is_result_not_t() { let handle = tokio::spawn(async { 1 + 2 }); let out: Result<i32, tokio::task::JoinError> = handle.await; assert_eq!(out.unwrap(), 3); }
#[tokio::test] async fn panic_in_task_is_err_is_panic() { let handle = tokio::spawn(async { panic!("boom") }); let err = handle.await.unwrap_err(); assert!(err.is_panic()); assert!(!err.is_cancelled()); }
#[tokio::test] async fn abort_is_cancelled() { let handle = tokio::spawn(async { tokio::time::sleep(Duration::from_secs(3600)).await }); handle.abort(); let err = handle.await.unwrap_err(); assert!(err.is_cancelled()); assert!(!err.is_panic()); }
#[tokio::test] async fn join_returns_a_tuple() { let (x, y) = tokio::join!(a(), b()); assert_eq!((x, y), (1, "two")); }
#[tokio::test] async fn try_join_short_circuits() { let r: Result<(i32, i32), String> = tokio::try_join!(ok1(), fails()); assert_eq!(r, Err("bad".to_string())); let r2: Result<(i32, i32), String> = tokio::try_join!(ok1(), ok1()); assert_eq!(r2, Ok((1, 1))); }}test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sA — laziness ยังจริงอยู่ แค่ spawn เป็นข้อยกเว้น: async { … } เปล่าๆ ยังคงไม่รันจนกว่าจะถูก poll ทุกประการตามบท 1 สิ่งที่ spawn ทำคือ ส่งมันเข้าคิว scheduler ซึ่งมี worker คอย poll ให้อยู่แล้ว มันจึงดูเหมือน eager — อย่าเหมารวมว่า “Rust future eager แล้ว” เพราะ future ที่คุณ join! ยังคง cold ทุกตัว
B — std ไม่มี runtime และ spawn ก็ไม่มีที่ยืนถ้าไม่มี runtime: เขียน tokio::spawn(...) ใน function main ธรรมดาที่ไม่มี runtime compile ผ่าน แล้วไป panic ตอนรัน ด้วยข้อความจริงว่า there is no reactor running, must be called from the context of a Tokio 1.x runtime — คนละโลกกับ .NET ที่ Task.Run ใช้ได้ทุกที่เพราะ ThreadPool ฝังมากับ CLR
C — function colouring ที่ไปถึงระดับ type: spawn ไม่ได้แค่ต้องการ async มันต้องการ F: Future + Send + 'static และ F::Output: Send + 'static ด้วย นั่นแปลว่า ชนิดของข้อมูล ที่คุณถืออยู่คร่อม .await มีสิทธิ์ห้ามคุณ spawn ได้ตรงๆ (Rc ห้าม, การยืม local ห้าม) นี่คือ colouring ที่แรงกว่าใน C# มาก และเราจะเก็บเรื่องนี้เต็มๆ ในบท 7
D — ทุกอย่างในบทนี้ compile และรันจริง (ยกเว้น snippet ที่ติดป้าย ❌ ซึ่งตั้งใจให้พัง): ตรวจด้วย recipe เดียวของทั้งคอร์สคือ build/run/test/clippy -D warnings บน target native x86_64-unknown-linux-gnu (กลเม็ด musl + rust-lld ที่ #22/#23 ใช้ได้เพราะเป็น zero-crate ปลดระวางไปแล้วทั้งคอร์สนี้ เพราะ tokio ลาก native dependency เข้ามาจริง) ส่วน red_send2.rs · red_static.rs และ project ที่ถอด "macros" ออก เรา compile แล้วลอกข้อความ error จริงที่ rustc ตอบกลับมาลงไว้แทน output ไม่มี snippet ไหนเป็นไดอะแกรมอย่างเดียว ตัวเลข 64 byte ต่อ task เป็น คำอ้างจากเอกสาร Tokio ที่ยกมาตรงๆ ไม่ใช่ benchmark ที่เราวัดเอง — สิ่งที่เราวัดเองมีอย่างเดียวคือลำดับ hog done/polite done ที่ซ้ำเดิมทุกรอบ
สรุปก่อนไปต่อ
หัวข้อที่มีชื่อว่า “สรุปก่อนไปต่อ”spawn คือเส้นแบ่งระหว่าง future ที่ขี้เกียจกับ task ที่ขยัน — พิสูจน์ด้วย [2] ที่พิมพ์ก่อน [3] ทั้งที่ยังไม่มีใคร await; JoinHandle<T> เป็น future ที่ Output เป็น Result<T, JoinError> ไม่ใช่ T panic ในตัว task จึงกลับมาเป็น Err ที่ is_panic() โดยไม่ล้ม main และไม่ re-throw ให้แบบ C#; drop(handle) คือ detach (counter ยังขึ้นเป็น 1) ส่วน abort() ต่างหากที่ cancel จริง (counter ค้างที่ 1 ไม่ใช่ 101); Send + 'static ในนิยามของ spawn ปฏิเสธ Rc ที่คร่อม .await ด้วยข้อความ future cannot be sent between threads safely ที่ไม่มีรหัส error และปฏิเสธการยืม local ด้วย E0373 ที่ใบ้ให้เติม move; join! ให้ concurrency บน task เดียว (branch ที่ block ทำให้ทั้งก้อนช้าตาม) ส่วน parallelism จริงต้อง spawn + multi_thread runtime และงาน blocking ที่หลีกไม่ได้ให้ spawn_blocking ซึ่งมี pool ของตัวเองกว้าง 512 thread; สุดท้าย join!/try_join! อยู่ใต้ feature "macros" เท่านั้น ถอดออกแล้วได้ error[E0433] ทันที
บท 4 เราเอา task ไปแปะกับ socket จริง: ตอนนี้เรามีหน่วยงานราคาถูกในมือแล้ว บทหน้าจะย้าย server ของ kaen-kvstore จาก std::net มาที่ tokio::net — รูป code เกือบเหมือนเดิม 1:1 แต่ทุก call ต้อง .await และหนึ่งคอนเนกชันกลายเป็น 1 task แทน1 OS thread พร้อมกำแพงมือใหม่อันดับหนึ่งที่รออยู่ตรงนั้น คือ read/write_all ที่อยู่บน extension trait จนลืม use แล้ว method หายไปเฉยๆ
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
tokio::spawn— docs.rs/tokio/1.53.1 (รุ่นเผยแพร่ 2026-07-20 · เข้าถึง 2026-07-27) —pub fn spawn<F>(future: F) -> JoinHandle<F::Output> where F: Future + Send + 'static, F::Output: Send + 'staticและประโยคหลักว่า task เริ่มรันเบื้องหลังทันทีที่ spawn แม้ยังไม่ await ตัวแฮนเดิล · อยู่ใต้ featurerttokio::task::JoinHandle— docs.rs/tokio/1.53.1 (รุ่นเผยแพร่ 2026-07-20 · เข้าถึง 2026-07-27) —impl<T> Future for JoinHandle<T> { type Output = Result<T, JoinError>; }·abort(&self)·is_finished()·abort_handle()·id()และประโยคที่บทนี้ยึดว่า dropping a JoinHandle detaches the task (รันต่อ ผลหาย) ไม่ใช่ canceltokio::task::JoinError— docs.rs/tokio/1.53.1 (รุ่นเผยแพร่ 2026-07-20 · เข้าถึง 2026-07-27) —JoinErrorมีสาเหตุได้สองอย่างเท่านั้นคือ panic หรือ cancel/abort พร้อมis_panic()·is_cancelled()·into_panic()·try_into_panic()tokio::task::spawn_blocking— docs.rs/tokio/1.53.1 (รุ่นเผยแพร่ 2026-07-20 · เข้าถึง 2026-07-27) —pub fn spawn_blocking<F, R>(f: F) -> JoinHandle<R> where F: FnOnce() -> R + Send + 'static, R: Send + 'staticและข้อความว่าabort()ไม่มีผล เมื่องาน blocking เริ่มรันไปแล้วtokio::runtime::Builder— docs.rs/tokio/1.53.1 (รุ่นเผยแพร่ 2026-07-20 · เข้าถึง 2026-07-27) — ที่มาของตัวเลข defaultmax_blocking_threads= 512 และ defaultworker_threads= จำนวน core ของเครื่องtokio::join!— docs.rs/tokio/1.53.1 (รุ่นเผยแพร่ 2026-07-20 · เข้าถึง 2026-07-27) — รันทุก branch concurrently บน task/thread เดียวกัน (ไม่ parallel — ถ้า branch 1 block thread ตัวอื่นไปต่อไม่ได้) คืน tuple · ต้องการ featuremacrostokio::try_join!— docs.rs/tokio/1.53.1 (รุ่นเผยแพร่ 2026-07-20 · เข้าถึง 2026-07-27) — คืนเมื่อทุก branch เป็นOk(_)หรือคืนทันทีที่Err(_)ตัวแรก (short-circuit) · ต้องการ featuremacros- source ของ tokio 1.53.1 —
tokio/src/macros/mod.rs(เข้าถึง 2026-07-27) — หลักฐานตรงว่าjoin!/try_join!/select!อยู่ใน blockcfg_macros!ทั้งหมด จึง ไม่มี ให้ใช้ถ้าเปิดแค่rt/rt-multi-thread - Tokio Tutorial — Spawning (เข้าถึง 2026-07-27) — ที่มาของคำอธิบาย task ว่าเป็น green thread ที่กิน “a single allocation and 64 bytes of memory” จึง spawn เป็นพันเป็นล้านตัวได้ พร้อมเหตุผลว่าทำไมต้อง
Send(runtime ย้าย task ข้าม thread ได้ทุกจุดที่มัน suspend) และทำไมต้อง'static(จึงมักต้องใช้move)
เช็กความเข้าใจ — บทที่ 3
ข้อ 1 / 3คุณเขียน let handle = tokio::spawn(async { panic!("boom") }); แล้วตามด้วย handle.await; เฉยๆ โดยไม่ตรวจอะไรเลย ผลที่เกิดขึ้นจริงคืออะไร?