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

Trạng thái dùng chung trong Rust: RwLock, atomic, scoped thread

Ở trang Lập trình đồng thời, bạn đã chia sẻ một bộ đếm giữa các luồng (thread) bằng Arc<Mutex<T>>: Arc cho phép nhiều luồng cùng sở hữu giá trị, còn Mutex chỉ cho một luồng đụng vào nó tại một thời điểm. Ở trang Channel, bạn tránh hẳn việc chia sẻ bằng cách gửi tin nhắn. Cả hai đều là công cụ tuyệt vời, và bạn có thể xây gần như mọi thứ với chúng.

Nhưng hãy nghĩ về Mutex như chiếc chìa khóa duy nhất của phòng vệ sinh văn phòng. Nó hoạt động, nhưng hơi quá tay nếu tất cả những gì bạn muốn là đọc bảng thông báo ngoài hành lang. Thư viện chuẩn của Rust còn vài công cụ khác, mỗi cái hợp hơn cho một việc nhất định. Ở trang này bạn sẽ gặp bốn công cụ: scoped thread, RwLock, atomic và OnceLock/LazyLock. Bạn cũng sẽ học cách tránh loại lỗi duy nhất mà trình biên dịch không bắt được giúp bạn: deadlock (khóa chết).

Với thread::spawn, bạn thường cần move và hay cần cả Arc. Đó là vì một luồng được tạo ra có thể vẫn chạy tiếp sau khi main đã đi tiếp, nên Rust không cho nó mượn các biến cục bộ của bạn.

std::thread::scope giải quyết chuyện này bằng một lời hứa: mọi luồng được khởi chạy bên trong scope đều kết thúc trước khi scope kết thúc. Vì các luồng không thể sống lâu hơn dữ liệu của bạn, chúng được phép mượn nó một cách đơn giản. Không Arc, không move, không clone.

use std::thread;
fn main() {
let shop = "Tiệm góc phố";
let sales = vec![12, 7, 30, 5, 18, 22, 9, 14];
let (morning, afternoon) = sales.split_at(4);
let (am, pm) = thread::scope(|s| {
let am = s.spawn(|| morning.iter().sum::<i32>());
let pm = s.spawn(|| afternoon.iter().sum::<i32>());
(am.join().unwrap(), pm.join().unwrap())
});
println!("{shop}: buổi sáng {am}, buổi chiều {pm}, cả ngày {}", am + pm);
println!("vẫn là của tôi: {:?}", sales);
}

Kết quả:

Tiệm góc phố: buổi sáng 54, buổi chiều 63, cả ngày 117
vẫn là của tôi: [12, 7, 30, 5, 18, 22, 9, 14]
▶ Tự chạy thử

Điều đang diễn ra:

  • thread::scope(|s| { ... }) mở scope. Bạn khởi chạy luồng bằng s.spawn(...) thay vì thread::spawn(...).
  • Hai closure mượn morning và afternoon, vốn chỉ là các slice của sales. Không có gì bị sao chép.
  • Thứ mà closure của scope trả về (ở đây là một tuple gồm hai tổng) trở thành giá trị của thread::scope.
  • Sau scope, sales vẫn thuộc về main, nên bạn có thể tiếp tục dùng nó.

Kể cả khi bạn quên gọi join(), scope vẫn chờ mọi luồng của nó ở dấu } đóng. Đó chính là lời hứa giúp việc mượn trở nên an toàn.

RwLock: nhiều người đọc hoặc một người ghi

Phần tiêu đề “RwLock: nhiều người đọc hoặc một người ghi”

Hãy hình dung tấm bảng menu trong một quán cà phê. Nhiều khách có thể đọc nó cùng lúc, chẳng sao cả. Nhưng khi chủ quán đổi giá, mọi người phải chờ một chút cho tới khi bảng mới được treo lên.

Đó là một RwLock (“read-write lock”, khóa đọc-ghi). Nó có hai lối vào:

  • read() cho bạn một guard đọc dùng chung. Nhiều luồng có thể giữ nó cùng lúc.
  • write() cho bạn một guard ghi độc quyền. Trong khi một người ghi đang giữ nó, không ai khác đọc hay ghi được.

