0

Cơ bản về đa luồng trong Rust (#02)

... đâ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ào s đạ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ơn scope. Với với lý do này phương thức spawn đượ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ơn scope ví dụ như giá trị được đặt tên numbers. Trong Mã nguồn 01-05, cả hai luồng chương trình được sinh ra sẽ đồng thời sử dụng numbers, điều này hoàn toàn được vì cả hai đều không thay đổi giá trị của numbers 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 & ghi numbers, 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ụng numbers.
// 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ột JoinHandle thì nó trả về một JoinGuard, 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ơn JoinGuard này. Cách này có vẻ an toàn chừng nào JoinGuard đượ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ột Vec thì chúng ta cũng đang rò rỉ các phần tử trong Vec đó). Đâ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ện std. Hơn nữa, ở một mạch làm việc khác, hàm std::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ện std như chúng ta thấy ngày nay, hàm này không tin vào việc Drop 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 ...)


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí