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

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 อะไร​เลย​ด้วย​ซ้ำ

📦 kaen-kvstore

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

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

ทุก 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:

Terminal window
export PATH="$HOME/.cargo/bin:$PATH"
cargo build --target x86_64-unknown-linux-gnu
cargo run --target x86_64-unknown-linux-gnu
cargo test --target x86_64-unknown-linux-gnu
cargo clippy --target x86_64-unknown-linux-gnu -- -D warnings

ทุก snippet ผ่าน​ครบ​ทั้ง​สี่​คำ​สั่ง ยกเว้น snippet ที่​ติด​ป้าย ❌ ซึ่ง​ตั้งใจ​ให้​พัง — ของ​พวก​นั้น​เรา compile แล้ว​ลอก​ข้อความ error จริง​มา​แสดง​แทน ทุก​บรรทัด output ใน​บท​นี้​คือ​ข้อความ​จริง​ที่​พิมพ์​ออก​มา ไม่ใช่​ของ​ที่​เขียน​ขึ้น​เอง

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

นี่​คือ​จุด​ที่ .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:
boom
note: 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

กับดัก​ถัด​ไป​ดัก​คน​ที่​เพิ่ง เข้าใจ 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 แทรก​เข้าไป​หยุด​มัน​ได้

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 use
help: 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 ของ​จริง

ก่อน​จะ​ไป​หัวข้อ​ถัด​ไป​ที่​แสดง​ให้​เห็น​ว่า​งาน 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}");

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 done
polite done
--- spawn (multi_thread) ---
polite done
hog 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

เอา spawn_blocking มา​แก้​ครึ่ง​บน​ให้​เป็น concurrency จริง

ทาง​แก้​ของ​ครึ่ง​บน​คือ​ย้าย 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 done
hog 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 เป็น​จริง

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# / .NETRust + 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 ที่​รอ​ครบ​แล้ว​โยน AggregateExceptiontokio::try_join!(a, b)try_join! คืน​ที่ Err ตัว​แรก​ทันที ไม่​รอ branch ที่​เหลือ
Task.Run สำหรับ​งาน CPU-boundtokio::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 ของ spawnC# ไม่​เคย​ห้าม​คุณ capture อะไร​เข้า Task.Run เลย ส่วน Rust ปฏิเสธ​ตั้งแต่ compile

แถว​สุดท้าย​คือ​แถว​ที่​จะ​กลับ​มา​หลอก​หลอน​คุณ​ใน​บท 6 ตอน​ที่ MutexGuard ของ std ซึ่ง​ไม่ Send ไป​โผล่​คร่อม .await

หัวข้อ​สุดท้าย​ที่​ยัง​ไม่​ได้​วาง​คือ​หัวข้อ 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.00s
honesty spine 4 เส้น ที่​บท​นี้​แตะ​ครบ

A — 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 ตัว​แฮนเดิล · อยู่​ใต้ feature rt
  • tokio::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 (รัน​ต่อ ผล​หาย) ไม่ใช่ cancel
  • tokio::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) — ที่มา​ของ​ตัวเลข default max_blocking_threads = 512 และ default worker_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 · ต้องการ feature macros
  • tokio::try_join! — docs.rs/tokio/1.53.1 (รุ่น​เผยแพร่ 2026-07-20 · เข้าถึง 2026-07-27) — คืน​เมื่อ​ทุก branch เป็น Ok(_) หรือ​คืน​ทันที​ที่ Err(_) ตัว​แรก (short-circuit) · ต้องการ feature macros
  • source ของ tokio 1.53.1 — tokio/src/macros/mod.rs (เข้าถึง 2026-07-27) — หลักฐาน​ตรง​ว่า join!/try_join!/select! อยู่​ใน block cfg_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; เฉยๆ โดยไม่ตรวจอะไรเลย ผลที่เกิดขึ้นจริงคืออะไร?