Mutex không phân biệt điều đó: ngay cả hai người đọc cũng phải lần lượt. Đây là một cache cài đặt mà vài bộ xử lý yêu cầu cùng đọc, và một quản trị viên cập nhật một lần:

use std::sync::{Arc, RwLock};
use std::thread;
struct Settings {
theme: String,
max_upload_mb: u32,
}
fn main() {
let settings = Arc::new(RwLock::new(Settings {
theme: String::from("light"),
max_upload_mb: 10,
}));
// Bốn bộ xử lý yêu cầu đọc cài đặt cùng lúc.
let mut handles = Vec::new();
for id in 1..=4 {
let settings = Arc::clone(&settings);
handles.push(thread::spawn(move || {
let s = settings.read().unwrap(); // dùng chung: người khác cũng đọc được
format!("yêu cầu {id}: giao diện={}, giới hạn={}MB", s.theme, s.max_upload_mb)
}));
}
for handle in handles {
println!("{}", handle.join().unwrap());
}
// Một quản trị viên đổi cài đặt một lần.
{
let mut s = settings.write().unwrap(); // độc quyền: mọi người khác phải chờ
s.theme = String::from("dark");
s.max_upload_mb = 25;
} // guard ghi bị drop ở đây, nên khóa lại được giải phóng
let s = settings.read().unwrap();
println!("sau khi cập nhật: giao diện={}, giới hạn={}MB", s.theme, s.max_upload_mb);
}

Kết quả:

yêu cầu 1: giao diện=light, giới hạn=10MB
yêu cầu 2: giao diện=light, giới hạn=10MB
yêu cầu 3: giao diện=light, giới hạn=10MB
yêu cầu 4: giao diện=light, giới hạn=10MB
sau khi cập nhật: giao diện=dark, giới hạn=25MB
▶ Tự chạy thử

Các luồng có thể xong theo thứ tự bất kỳ, nhưng kết quả lúc nào cũng giống nhau. Mỗi luồng trả về dòng của nó thay vì tự in ra, và main in chúng theo thứ tự nó join.

Khi nào RwLock hơn Mutex? Khi việc đọc diễn ra thường xuyên, việc ghi hiếm khi xảy ra, và mỗi lần đọc mất một chút thời gian (chẳng hạn tìm trong một cache lớn). Khi đó những người đọc thật sự chạy song song với nhau.

Khi nào thì không? Nếu mỗi lần đọc rất nhỏ, hoặc việc ghi diễn ra liên tục, thì RwLock chỉ là một Mutex phức tạp hơn. Nó phải làm thêm việc sổ sách, và người đọc với người ghi phần lớn vẫn phải chờ nhau. Khi không chắc, hãy bắt đầu với Mutex. Nó đơn giản hơn, và bạn có thể đổi sau nếu đo đạc cho thấy khóa là điểm nghẽn.

Với một con số đơn lẻ hoặc một cờ đúng/sai, ngay cả một cái khóa cũng là quá mức cần thiết. Các kiểu atomic như AtomicUsize, AtomicI64 và AtomicBool có thể được nhiều luồng thay đổi an toàn cùng lúc mà không cần khóa nào. CPU khiến mỗi thao tác diễn ra trong một bước không thể chia nhỏ, nên không lần cập nhật nào bị mất.

Ở đây, 8 luồng mỗi luồng ghi nhận 1000 lượt xem trang. Chúng cũng đếm phiếu bầu: một phiếu thuận, rồi ba phiếu chống, lặp đi lặp lại. Tổng phiếu sẽ âm, đó là lý do nó là AtomicI64 chứ không phải một kiểu không dấu:

use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
use std::thread;
fn main() {
let page_views = AtomicUsize::new(0);
let net_votes = AtomicI64::new(0);
thread::scope(|s| {
for _ in 0..8 {
s.spawn(|| {
for n in 0..1000 {
page_views.fetch_add(1, Ordering::Relaxed);
if n % 4 == 0 {
net_votes.fetch_add(1, Ordering::Relaxed); // phiếu thuận
} else {
net_votes.fetch_sub(1, Ordering::Relaxed); // phiếu chống
}
}
});
}
}); // cả 8 luồng đều đã xong ở đây
println!("lượt xem trang: {}", page_views.load(Ordering::Relaxed));
println!("tổng phiếu: {}", net_votes.load(Ordering::Relaxed));
}

