Sử Dụng Luồng để Chạy Mã Đồng Thời

Trong hầu hết các hệ điều hành hiện đại, mã của một chương trình đã thực thi được chạy trong một quy trình (process), và hệ điều hành sẽ quản lý nhiều quy trình cùng một lúc. Trong một chương trình, bạn cũng có thể có các phần độc lập chạy đồng thời. Các tính năng chạy các phần độc lập này được gọi là luồng (threads). Ví dụ, một máy chủ web có thể có nhiều luồng để nó có thể phản hồi nhiều hơn một yêu cầu cùng một lúc.

Việc chia nhỏ tính toán trong chương trình của bạn thành nhiều luồng để chạy nhiều tác vụ cùng một lúc có thể cải thiện hiệu suất, nhưng nó cũng làm tăng độ phức tạp. Bởi vì các luồng có thể chạy đồng thời, không có đảm bảo cố hữu về thứ tự mà các phần mã của bạn trên các luồng khác nhau sẽ chạy. Điều này có thể dẫn đến các vấn đề, chẳng hạn như:

  • Điều kiện tranh đua (Race conditions), trong đó các luồng đang truy cập dữ liệu hoặc tài nguyên theo thứ tự không nhất quán
  • Bế tắc (Deadlocks), trong đó hai luồng đang chờ đợi nhau, ngăn cản cả hai luồng tiếp tục
  • Lỗi chỉ xảy ra trong một số tình huống nhất định và khó tái tạo và sửa chữa một cách đáng tin cậy

Rust cố gắng giảm thiểu các tác động tiêu cực của việc sử dụng luồng, nhưng lập trình trong bối cảnh đa luồng vẫn cần suy nghĩ cẩn thận và yêu cầu cấu trúc mã khác với cấu trúc trong các chương trình chạy trong một luồng duy nhất.

Các ngôn ngữ lập trình triển khai luồng theo một vài cách khác nhau, và nhiều hệ điều hành cung cấp một API mà ngôn ngữ có thể gọi để tạo luồng mới. Thư viện chuẩn của Rust sử dụng mô hình 1:1 cho việc triển khai luồng, theo đó một chương trình sử dụng một luồng hệ điều hành cho mỗi một luồng ngôn ngữ. Có các crate triển khai các mô hình luồng khác có sự đánh đổi khác nhau so với mô hình 1:1. (Hệ thống async của Rust, mà chúng ta sẽ thấy trong chương tiếp theo, cung cấp một cách tiếp cận khác cho đồng thời.)

Tạo Luồng Mới với spawn

Để tạo một luồng mới, chúng ta gọi hàm thread::spawn và truyền cho nó một closure (chúng ta đã nói về closure trong Chương 13) chứa mã mà chúng ta muốn chạy trong luồng mới. Ví dụ trong Listing 16-1 in một số văn bản từ luồng chính và văn bản khác từ một luồng mới:

use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

Lưu ý rằng khi luồng chính của một chương trình Rust hoàn thành, tất cả các luồng được tạo ra đều bị tắt, cho dù chúng đã hoàn thành chạy hay chưa. Đầu ra từ chương trình này có thể hơi khác nhau mỗi lần, nhưng nó sẽ trông giống như sau:

hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!

Các lệnh gọi đến thread::sleep buộc một luồng dừng thực thi trong một khoảng thời gian ngắn, cho phép một luồng khác chạy. Các luồng có thể sẽ lần lượt thay phiên nhau, nhưng điều đó không được đảm bảo: nó phụ thuộc vào cách hệ điều hành của bạn lên lịch các luồng. Trong lần chạy này, luồng chính in trước, mặc dù câu lệnh in từ luồng được tạo ra xuất hiện đầu tiên trong mã. Và mặc dù chúng ta bảo luồng được tạo ra in cho đến khi i9, nó chỉ đạt đến 5 trước khi luồng chính tắt.

