Bỏ qua để đến nội dung

Lập trình đồng thời trong Rust

Lập trình đồng thời (concurrency), tức là chạy nhiều phần việc cùng lúc, là chỗ mà nhiều ngôn ngữ trở nên thật sự nguy hiểm. Hai luồng (thread) cùng đụng vào một dữ liệu tại cùng một thời điểm có thể làm hỏng nó theo những cách cực kỳ khó lần ra. Đây đúng là loại lỗi mà các quy tắc mượn (borrowing) được thiết kế để ngăn chặn, và sự bảo vệ đó áp dụng thẳng cho cả các luồng.

use std::thread;
fn main() {
let handle = thread::spawn(|| {
for i in 1..=3 {
println!("luồng: {i}");
}
});
for i in 1..=3 {
println!("main: {i}");
}
handle.join().unwrap(); // chờ luồng chạy xong
}
▶ Tự chạy thử

thread::spawn nhận một closure và chạy nó trên một luồng mới, song song với mọi thứ khác. .join() chặn lại cho tới khi luồng đó chạy xong. Không có nó, main có thể kết thúc trước khi luồng mới kịp chạy.

Closure truyền cho thread::spawn cần sở hữu hoàn toàn mọi dữ liệu nó dùng, vì nó có thể chạy sau khi phạm vi (scope) ban đầu đã kết thúc. Thêm từ khóa move để buộc điều đó:

use std::thread;
fn main() {
let message = String::from("xin chào từ một luồng");
let handle = thread::spawn(move || {
println!("{message}");
});
handle.join().unwrap();
}

Kết quả:

xin chào từ một luồng
▶ Tự chạy thử

Không có move, Rust sẽ không biên dịch đoạn này: nó không thể đảm bảo message vẫn còn hợp lệ vào lúc luồng mới thật sự chạy. move chuyển quyền sở hữu message vào closure, cũng chính là “move” bạn đã thấy ở trang Quyền sở hữu, chỉ là lần này giao cho một luồng thay vì một biến khác.

Chia sẻ dữ liệu giữa các luồng một cách an toàn

Phần tiêu đề “Chia sẻ dữ liệu giữa các luồng một cách an toàn”

Vì tham chiếu thông thường không dùng được giữa các luồng (chúng không thể sống lâu hơn phạm vi đã tạo ra chúng), việc chia sẻ dữ liệu giữa các luồng kết hợp hai thứ bạn đã gặp: Arc (từ trang Con trỏ thông minh) để chia sẻ quyền sở hữu, và một Mutex để đảm bảo mỗi lúc chỉ có một luồng đụng vào dữ liệu:

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Kết quả: {}", *counter.lock().unwrap());
}

Kết quả:

Kết quả: 5
▶ Tự chạy thử

counter.lock() chờ tới khi có thể giành quyền truy cập độc quyền vào giá trị một cách an toàn, thực hiện cập nhật, rồi tự động nhả ra. Năm luồng cùng tăng một bộ đếm, và nhờ Mutex, chúng không bao giờ giẫm chân nhau: bạn luôn nhận được 5, chứ không phải một số nhỏ hơn do một lần cập nhật bị mất.

Khóa dữ liệu dùng chung là một cách để các luồng phối hợp với nhau. Cách phổ biến còn lại là không chia sẻ gì cả, mà để các luồng gửi tin nhắn cho nhau. Đó là nội dung của trang Channel.

Vì sao Rust gọi đây là “fearless concurrency”

Phần tiêu đề “Vì sao Rust gọi đây là “fearless concurrency””

Trong hầu hết các ngôn ngữ, không có gì ngăn bạn quên khóa một Mutex, hoặc vô tình chia sẻ giữa các luồng một tham chiếu không nên chia sẻ. Trình biên dịch của Rust thật sự bắt buộc điều này: code có thể gây ra data race giữa các luồng đơn giản là không biên dịch được. Đó chính là hệ thống ownership và borrowing bạn đã học ở phần trước, chỉ là áp dụng cho các luồng thay vì cho một phạm vi đơn lẻ. Vì vậy người ta gọi nó là “lập trình đồng thời không sợ hãi”.

Cộng các số từ 1 đến 100 bằng hai luồng: một luồng cộng từ 1 đến 50, luồng kia cộng từ 51 đến 100. Sau đó main cộng hai kết quả lại. Gợi ý: closure của một luồng có thể trả về một giá trị, và handle.join().unwrap() trả nó lại cho bạn.

Xem lời giải
use std::thread;
fn main() {
let first = thread::spawn(|| {
let total: u32 = (1..=50).sum();
total
});
let second = thread::spawn(|| {
let total: u32 = (51..=100).sum();
total
});
let a = first.join().unwrap();
let b = second.join().unwrap();
println!("{a} + {b} = {}", a + b);
}

Kết quả:

1275 + 3775 = 5050
▶ Tự chạy thử

Các closure này không dùng biến nào bên ngoài, nên chúng không cần move. Hai luồng chạy cùng lúc, và join() chờ từng luồng rồi trả lại kết quả của nó.

Khởi chạy ba luồng. Mỗi luồng thêm một dòng như "luồng 2 đã ghé qua" vào một Vec<String> dùng chung. Sau khi tất cả các luồng chạy xong, in các dòng theo thứ tự đã sắp xếp. Gợi ý: dùng Arc<Mutex<Vec<String>>> như ví dụ bộ đếm, và sắp xếp một bản sao ở cuối, vì các luồng có thể xong theo bất kỳ thứ tự nào.

Xem lời giải
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let book = Arc::new(Mutex::new(Vec::new()));
let mut handles = vec![];
for id in 1..=3 {
let book = Arc::clone(&book);
handles.push(thread::spawn(move || {
book.lock().unwrap().push(format!("luồng {id} đã ghé qua"));
}));
}
for handle in handles {
handle.join().unwrap();
}
let mut lines = book.lock().unwrap().clone();
lines.sort();
for line in lines {
println!("{line}");
}
}

Kết quả:

luồng 1 đã ghé qua
luồng 2 đã ghé qua
luồng 3 đã ghé qua
▶ Tự chạy thử

Hãy xóa dòng lines.sort() rồi chạy vài lần: thứ tự có thể thay đổi. Sắp xếp là cách để lần nào cũng có cùng một kết quả.

  • thread::spawn(closure) chạy một closure trên một luồng mới; .join() chờ nó chạy xong.
  • Closure truyền cho một luồng thường cần move để lấy trọn quyền sở hữu dữ liệu nó dùng.
  • Chia sẻ dữ liệu giữa các luồng bằng Arc (chia sẻ quyền sở hữu) kết hợp với Mutex (truy cập độc quyền): counter.lock() cho quyền truy cập an toàn, mỗi lần một luồng.
  • Trình biên dịch từ chối code có thể gây data race ngay lúc biên dịch, không phải lúc chạy.

Kiểm tra nhanh

1. Vì sao closure truyền cho thread::spawn thường cần từ khóa move?

2. Arc<Mutex<T>> dùng để làm gì?

3. Điều gì có thể xảy ra nếu bạn quên gọi .join() trên handle của một luồng?

4. Vì sao thứ tự các dòng "main:" và "luồng:" có thể khác nhau giữa các lần chạy cùng một chương trình?

Điểm: 0 / 4