Kết quả:

lượt xem trang: 8000
tổng phiếu: -4000
▶ Tự chạy thử

Để ý rằng page_views không phải mut. Atomic thay đổi qua một tham chiếu dùng chung, đó chính là lý do nhiều luồng dùng được chúng cùng lúc. Các phương thức bạn sẽ dùng nhiều nhất:

  • fetch_add(n, ordering) / fetch_sub(n, ordering) cộng hoặc trừ, và trả lại giá trị cũ.
  • load(ordering) đọc giá trị hiện tại.
  • store(value, ordering) ghi đè lên nó.

Mỗi thao tác atomic đều yêu cầu một Ordering. Nó cho CPU và trình biên dịch biết thao tác này phải giữ đúng thứ tự với các thao tác bộ nhớ xung quanh chặt chẽ tới mức nào. Câu chuyện đầy đủ khá sâu, nhưng là người mới, bạn chỉ cần hai quy tắc:

  • Khi không chắc, hãy dùng Ordering::SeqCst. Đó là lựa chọn chặt chẽ và an toàn nhất.
  • Ordering::Relaxed là đủ cho một bộ đếm đơn giản mà bạn chỉ đọc ở cuối, như ví dụ trên. Tổng vẫn hoàn toàn chính xác.

Nếu một ngày nào đó bạn muốn đi xa hơn, cuốn sách miễn phí Rust Atomics and Locks (có link ở cuối trang) giải thích các ordering rất rõ ràng.

Một AtomicBool là tín hiệu “làm ơn dừng lại” rất tốt. Ở đây một worker chạy nền tiếp tục tự động lưu cho tới khi main bật cờ:

use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
fn main() {
let stop = AtomicBool::new(false);
let report = thread::scope(|s| {
let worker = s.spawn(|| {
while !stop.load(Ordering::SeqCst) {
// giả vờ tự động lưu tài liệu ở đây
thread::sleep(Duration::from_millis(5));
}
"worker tự động lưu đã dừng gọn gàng"
});
thread::sleep(Duration::from_millis(30)); // main làm việc khác
stop.store(true, Ordering::SeqCst); // yêu cầu worker dừng lại
worker.join().unwrap()
});
println!("{report}");
}

Kết quả:

worker tự động lưu đã dừng gọn gàng
▶ Tự chạy thử

Worker kiểm tra cờ mỗi vòng lặp. Nó làm xong bước hiện tại rồi tự thoát, nhẹ nhàng hơn nhiều so với việc giết một luồng khi nó đang lưu tệp dở dang.

Đôi khi bạn muốn một giá trị toàn cục mà mọi luồng đều đọc được: một bảng tra cứu, tên ứng dụng, một cấu hình được nạp lúc khởi động. Phần khó là tạo ra nó. Nếu hai luồng cùng cố tạo nó vào đúng một thời điểm thì sao?

std::sync::LazyLock và std::sync::OnceLock giải quyết chuyện này. Giá trị được tạo đúng một lần, vào lần đầu tiên có ai đó cần nó. Nếu các luồng khác hỏi trong lúc nó đang được tạo, chúng chỉ việc chờ, rồi mọi người cùng dùng chung một giá trị.

use std::collections::HashMap;
use std::sync::{LazyLock, OnceLock};
use std::thread;
// LazyLock: công thức được viết ngay tại đây, và chạy ở lần dùng đầu tiên.
static CURRENCIES: LazyLock<HashMap<&str, &str>> = LazyLock::new(|| {
println!("đang dựng bảng tiền tệ...");
HashMap::from([("US", "USD"), ("JP", "JPY"), ("DE", "EUR"), ("BR", "BRL")])
});
// OnceLock: bắt đầu rỗng, và bạn quyết định cách điền nó ở nơi bạn dùng.
static STORE_NAME: OnceLock<String> = OnceLock::new();
fn store_name() -> &'static str {
STORE_NAME.get_or_init(|| {
std::env::var("STORE_NAME").unwrap_or_else(|_| String::from("Rusty Shop"))
})
}
fn main() {
let mut handles = Vec::new();
for country in ["US", "JP", "DE", "BR"] {
handles.push(thread::spawn(move || {
format!("{} ở {country} tính tiền bằng {}", store_name(), CURRENCIES[country])
}));
}
for handle in handles {
println!("{}", handle.join().unwrap());
}
}