Nếu bạn chạy mã này và chỉ thấy đầu ra từ luồng chính, hoặc không thấy bất kỳ sự chồng chéo nào, hãy thử tăng số lượng trong các phạm vi để tạo thêm cơ hội cho hệ điều hành chuyển đổi giữa các luồng.

Chờ Tất Cả Các Luồng Hoàn Thành Bằng Cách Sử Dụng join Handles

Mã trong Listing 16-1 không chỉ dừng luồng được tạo ra sớm hơn dự kiến trong hầu hết thời gian do luồng chính kết thúc, mà còn vì không có đảm bảo về thứ tự chạy của các luồng, chúng ta cũng không thể đảm bảo rằng luồng được tạo ra sẽ có cơ hội chạy!

Chúng ta có thể khắc phục vấn đề luồng được tạo ra không chạy hoặc kết thúc sớm bằng cách lưu giá trị trả về của thread::spawn trong một biến. Kiểu trả về của thread::spawnJoinHandle<T>. Một JoinHandle<T> là một giá trị được sở hữu mà, khi chúng ta gọi phương thức join trên nó, sẽ chờ cho luồng của nó kết thúc. Listing 16-2 hiển thị cách sử dụng JoinHandle<T> của luồng mà chúng ta đã tạo trong Listing 16-1 và cách gọi join để đảm bảo luồng được tạo ra hoàn thành trước khi main thoát.

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}

Gọi join trên handle sẽ chặn luồng hiện đang chạy cho đến khi luồng được đại diện bởi handle kết thúc. Chặn (Blocking) một luồng có nghĩa là luồng đó bị ngăn không cho thực hiện công việc hoặc thoát. Bởi vì chúng ta đã đặt lệnh gọi đến join sau vòng lặp for của luồng chính, việc chạy Listing 16-2 sẽ tạo ra đầu ra tương tự như sau:

hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

Hai luồng tiếp tục thay phiên nhau, nhưng luồng chính chờ đợi vì lời gọi đến handle.join() và không kết thúc cho đến khi luồng được tạo ra hoàn thành.

Nhưng hãy xem điều gì xảy ra khi chúng ta thay vào đó di chuyển handle.join() trước vòng lặp for trong main, như thế này:

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    handle.join().unwrap();

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

Luồng chính sẽ chờ luồng được tạo ra hoàn thành và sau đó chạy vòng lặp for của nó, vì vậy đầu ra sẽ không còn xen kẽ nữa, như hiển thị ở đây:

hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!

Các chi tiết nhỏ, chẳng hạn như nơi join được gọi, có thể ảnh hưởng đến việc các luồng của bạn có chạy cùng lúc hay không.

Sử Dụng Closure move với Luồng

Chúng ta thường sử dụng từ khóa move với closure được truyền cho thread::spawn bởi vì closure sau đó sẽ lấy quyền sở hữu các giá trị mà nó sử dụng từ môi trường, do đó chuyển quyền sở hữu của các giá trị đó từ một luồng này sang luồng khác. Trong "Capturing the Environment With Closures" trong Chương 13, chúng ta đã thảo luận về move trong bối cảnh closure. Bây giờ, chúng ta sẽ tập trung nhiều hơn vào sự tương tác giữa movethread::spawn.

Lưu ý trong Listing 16-1 rằng closure mà chúng ta truyền cho thread::spawn không nhận tham số nào: chúng ta không sử dụng bất kỳ dữ liệu nào từ luồng chính trong mã của luồng được tạo ra. Để sử dụng dữ liệu từ luồng chính trong luồng được tạo ra, closure của luồng được tạo ra phải bắt các giá trị mà nó cần. Listing 16-3 hiển thị một nỗ lực tạo một vectơ trong luồng chính và sử dụng nó trong luồng được tạo ra. Tuy nhiên, điều này chưa hoạt động, như bạn sẽ thấy trong một lúc nữa.

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}

