Danh sách bài viết

Bài 222: thread::spawn & JoinHandle

Bài 222 của series Rust Cơ Bản — mở đầu Nhóm 28 (Concurrency Threads), bước vào thế giới đa luồng của Rust sau khi đã trang bị đầy đủ smart pointer ở Nhóm 27. Đây là nền tảng để hiểu các API phức tạp hơn — channel, Mutex, Arc, Condvar, scoped threads, async — đều xoay quanh đơn vị cơ bản: một OS thread. Rust cung cấp std::thread::spawn để tạo thread mới chạy song song với main, và trả về JoinHandle<T> — một handle để chờ thread đó kết thúc và lấy giá trị về. Bài này phân tích cú pháp thread::spawn(|| { ... }), ý nghĩa của handle.join() với kiểu trả về Result<T, Box<dyn Any + Send>>, lý do closure thường phải dùng move (vì thread yêu cầu lifetime 'static), cách thread return giá trị qua kiểu T của closure, cách panic trong thread con không làm crash main mà chỉ bị bắt qua join, và cuối cùng là cạm bẫy phổ biến nhất: main exit không đợi children — mọi spawned thread chưa join sẽ bị OS kill ngay khi main return.

09/06/2026
11 phút đọc
2 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Hiểu std::thread::spawn tạ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 bound FnOnce() -> 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ề Err vớ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::parkthread::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.

2

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.

3

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á.

4

.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ào panic! dưới dạng Box<dyn Any> — bạn có thể downcast sang &str hoặc String để 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.

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:?}") }.

5

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.

6

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.

7

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")&'static str; truyền vào panic!("{}", x)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).

8

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.

9

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):

  1. 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.
  2. 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.
  3. 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).

10

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 type Copy thì 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 join trả Err với payload Box<dyn Any>, downcast về &str hoặc String để 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.
11

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. 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ào Vec rồi join từng cái.
  2. Cho Vec<i64> 1 triệu phần tử random. Viết hàm parallel_sum(v: Vec<i64>, n_threads: usize) -> i64 chia mảng thành n_threads phần, mỗi thread tính tổng một phần, main join và cộng lại.
  3. Spawn một thread cố ý panic với panic!("custom message"). Trong main, gọi join và downcast payload để in ra "custom message".
  4. 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.
  5. Sửa bài 4 để main join tất cả — đảm bảo 5 dòng đều in ra.
Đáp án
  1. 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(); }
  2. Tính chunk = v.len() / n_threads, chia v thành slice, to_vec() mỗi slice rồi spawn(move || s.iter().sum()); collect handle, join, cộng tất cả.
  3. match h.join() { Err(p) => if let Some(s) = p.downcast_ref::<&str>() { println!("{s}"); }, _ => {} }
  4. 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.
  5. 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.
12

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 ArcMutex.