Arc<Mutex<T>> và ba lần bạn không cần đến nó
Arc<Mutex<T>> là thứ bạn tìm thấy khi tra “trạng thái khả biến dùng chung trong Rust”, và nó chạy
được. Nó cũng là thứ tôi với tới quá sớm, và ba trong bốn lần tôi dùng nó, một thứ đơn giản hơn mới
là đúng.
Mỗi nửa làm gì
Hai lớp bọc này giải hai bài toán khác nhau và thường bị lẫn với nhau.
Arc<T> — đếm tham chiếu nguyên tử. Nhiều chủ sở hữu của một giá trị trên heap, được giải phóng
khi chủ cuối cùng biến mất. Nó cho bạn sự chia sẻ, và nội dung vẫn bất biến.
Mutex<T> — loại trừ lẫn nhau. Mỗi lúc chỉ một luồng được truy cập nội dung. Nó cho bạn sự thay
đổi, và nó không cho bạn sự chia sẻ.
Bạn cần cả hai để chia sẻ trạng thái khả biến qua nhiều luồng, và đó là lý do chúng đi cùng nhau:
use std::sync::{Arc, Mutex};
let counter = Arc::new(Mutex::new(0));
for _ in 0..10 {
let counter = Arc::clone(&counter);
thread::spawn(move || {
let mut value = counter.lock().unwrap();
*value += 1;
});
}
Hãy để ý rằng Mutex của Rust sở hữu dữ liệu chứ không nằm cạnh nó. Không có cách nào truy cập
giá trị mà không khóa, và điều đó dẹp sạch cả lớp lỗi khi có ai đó quên khóa.
Trường hợp 1: bạn chỉ cần chia sẻ
Nếu dữ liệu bất biến sau khi dựng xong, hãy bỏ cái mutex đi:
// không cần thiết
let config = Arc::new(Mutex::new(Config::load()?));
// đúng
let config = Arc::new(Config::load()?);
Đọc qua một mutex vẫn phải lấy rồi nhả một cái khóa ở mỗi lần truy cập, nên đây không chỉ là chuyện gọn gàng hơn — nó dẹp đi sự tranh chấp có thật. Đọc một cấu hình ở mỗi request qua một mutex là một điểm tuần tự hóa toàn cục mà chẳng vì lý do gì.
Trường hợp 2: số lần đọc áp đảo số lần ghi
RwLock cho phép nhiều bên đọc đồng thời hoặc một bên ghi:
use std::sync::RwLock;
let cache = Arc::new(RwLock::new(HashMap::new()));
// nhiều luồng cùng lúc
let value = cache.read().unwrap().get(&key).cloned();
// độc quyền
cache.write().unwrap().insert(key, value);
Điểm cần lưu ý là RwLock chậm hơn Mutex trong trường hợp không có tranh chấp, và bên ghi có thể
bị bỏ đói dưới tải đọc nặng. Nó thắng khi số lần đọc áp đảo theo một khoảng cách lớn và vùng tới hạn
đủ dài để đáng kể. Với một vùng tới hạn ngắn dưới tranh chấp nhẹ, Mutex thường nhanh hơn.
Trường hợp 3: đó là một bộ đếm hay một cái cờ
Một biến nguyên tử chẳng cần khóa nào cả:
use std::sync::atomic::{AtomicUsize, Ordering};
let counter = Arc::new(AtomicUsize::new(0));
counter.fetch_add(1, Ordering::Relaxed);
Với các bộ đếm, các cờ và các máy trạng thái đơn giản, cách này rẻ hơn rất nhiều — một lệnh CPU duy nhất thay vì một lượt giành khóa.
Ordering::Relaxed đúng cho một bộ đếm thống kê mà bạn chỉ quan tâm tổng cuối cùng. Bất cứ chỗ nào
biến nguyên tử canh giữ dữ liệu khác thì cần Acquire/Release, và nếu bạn không chắc thì SeqCst
là mặc định an toàn. Đặt sai thứ tự bộ nhớ là chuyện tinh vi theo cách mà các cái khóa thì không, và
đó là một lập luận thật cho việc dùng mutex khi trạng thái phức tạp hơn một con số.
Trường hợp 4: truyền thông điệp thay vì chia sẻ
Thường thì trạng thái dùng chung tồn tại chỉ vì hai luồng cần phối hợp với nhau, và một kênh diễn đạt điều đó tốt hơn:
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for item in work {
tx.send(process(item)).unwrap();
}
});
for result in rx {
collect(result);
}
Không khóa, không Arc, không khả năng bế tắc. Mỗi lúc một luồng sở hữu dữ liệu và quyền sở hữu di
chuyển qua cái kênh — đó là mô hình sở hữu của Rust đang lo phần đồng bộ hóa hộ bạn.
Đây là lựa chọn hóa ra thường đúng nhất, và cũng là lựa chọn tôi hay quên là nó tồn tại.
Cảnh báo
Trong code async, đừng giữ một std::sync::Mutex xuyên qua một .await. Cái khóa được giữ trong
lúc task đang treo, và một task khác trên cùng luồng mà muốn nó sẽ bế tắc. Hãy dùng
tokio::sync::Mutex — thứ hiểu async và biết nhường lượt — hoặc tái cấu trúc để cái khóa được
nhả ra trước lần await.
Khi nào Arc<Mutex<T>> là đúng
Một mẩu trạng thái thật sự dùng chung, thật sự khả biến và thật sự phức tạp: một bể kết nối, một sổ đăng ký mà nhiều task thêm vào rồi xóa đi, một kho lưu trong bộ nhớ.
Hai thói quen làm nó an toàn hơn khi bạn thật sự dùng:
Giữ vùng tới hạn ngắn. Khóa, lấy thứ bạn cần, mở khóa. Làm I/O trong lúc đang giữ khóa là cách bạn biến một dịch vụ nhanh thành một dịch vụ chậm.
// tệ: cái khóa bị giữ xuyên qua lời gọi mạng
let mut state = shared.lock().unwrap();
state.items = fetch_items().await;
// tốt hơn
let items = fetch_items().await;
shared.lock().unwrap().items = items;
Khóa theo một thứ tự nhất quán. Hai mutex được hai luồng giành theo hai thứ tự khác nhau là cú bế tắc trong sách giáo khoa, và đó là thứ duy nhất Rust không bắt hộ bạn được.
Thứ tự tôi đi bây giờ: bất biến kèm Arc → nguyên tử → kênh → RwLock → Mutex. Đến lúc tôi chạm
tới cuối cái danh sách ấy thì trạng thái thật sự là dùng chung và phức tạp, và cái khóa xứng đáng có
mặt.