Mục lục
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Hiểu
std::thread::spawntạo một OS thread mới (1:1 với native thread của hệ điều hành) chạy song song cùng main; mỗi thread có stack riêng (mặc định 2 MB trên Linux). - Viết được cú pháp cơ bản
let handle = thread::spawn(|| { /* code */ });và biết closure phải thoả mãn boundFnOnce() -> T + Send + 'static. - Gọi
handle.join()để block thread hiện tại đến khi thread spawn kết thúc; hiểu kiểu trả vềResult<T, Box<dyn Any + Send>>. - Biết khi nào closure cần keyword
moveđể chuyển ownership biến ngoài vào thread — gần như luôn cần khi capture biến không phải Copy. - Trả giá trị từ thread con qua return type của closure và lấy ra qua
handle.join().unwrap(). - Hiểu cơ chế panic: thread con panic không làm crash main, chỉ bị bắt qua
join()trả vềErrvới payload. - Tránh cạm bẫy main-exit-không-join: spawned thread sẽ bị hệ điều hành kill ngay khi process exit, không có chuyện "đợi" giùm.
- Liệt kê được ba use case điển hình của
spawn: parallel computation, background task, periodic worker — chuẩn bị cho các bài sau về channel và shared state.
Bài tiếp theo (223) sẽ giới thiệu thread::sleep, thread::park và thread::Thread::unpark — các primitive đồng bộ hoá thấp cấp để thread "chờ" một sự kiện mà không busy-loop, mở đường cho channel ở các bài kế tiếp.
std::thread::spawn Là Gì
std::thread::spawn là API cốt lõi nhất của module std::thread — nó tạo một OS thread mới (kernel thread, 1:1 với native thread của hệ điều hành) để chạy một closure song song với thread đã gọi nó. Khác với green thread (M:N) ở một số ngôn ngữ khác (Go goroutine, Erlang process), Rust thread chính là native thread — nghĩa là mỗi thread có stack riêng (mặc định 2 MB trên Linux, 8 MB trên macOS), được kernel scheduler quản lý, và có cost thật khi tạo (~10-100 μs).
Signature đơn giản nhất: fn spawn<F, T>(f: F) -> JoinHandle<T> where F: FnOnce() -> T + Send + 'static, T: Send + 'static. Ba bound quan trọng cần nhớ. Một, FnOnce — closure chỉ chạy đúng một lần, nên có thể consume biến đã capture. Hai, Send — closure (và mọi thứ nó capture) phải an toàn để chuyển qua thread boundary. Ba, 'static — closure không được giữ reference ngắn hạn vào dữ liệu của caller, vì thread spawn ra có thể sống lâu hơn caller.
Giá trị trả về là JoinHandle<T> — một handle sở hữu thread vừa tạo. Bạn có thể giữ handle này để gọi .join() sau (chờ thread kết thúc và lấy T), hoặc drop nó đi để "detach" thread (thread vẫn chạy nhưng bạn không có cách lấy kết quả). Hành vi mặc định là detach — nếu bạn không bắt giá trị trả về, handle bị drop ngay, và thread tự chạy nền.
Cú Pháp Cơ Bản
Ví dụ tối giản nhất — spawn một thread in ra string, main in song song:
use std::thread;
fn main() {
// Spawn một thread mới — closure chạy trên thread đó
let handle = thread::spawn(|| {
println!("Hello từ thread con (id = {:?})", thread::current().id());
});
println!("Hello từ main (id = {:?})", thread::current().id());
// Chờ thread con xong rồi mới thoát main
handle.join().unwrap();
}
// Output (thứ tự có thể đổi):
// Hello từ main (id = ThreadId(1))
// Hello từ thread con (id = ThreadId(2))
Phân tích từng dòng. thread::spawn(|| { ... }) truyền vào một closure FnOnce() không nhận tham số, return () — kiểu T ở đây là unit. Closure được move vào thread mới và bắt đầu chạy ngay (không cần explicit start như một số ngôn ngữ khác). thread::current().id() trả về ThreadId để bạn phân biệt thread nào đang chạy.
Thứ tự output không đảm bảo: hai thread chạy song song, scheduler có thể chọn in main trước hoặc thread con trước. Đây là điểm khác biệt cơ bản với code single-thread — bạn không được giả định thứ tự nếu không có cơ chế đồng bộ hoá.
.join() — Chờ Thread Kết Thúc
Method JoinHandle::join(self) block thread hiện tại đến khi thread mà handle đại diện kết thúc, rồi trả về Result<T, Box<dyn Any + Send + 'static>>. Hai trường hợp:
Ok(value)— thread chạy bình thường, value là giá trị closure return (kiểu T).Err(payload)— thread panic, payload là giá trị truyền vàopanic!dưới dạngBox<dyn Any>— bạn có thể downcast sang&strhoặcStringđể in.
use std::thread;
fn main() {
let h1 = thread::spawn(|| {
for i in 1..=3 {
println!("[T1] tick {i}");
}
});
let h2 = thread::spawn(|| {
for i in 1..=3 {
println!("[T2] tick {i}");
}
});
// join lần lượt — main đợi cả hai
h1.join().unwrap();
h2.join().unwrap();
println!("main: cả hai thread đã xong");
}
Lưu ý join(self) nhận self theo value — tức là handle bị consume, sau khi join bạn không gọi lại được. Nếu chỉ muốn check thread đã xong chưa mà không block, dùng handle.is_finished() (Rust 1.61+). Gọi join trên thread đã xong vẫn hợp lệ và trả về ngay lập tức, không block.
Vì join trả về Result, bạn nên handle lỗi thay vì unwrap() bừa — nhất là trong production code. Pattern phổ biến: match h.join() { Ok(v) => ..., Err(e) => eprintln!("Thread panic: {e:?}") }.
Closure Capture Với move
Closure trong Rust mặc định capture biến ngoài theo cách tối thiểu — borrow nếu chỉ đọc, mutable borrow nếu chỉnh sửa, move nếu consume. Nhưng với thread::spawn, closure phải thoả 'static — không được giữ reference vào dữ liệu của caller. Do đó hầu như luôn phải dùng keyword move để ép closure own biến đã capture:
use std::thread;
fn main() {
let v = vec![1, 2, 3];
// SAI: thiếu move — closure borrow v, mà v sống trong main scope,
// không đủ 'static. Compiler báo:
// "closure may outlive the current function, but it borrows `v`"
// let h = thread::spawn(|| {
// println!("v = {:?}", v);
// });
// ĐÚNG: move chuyển ownership v vào thread
let h = thread::spawn(move || {
println!("v = {v:?}");
// sau dòng này v sống trên stack của thread con, main không truy cập nữa
});
// println!("{v:?}"); // ERROR: v đã move
h.join().unwrap();
}
Hiểu sâu: move không có nghĩa "luôn move tất cả" mà là "ép closure giữ by value". Với type Copy (i32, bool...), move thực ra là copy. Với type không Copy (String, Vec, custom struct...), move chuyển ownership thật sự, biến gốc không còn dùng được.
Nếu bạn cần share dữ liệu giữa nhiều thread (đọc cùng một Vec từ 3 thread), move không đủ — vì move chỉ chuyển vào đúng một thread. Lúc đó cần Arc<T> (atomic reference count) — sẽ học chi tiết ở bài về thread-safe smart pointer. Hoặc tốt hơn nếu data có lifetime ngắn hạn: dùng std::thread::scope (Bài 225) để spawn scoped threads cho phép borrow non-static.
Return Value Từ Thread
Closure truyền cho spawn có thể return bất kỳ kiểu T nào thoả Send + 'static, và handle.join() sẽ trả lại giá trị đó. Đây là cách đơn giản nhất để pass kết quả từ thread con về main mà không cần channel:
use std::thread;
fn sum_slice(slice: Vec<i64>) -> i64 {
slice.iter().sum()
}
fn main() {
let data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// Chia đôi, mỗi thread xử lý một nửa
let (left, right) = data.split_at(data.len() / 2);
let left = left.to_vec();
let right = right.to_vec();
let h1 = thread::spawn(move || sum_slice(left)); // return i64
let h2 = thread::spawn(move || sum_slice(right)); // return i64
// join trả Result<i64, _>, unwrap lấy i64
let s1: i64 = h1.join().unwrap();
let s2: i64 = h2.join().unwrap();
println!("Tổng = {}", s1 + s2); // 55
}
Đây là pattern fork-join kinh điển: chia data thành N phần, spawn N thread mỗi thread xử lý một phần, rồi join hết để gộp kết quả. Với CPU-bound work và N = số core, bạn có thể đạt tốc độ gần tuyến tính với số core. Hạn chế: overhead spawn ~50 μs mỗi thread, nên chỉ đáng nếu mỗi phần task chạy ít nhất vài ms; với task siêu nhỏ, dùng thread pool (rayon, crossbeam) hợp lý hơn.
Lưu ý kiểu T phải Send — đa số kiểu thường gặp (i32, String, Vec, HashMap...) đều Send. Một số kiểu như Rc<T>, *mut T không Send và compiler sẽ chặn ngay tại spawn. Với Rc dùng giữa thread, đổi sang Arc.
Panic Trong Thread Con
Khi một thread con panic, nó không làm crash main hay các thread khác — chỉ thread đó bị unwind, drop hết biến local, rồi kết thúc. Main vẫn chạy tiếp như bình thường. Bạn chỉ "biết" thread con đã panic khi gọi join và nhận về Err:
use std::thread;
fn main() {
let h = thread::spawn(|| {
let v = vec![1, 2, 3];
println!("Trước panic");
let _ = v[100]; // panic: index out of bounds
println!("Sau panic — KHÔNG bao giờ in");
});
println!("Main vẫn chạy tiếp khi thread con đang panic...");
match h.join() {
Ok(_) => println!("Thread chạy bình thường"),
Err(payload) => {
// payload là Box<dyn Any + Send>
if let Some(s) = payload.downcast_ref::<&str>() {
eprintln!("Thread panic với message: {s}");
} else if let Some(s) = payload.downcast_ref::<String>() {
eprintln!("Thread panic với message: {s}");
} else {
eprintln!("Thread panic với payload không xác định");
}
}
}
println!("Main kết thúc bình thường");
}
Mặc định khi thread panic, runtime in stack trace ra stderr (giống single-thread). Để tắt, dùng std::panic::set_hook hoặc env var RUST_BACKTRACE=0. Payload truyền vào panic!("msg") là &'static str; truyền vào panic!("{}", x) là String — đó là lý do code trên downcast cả hai kiểu.
Lưu ý ngược chiều: nếu main panic, toàn bộ process bị abort, mọi spawned thread bị OS kill — không có cách "recover" trong main qua join. Đó là lý do trong production server, người ta thường dùng std::panic::catch_unwind để bắt panic ngay tại thread biên giới giữa Rust và bên ngoài (FFI, request handler).
Main Exit KHÔNG Đợi Children
Đây là cạm bẫy số 1 của người mới học thread trong Rust: khi main thread return từ fn main(), toàn bộ process exit — và mọi spawned thread chưa join bị OS kill ngay lập tức, kể cả khi chúng đang chạy giữa chừng. Khác với Java (non-daemon thread giữ JVM sống), Rust không có khái niệm "background thread tự sống". Demo:
use std::thread;
use std::time::Duration;
fn main() {
// Spawn thread cố in 5 dòng, mỗi dòng cách 100 ms
let _h = thread::spawn(|| {
for i in 1..=5 {
println!("[child] tick {i}");
thread::sleep(Duration::from_millis(100));
}
println!("[child] xong");
});
println!("[main] không join, return ngay");
// Main return ở đây → process exit → child bị kill trước khi in xong
}
// Output thường:
// [main] không join, return ngay
// [child] tick 1
// (có thể vài tick nữa hoặc không — KHÔNG bao giờ in "xong")
Sửa: phải h.join().unwrap() trước khi main return. Hoặc nếu bạn thật sự muốn thread chạy nền và không quan tâm kết quả, hãy dùng pattern khác — ví dụ thread pool có shutdown rõ ràng, hoặc cấu trúc lại logic để main chờ một tín hiệu (channel close, atomic flag).
Cạm bẫy biến tướng: spawn nhiều thread trong vòng lặp mà không lưu handle. Ví dụ for i in 0..10 { thread::spawn(move || work(i)); } — handle bị drop ngay sau mỗi vòng (detach), main chạy tiếp đến hết, process exit, có thể vài thread chưa kịp khởi động đã bị kill. Pattern đúng: collect handle vào Vec<JoinHandle<()>>, sau loop duyệt và join từng cái.
Use Case Thực Tế
Ba tình huống điển hình thường gặp khi cần dùng thread::spawn trực tiếp (không qua thread pool):
- Parallel CPU-bound computation — chia dữ liệu lớn (ảnh, mảng số liệu, document corpus) thành N phần, mỗi thread xử lý một phần. Pattern fork-join như mục 6. Trên máy 8 core, có thể tăng tốc xấp xỉ 6-7 lần với task đủ nặng.
- Background task tách biệt — logger flush buffer định kỳ, cleanup expired cache, metrics reporter. Spawn một thread duy nhất ở đầu chương trình, để nó chạy suốt vòng đời, và shutdown khi main muốn — thường qua channel hoặc atomic flag.
- Blocking I/O không hỗ trợ async — gọi một thư viện C/C++ qua FFI mà thư viện đó blocking, không thể dùng trong async runtime. Spawn một thread riêng để giữ blocking call, main vẫn responsive. Khi nào thread xong, dùng channel báo về.
Khi nào không nên dùng thread::spawn trực tiếp? Khi bạn có hàng nghìn task nhẹ — mỗi OS thread tốn 2 MB stack, 10000 thread = 20 GB ảo. Lúc đó dùng thread pool (rayon cho data parallelism, tokio / async-std cho I/O-bound async). Khi cần borrow non-static data — dùng std::thread::scope (Bài 225). Khi cần message passing nhiều thread — dùng channel (sẽ học ở Nhóm 29).
Tổng Kết
std::thread::spawn(closure)tạo một OS thread mới (1:1 native, stack ~2 MB) chạy closure song song; closure phải thoảFnOnce() -> T + Send + 'static.- Trả về
JoinHandle<T>— handle sở hữu thread;handle.join()block đến khi thread kết thúc, trảResult<T, Box<dyn Any + Send>>. - Closure cần keyword
moveđể own biến capture (vì spawn yêu cầu'static); với typeCopythì move = copy, với type không Copy thì biến gốc không dùng được nữa. - Thread có thể return giá trị qua kiểu T của closure — fork-join pattern cho parallel computation.
- Panic trong thread con không crash main; bắt qua
jointrảErrvới payloadBox<dyn Any>, downcast về&strhoặcStringđể in. - Main return → process exit → mọi spawned thread chưa join bị OS kill ngay; luôn nhớ join hoặc dùng cơ chế shutdown rõ ràng.
- Use case chính: parallel CPU-bound, background task, blocking I/O wrapper; với task nhẹ số lượng lớn dùng thread pool.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Viết chương trình spawn 4 thread, mỗi thread in id của mình (lấy qua
thread::current().id()) và một message "Hello from thread N". Collect handle vàoVecrồi join từng cái. - Cho
Vec<i64>1 triệu phần tử random. Viết hàmparallel_sum(v: Vec<i64>, n_threads: usize) -> i64chia mảng thànhn_threadsphần, mỗi thread tính tổng một phần, main join và cộng lại. - Spawn một thread cố ý panic với
panic!("custom message"). Trong main, gọijoinvà downcast payload để in ra "custom message". - Viết code spawn 5 thread, mỗi thread sleep i × 100 ms rồi in "thread i xong". Main KHÔNG join. Chạy thử xem output có đầy đủ 5 dòng không, giải thích vì sao.
- Sửa bài 4 để main join tất cả — đảm bảo 5 dòng đều in ra.
Đáp án
let handles: Vec<_> = (0..4).map(|n| thread::spawn(move || println!("Hello from thread {n} ({:?})", thread::current().id()))).collect(); for h in handles { h.join().unwrap(); }- Tính
chunk = v.len() / n_threads, chiavthành slice,to_vec()mỗi slice rồispawn(move || s.iter().sum()); collect handle, join, cộng tất cả. match h.join() { Err(p) => if let Some(s) = p.downcast_ref::<&str>() { println!("{s}"); }, _ => {} }- Không đầy đủ — main return rất nhanh, các thread sleep lâu hơn bị kill trước khi in xong. Output có thể chỉ vài dòng hoặc không có dòng nào.
- Collect handle, sau loop spawn duyệt
for h in handles { h.join().unwrap(); }— main đợi cả 5 thread xong rồi mới exit.
Bài Tiếp Theo
Bài 223: thread::sleep, park, unpark — các primitive thấp cấp để thread "chờ" một sự kiện mà không busy-loop CPU. thread::sleep(Duration) để delay thời gian cố định, thread::park / Thread::unpark cho phép thread block đến khi thread khác đánh thức — nền tảng để hiểu cách channel, Mutex, Condvar được xây dựng bên trong, đồng thời chuẩn bị cho các bài về std::thread::scope, thread Builder, và sau cùng là shared state với Arc và Mutex.