Kết quả:

đang dựng bảng tiền tệ...
Rusty Shop ở US tính tiền bằng USD
Rusty Shop ở JP tính tiền bằng JPY
Rusty Shop ở DE tính tiền bằng EUR
Rusty Shop ở BR tính tiền bằng BRL
▶ Tự chạy thử

Bốn luồng cùng muốn có bảng vào khoảng cùng một lúc, vậy mà “đang dựng bảng tiền tệ…” chỉ được in một lần. Và vì đây là các giá trị static sống suốt chương trình, các luồng hoàn toàn không cần Arc.

Nên chọn cái nào?

  • LazyLock khi bạn biết cách tạo giá trị ngay tại chỗ khai báo. Nó có cảm giác như một biến toàn cục bình thường.
  • OnceLock khi giá trị được quyết định sau, ví dụ từ một cờ dòng lệnh. Bạn điền nó bằng get_or_init(...) hoặc set(...) (và set sẽ thất bại một cách lịch sự nếu nó đã được điền).

Cả hai đều thuộc thư viện chuẩn. LazyLock xuất hiện từ Rust 1.80, nên code cũ hơn thường dùng crate once_cell hoặc lazy_static cho cùng việc này.

Trình biên dịch ngăn được data race, nhưng có một lỗi đồng thời mà nó không bắt được: deadlock. Đó là khi các luồng chờ nhau mãi mãi và chương trình của bạn cứ thế đứng im. Không lỗi, không sập, không gì cả.

Công thức kinh điển là hai cái khóa được lấy theo thứ tự khác nhau. Hãy tưởng tượng hai người, mỗi người đều cần cả cây bút lẫn cuốn sổ. Một người cầm lấy bút, người kia cầm lấy sổ, và mỗi người lịch sự chờ người kia buông ra. Mãi mãi.

// Luồng A // Luồng B
let from = alice.lock().unwrap(); let from = bob.lock().unwrap();
let to = bob.lock().unwrap(); let to = alice.lock().unwrap();
// A giữ alice, chờ bob. // B giữ bob, chờ alice. Kẹt cứng!

Ba thói quen giúp bạn an toàn:

  1. Luôn lấy khóa theo cùng một thứ tự. Nếu mọi luồng đều khóa alice trước bob (ví dụ, sắp theo mã tài khoản), vòng chờ không thể xảy ra.
  2. Giữ phạm vi khóa ngắn. Chỉ giữ khóa đủ lâu để đọc hoặc sửa dữ liệu. Đừng bao giờ giữ nó trong lúc làm việc chậm như gọi mạng, ghi tệp hay một phép tính dài. Mọi luồng khác sẽ bị kẹt chờ suốt thời gian đó.
  3. Drop guard sớm. Một guard mở khóa khi nó bị drop. Bạn có thể kết thúc nó bằng một khối bên trong { ... } (như người ghi RwLock ở trên) hoặc tự gọi drop(guard).

Đây là thói quen thứ 3 trong thực tế. Mỗi worker lấy một việc từ hàng đợi chung, mở khóa hàng đợi, và chỉ sau đó mới làm phần chậm. (Tên công việc được giữ bằng tiếng Anh vì kết quả được sắp xếp theo chữ cái.)

use std::sync::Mutex;
use std::thread;
fn main() {
let queue = Mutex::new(vec!["resize photo", "send email", "build report"]);
let finished = Mutex::new(Vec::new());
thread::scope(|s| {
for _ in 0..3 {
s.spawn(|| {
let mut jobs = queue.lock().unwrap();
let job = jobs.pop();
drop(jobs); // mở khóa hàng đợi TRƯỚC phần chậm
if let Some(job) = job {
let result = format!("done: {job}"); // hãy tưởng tượng việc này mất vài giây
finished.lock().unwrap().push(result);
}
});
}
});
// Scope đã kết thúc, nên không luồng nào dùng `finished` nữa.
let mut done = finished.into_inner().unwrap();
done.sort(); // các luồng xong theo thứ tự bất kỳ
for line in done {
println!("{line}");
}
}