Closure sử dụng v, vì vậy nó sẽ bắt v và làm cho nó trở thành một phần của môi trường của closure. Bởi vì thread::spawn chạy closure này trong một luồng mới, chúng ta nên có thể truy cập v bên trong luồng mới đó. Nhưng khi chúng ta biên dịch ví dụ này, chúng ta nhận được lỗi sau:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Here's a vector: {v:?}");
  |                                     - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let handle = thread::spawn(|| {
  |  __________________^
7 | |         println!("Here's a vector: {v:?}");
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Rust suy luận cách bắt v, và bởi vì println! chỉ cần một tham chiếu đến v, closure cố gắng mượn v. Tuy nhiên, có một vấn đề: Rust không thể biết luồng được tạo ra sẽ chạy trong bao lâu, vì vậy nó không biết liệu tham chiếu đến v sẽ luôn hợp lệ hay không.

Listing 16-4 cung cấp một kịch bản mà có khả năng cao hơn có một tham chiếu đến v mà sẽ không hợp lệ:

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    drop(v); // oh no!

    handle.join().unwrap();
}

Nếu Rust cho phép chúng ta chạy mã này, có khả năng luồng được tạo ra sẽ ngay lập tức bị đặt vào nền mà không chạy chút nào. Luồng được tạo ra có một tham chiếu đến v bên trong, nhưng luồng chính ngay lập tức làm rơi v, sử dụng hàm drop mà chúng ta đã thảo luận trong Chương 15. Sau đó, khi luồng được tạo ra bắt đầu thực thi, v không còn hợp lệ nữa, vì vậy một tham chiếu đến nó cũng không hợp lệ. Ôi không!

Để sửa lỗi trình biên dịch trong Listing 16-3, chúng ta có thể sử dụng lời khuyên từ thông báo lỗi:

help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

Bằng cách thêm từ khóa move trước closure, chúng ta buộc closure lấy quyền sở hữu các giá trị mà nó đang sử dụng thay vì để Rust suy luận rằng nó nên mượn các giá trị. Sửa đổi Listing 16-3 như trong Listing 16-5 sẽ biên dịch và chạy như chúng ta dự định.

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}

Chúng ta có thể bị cám dỗ để thử cùng một điều để sửa mã trong Listing 16-4 nơi luồng chính gọi drop bằng cách sử dụng một closure move. Tuy nhiên, cách sửa này sẽ không hoạt động vì những gì Listing 16-4 đang cố gắng làm bị cấm vì lý do khác. Nếu chúng ta thêm move vào closure, chúng ta sẽ di chuyển v vào môi trường của closure, và chúng ta không thể gọi drop trên nó trong luồng chính nữa. Chúng ta sẽ nhận được lỗi trình biên dịch này thay thế:

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
4  |     let v = vec![1, 2, 3];
   |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
5  |
6  |     let handle = thread::spawn(move || {
   |                                ------- value moved into closure here
7  |         println!("Here's a vector: {v:?}");
   |                                     - variable moved due to use in closure
...
10 |     drop(v); // oh no!
   |          ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Các quy tắc sở hữu của Rust đã cứu chúng ta một lần nữa! Chúng ta nhận được lỗi từ mã trong Listing 16-3 vì Rust đang bảo thủ và chỉ mượn v cho luồng, điều đó có nghĩa là luồng chính có thể theo lý thuyết làm mất hiệu lực tham chiếu của luồng được tạo ra. Bằng cách nói với Rust để chuyển quyền sở hữu của v cho luồng được tạo ra, chúng ta đang đảm bảo với Rust rằng luồng chính sẽ không sử dụng v nữa. Nếu chúng ta thay đổi Listing 16-4 theo cùng một cách, chúng ta sẽ vi phạm các quy tắc sở hữu khi chúng ta cố gắng sử dụng v trong luồng chính. Từ khóa move ghi đè mặc định bảo thủ của Rust về việc mượn; nó không cho phép chúng ta vi phạm các quy tắc sở hữu.

Bây giờ chúng ta đã đề cập đến những gì là luồng và các phương thức được cung cấp bởi API luồng, hãy xem một số tình huống mà chúng ta có thể sử dụng luồng.