... đây là phần tiếp theo về đa luồng trong Rust, bạn có thể xem lại Phần 1 ở đây hoặc xem các video diễn giải về các vấn đề liên quan ở kênh RustDEV Vietnam.
Luồng trong phạm vi xác định
Trong nhiều bài toán, khi chúng ta biết chắc chắn vòng đời của một luồng mới sinh ra hay luồng mới sinh ra sẽ không thể tồn tại lâu hơn một “phạm vi” nào đó, chúng ta có thể an toàn cho phép luồng đó mượn các giá trị không tồn tại mãi mãi, như biến cục bộ chẳng hạn, miễn sao cho các biến đó tồn tại lâu hơn “phạm vi” nói trên của luồng chương trình.
Thư viện std của Rust có hàm std::thread::scope
dùng để sinh ra các luồng trong một “phạm vi” tồn tại (”scoped thread”). Như tên gọi, hàm này dùng để sinh ra các luồng mới và các luồng mới này không thể tồn tại lâu hơn phạm vi của “closure” mà chúng ta truyền cho hàm đó, nhờ đó các luồng mới có thể an toàn mượn các biến cục bộ thông qua tham chiếu. (mà không cần phải move vào “closure”)
Hãy xem ví dụ sau:
// Mã nguồn 01-05 let numbers = vec![1, 2, 3]; thread::scope(|s| { // (1) s.spawn(|| { // (2) println!("length: {}", numbers.len()); }); s.spawn(|| { // (2) for n in &numbers { println!("{n}"); } });
}); // (3)
Trong ví dụ trên:
- dòng (1): gọi hàm
std::thread::scope
với một “closure”, “closure” này sẽ được thi hành với một tham số đầu vàos
đại diện cho phạm vi thi hành. - các dòng số (2): chúng ta sử dụng s để sinh ra các luồng, các “closure” trong
spawn
có thể mượn các biến cục bộ mà trong ví dụ này lànumbers
. - dòng (3): khi kết thúc
scope
, các luồng được tạo mới trong nó nếu chưa kết thúc và chưa ghép lại với nhau (mà nếu thực hiện thủ công thì sẽ phải dùng.join
như trong Mã nguồn 01-02) thì sẽ được tự động ghép lại với nhau. Với cách trên, chúng ta được đảm bảo rằng không có luồng nào được sinh ra sẽ tồn tại lâu hơnscope
. Với với lý do này phương thứcspawn
được giới hạn phạm vi không áp thuộc tính'static
trên tham số của nó, từ đó cho phép các “closure” bên trong có thể tham chiếu đến bất cứ giá trị nào miễn là giá trị đó tồn tại lâu hơnscope
ví dụ như giá trị được đặt tênnumbers
. Trong Mã nguồn 01-05, cả hai luồng chương trình được sinh ra sẽ đồng thời sử dụngnumbers
, điều này hoàn toàn được vì cả hai đều không thay đổi giá trị củanumbers
và trong chương trình chính cũng không có lệnh nào thay đổi nó. Nếu chúng ta thay đổi và cho phép luồng thứ nhất đọc & ghinumbers
, như trong đoạn mã dưới, thì ngay lập tức biên dịch sẽ báo lỗi, không cho phép chúng ta sinh thêm bất cứ luồng nào cũng sử dụngnumbers
.
// Mã nguồn 01-06 let mut numbers = vec![1, 2, 3]; thread::scope(|s| { s.spawn(|| { numbers.push(4); }); s.spawn(|| { println!("numbers: {:?}", numbers); // Gây lỗi });
});
Thông báo lỗi khi biên dịch mã nguồn 01-06 có thể khác nhau tùy theo phiên bản của rustc
, với phiên bản 1.82.0 thì thông báo lỗi sẽ như sau:
6 | thread::scope(|s| { | - has type `&'1 Scope<'1, '_>`
7 | s.spawn(|| { numbers.push(4) }); | ------------------------------- | | | | | | | first borrow occurs due to use of `numbers` in closure | | mutable borrow occurs here | argument requires that `numbers` is borrowed for `'1`
8 | s.spawn(|| { | ^^ immutable borrow occurs here
9 | println!("numbers: {:?}", numbers) | ------- second borrow occurs due to use of `numbers` in closure
Hiện tượng “Leakpocalypse”
Với các phiên bản Rust trước 1.0, thư viện chuẩn cũng có một hàm tên
std::thread::scoped
để sinh một luồng chương trình mới giống nhưstd::thread::spawn
. Nó cho phép sử dụng các tham số không có thuộc tính'static
bởi vì thay vì trả về mộtJoinHandle
thì nó trả về mộtJoinGuard
,JoinGuard
này sẽ tự động ghép (”join”) luồng đó khi bị vô hiệu hay “drop”. Các giá trị được mượn sẽ chỉ cần sống lâu hơnJoinGuard
này. Cách này có vẻ an toàn chừng nàoJoinGuard
được “drop” ở đâu đó.Nhưng rồi các tác giả nhận ra rằng không thể đảm bảo chắc chắn rằng
JoinGuard
sẽ được “drop” đúng đắn. Cuối cùng họ kết luận rằng thiết kế an toàn không thể hoàn toàn dựa vào niềm tin rằng các đối tượng sẽ luôn được vô hiệu, giải phóng khi kết thúc vòng đời. Khi một đối tượng bị rò rỉ (”leaking”) hay nói chính xác hơn là bộ nhớ dành cho đối tượng đó bị rò rỉ thì có thể sẽ kéo theo rò rỉ thêm nhiều đối tượng khác bị rò rỉ (ví dụ: khi rò rỉ mộtVec
thì chúng ta cũng đang rò rỉ các phần tử trongVec
đó). Đây gọi là "The Leakpocalypse".Với kết luận trên,
std::thread::scope
đã bị coi là không an toàn và bị loại bỏ khỏi thư việnstd
. Hơn nữa, ở một mạch làm việc khác, hàmstd::mem::forget
cũng đã được nâng cấp từunsafe
thành hàm kiểu “safe” để nhấn mạnh rằng việc bỏ sót hay rò rỉ bộ nhớ luôn có khả năng xảy ra.Mãi đến tận phiên bản Rust 1.63, hàm
std::thread::scope
được thiết kế lại một cách an toàn và được thêm vào thư việnstd
như chúng ta thấy ngày nay, hàm này không tin vào việcDrop
thủ công.
Các giá trị tĩnh
Có một vài cách để tạo ra các giá trị có thể dùng chung giữa các luồng nhưng không có luồng nào sở hữu giá trị đó. Cách đơn giản nhất là khai báo các giá trị kiểu tĩnh hay static
, các giá trị kiểu này thuộc “sở hữu” của toàn bộ chương trình, không thuộc sở hữu của bất kỳ một luồng cụ thể nào. Trong Mã nguồn 01-07, cả hai luồng chương trình đều có thể truy xuất được X
nhưng không luồng nào sở hữu X
.
// Mã nguồn 01-07 static X: [i32; 3] = [1, 2, 3]; thread::spawn(|| dbg!(&X));
thread::spawn(|| dbg!(&X));
Mã nguồn 01-07
Mỗi đối tượng static
có một bộ khởi tạo hằng số, nó không bao giờ bị giải phóng hay vô hiệu và nó thậm chí còn tồn tại trước cả khi hàm main khởi động. Do vậy tất cả các luồng chương trình đều có thể sử dụng nó.
Sự rò rỉ
Một cách khác để chia sẻ quyền sở hữu là làm “rò rỉ” (”leaking”) một vùng nhớ được cấp phát. Box::leak
có thể được dùng để giải phóng quyền sở hữu của một Box
, và cam kết rằng sẽ không bao giờ “drop” nó. Sau khi thực hiện lệnh này, đối tượng Box
sẽ tồn tại mãi, không có chủ sở hữu và do vậy nó có thể được mượn bởi bất kỳ luồng chương trình nào chừng nào chương trình của của chúng ta vẫn đang hoạt động.
let x: &'static [i32; 3] = Box::leak(Box::new([1, 2, 3])); thread::spawn(move || dbg!(x)).join().unwrap();
thread::spawn(move || dbg!(x)).join().unwrap();
Trong đoạn mã trên, chỉ thị move
đi cùng với “closure” có thể làm chúng ta nghĩ rằng nó sẽ chuyển quyền sở hữu x
vào các luồng, tuy nhiên nếu chúng ta nhìn kỹ hơn vào khai báo của x
thì chúng ta thấy rằng chúng ta sẽ chỉ cho các luồng một tham chiếu đến dữ liệu.
Các tham chiếu (”reference”) có thuộc tính hành vi
Copy
, điều này có nghĩa là khi chúng ta "move" các tham chiếu thì tham chiếu gốc vẫn tồn tại, giống như việc “move” các giá trị kiểu số vậy. (ví dụ: i32, i64, v.v.)
Lưu ý, mặc dù có khai báo 'static
đối với x
nhưng điều đó không đồng nghĩa với việc x
tồn tại trước khi hàm main
khởi động đến khi chương trình dừng chạy. Nó chỉ tồn tại từ thời điểm khai báo đến khi chương trình dừng chạy.
Vấn đề của việc chủ động làm “rò rỉ” này, đúng với tên gọi của nó, là chúng ta sẽ làm rõ rỉ bộ nhớ (“memory leaking”) hay nói cách khác chúng ta đang chiếm dụng bộ nhớ mà không giải phóng nó ngay cả khi không cần dùng đến nó nữa. Đôi khi đây là một chiến thuật hữu dụng nhưng nếu lạm dụng thì chương trình của chúng ta sẽ chạy chậm đi và có thể đổ vỡ do hết bộ nhớ khả dụng hay “run out of memory”.
(Còn tiếp ...)