Kết quả:

done: build report
done: resize photo
done: send email
▶ Tự chạy thử

Không có drop(jobs), guard sẽ sống tới cuối closure. Khi đó phần việc “chậm” sẽ diễn ra trong lúc hàng đợi vẫn bị khóa, và ba worker sẽ chạy lần lượt từng cái thay vì song song.

Có một trường hợp khó thấy cần biết: trong while let Some(job) = queue.lock().unwrap().pop() { ... }, guard sống suốt toàn bộ thân vòng lặp. Vì vậy hàng đợi vẫn bị khóa trong lúc bạn xử lý công việc. Hãy lấy công việc ra trong một câu lệnh let riêng trước, như ví dụ đã làm.

Bạn muốn… Hãy dùng
Gửi công việc hoặc kết quả từ luồng này sang luồng khác Một channel (mpsc)
Cho nhiều luồng cùng đọc và sửa một giá trị Arc<Mutex<T>>
Chia sẻ dữ liệu được đọc nhiều và ít khi thay đổi Arc<RwLock<T>>
Đếm, hoặc chia sẻ một cờ bật/tắt đơn giản Atomic (AtomicUsize, AtomicBool, …)
Tạo một giá trị toàn cục một lần, rồi chỉ đọc nó LazyLock hoặc OnceLock
Cho các luồng mượn dữ liệu cục bộ, không cần Arc thread::scope

Những công cụ này kết hợp thoải mái với nhau. Các ví dụ trên trang này đã kết hợp thread::scope với atomic và với Mutex. Hãy chọn công cụ đơn giản nhất phù hợp, và đừng lo chọn “sai”: đổi lại sau thường chỉ là một thay đổi nhỏ.

Mượn một biến cục bộ trong thread::spawn. Đoạn này trông vô hại:

use std::thread;
fn main() {
let sales = vec![12, 7, 30, 5];
let handle = thread::spawn(|| {
let total: i32 = sales.iter().sum();
total
});
println!("total: {}", handle.join().unwrap());
}
error[E0373]: closure may outlive the current function, but it borrows `sales`, which is owned by the current function
--> src/main.rs:6:32
|
6 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `sales`
7 | let total: i32 = sales.iter().sum();
| ----- `sales` is borrowed here
|
help: to force the closure to take ownership of `sales` (and any other referenced variables), use the `move` keyword

Trình biên dịch (bằng tiếng Anh) nói rằng closure có thể sống lâu hơn hàm hiện tại, trong khi nó lại mượn sales. Bạn biết mình gọi join() ngay sau đó, nhưng thread::spawn không thể biết điều này. Với nó, luồng có thể chạy mãi mãi. Bạn có hai cách sửa: thêm move (khi đó main không dùng được sales nữa), hoặc dùng thread::scope, thứ có thể mượn:

let total: i32 = thread::scope(|s| s.spawn(|| sales.iter().sum()).join().unwrap());

Quên Arc. Không có Arc, closure move của luồng đầu tiên lấy mất Mutex, và không còn gì cho luồng tiếp theo:

use std::sync::Mutex;
use std::thread;
fn main() {
let counter = Mutex::new(0);
let mut handles = vec![];
for _ in 0..3 {
handles.push(thread::spawn(move || {
*counter.lock().unwrap() += 1;
}));
}
for handle in handles {
handle.join().unwrap();
}
}
error[E0382]: use of moved value: `counter`
--> src/main.rs:9:36
|
5 | let counter = Mutex::new(0);
| ------- move occurs because `counter` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
8 | for _ in 0..3 {
| ------------- inside of this loop
9 | handles.push(thread::spawn(move || {
| ^^^^^^^ value moved into closure here, in previous iteration of loop

Thông báo nói rằng counter đã bị move vào closure ở lần lặp trước. Hãy bọc nó trong Arc::new(...) và clone Arc ở đầu vòng lặp, như ở trang Lập trình đồng thời. Hoặc dùng thread::scope và bỏ move. Nếu bạn dùng Rc thay cho Arc, bạn sẽ nhận được error[E0277]: `Rc<std::sync::Mutex<i32>>` cannot be sent between threads safely. Rc chỉ dành cho một luồng (xem Con trỏ thông minh).

Tạo một Vec gồm các số từ 1 đến 1000. Dùng thread::scope để chia nó thành 4 phần, mỗi phần 250 số, và cộng mỗi phần trong một luồng riêng. In tổng của từng phần và tổng cộng. Gợi ý: numbers.chunks(250) cho bạn các slice. Gom các handle vào một Vec, rồi join chúng theo thứ tự.

Xem lời giải
use std::thread;
fn main() {
let numbers: Vec<u64> = (1..=1000).collect();
let sums: Vec<u64> = thread::scope(|s| {
let mut handles = Vec::new();
for chunk in numbers.chunks(250) {
handles.push(s.spawn(move || chunk.iter().sum::<u64>()));
}
handles.into_iter().map(|h| h.join().unwrap()).collect()
});
println!("tổng từng phần: {:?}", sums);
println!("tổng cộng: {}", sums.iter().sum::<u64>());
}

Kết quả:

tổng từng phần: [31375, 93875, 156375, 218875]
tổng cộng: 500500
▶ Tự chạy thử

move ở đây chỉ chuyển chunk, một slice mượn nhỏ, vào mỗi luồng. Bản thân vector vẫn được mượn chứ không bị move, nên bạn có thể dùng lại numbers sau scope.

Một cửa hàng trực tuyến gán cho mỗi đơn hàng một mã số, bắt đầu từ 1000. Khởi chạy 4 luồng, mỗi luồng lấy 3 mã đơn hàng từ một AtomicU64 dùng chung. Gom cả 12 mã, sắp xếp rồi in ra. Gợi ý: fetch_add(1, Ordering::SeqCst) trả về giá trị trước khi cộng, nên mỗi mã được phát ra đúng một lần.

Xem lời giải
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
fn main() {
let next_order = AtomicU64::new(1000);
let mut numbers: Vec<u64> = thread::scope(|s| {
let mut handles = Vec::new();
for _ in 0..4 {
handles.push(s.spawn(|| {
(0..3)
.map(|_| next_order.fetch_add(1, Ordering::SeqCst))
.collect::<Vec<u64>>()
}));
}
handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
});
numbers.sort();
println!("{} đơn hàng: {:?}", numbers.len(), numbers);
}

Kết quả:

12 đơn hàng: [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011]
▶ Tự chạy thử

Luồng nào nhận mã nào thay đổi giữa các lần chạy, nhưng không mã nào bị phát hai lần hay bị bỏ sót. Sắp xếp giúp danh sách in ra lần nào cũng giống nhau.

  • thread::scope đảm bảo các luồng của nó kết thúc trước khi nó kết thúc, nên chúng có thể mượn dữ liệu cục bộ mà không cần Arc hay move.
  • RwLock cho phép nhiều người đọc hoặc một người ghi. Nó có ích với dữ liệu đọc nhiều; nếu không, Mutex đơn giản hơn.
  • Atomic (AtomicUsize, AtomicI64, AtomicBool) xử lý bộ đếm và cờ mà không cần khóa. Dùng SeqCst khi không chắc, Relaxed cho bộ đếm đơn giản.
  • LazyLock và OnceLock tạo một giá trị toàn cục đúng một lần, an toàn, kể cả khi nhiều luồng cùng hỏi một lúc.
  • Tránh deadlock: lấy khóa theo cùng một thứ tự, giữ phạm vi khóa ngắn, và drop guard sớm.

Kiểm tra nhanh

1. Vì sao các luồng khởi chạy bằng s.spawn bên trong thread::scope mượn được biến cục bộ?

2. Phát biểu nào về RwLock là đúng?

3. Bạn cần một bộ đếm lượt xem trang dùng chung cho nhiều luồng. Lựa chọn tốt đơn giản nhất là gì?

4. Luồng A khóa alice rồi bob. Luồng B khóa bob rồi alice. Điều gì có thể xảy ra?

Điểm: 0 / 4