0

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

Dẫn nhập

Trước khi xuất hiện các bộ xử lý đa lõi (”multi-core processor”) thì các hệ điều hành đã cho phép một máy tính có thể chạy nhiều chương trình đồng thời. Việc này được thực hiện bằng cách chuyển đổi qua lại rất nhanh giữa các tiến trình (”process”), cách này cho phép các tiến trình có thể thực thi xen kẽ mỗi tiến trình tiến triển một chút, và tất nhiên đủ nhanh để có cảm giác “trong suốt” với người dùng và tạo cảm giác như chúng đang chạy "song song". Ngày nay, hầu hết các máy tính, thậm chí cả điện thoại và đồng hồ đều sử dụng các bộ xử lý đa nhân và điều này cho phép các tiến trình có thể đồng thời chạy song song một cách thực sự (mỗi tiến trình trên một nhân bộ xử lý độc lập).

Hệ điều hành cũng luôn đảm bảo khu trú các tiến trình hay các chương trình một cách tối đa để chúng không không can thiệp lẫn nhau hay nói cách khác một chương trình hay tiến trình khi thi hành sẽ hoàn toàn không biết một chương trình hay tiến trình khác đang làm gì. Ví dụ, một tiến trình bình thường sẽ không thể truy cập vào vùng nhớ của một tiến trình đang chạy khác, không thể gửi nhận dữ liệu với một tiến trình khác nếu không xin phép và được nhân hệ điều hành (”kernel”) đồng ý.

Trong lúc đó, một chương trình hay tiến trình lại hoàn toàn có thể sinh ra nhiều luồng thực thi (”thread”) hoạt động đồng thời trong một tiến trình. Các luồng thực thi của một tiến trình có thể chia sẻ chung một vùng bộ nhớ (bộ nhớ của tiến trình) và tương tác với nhau thông qua vùng bộ nhớ chung đó không giống như tính độc lập giữa các tiến trình.

Chuỗi bài viết này sẽ giải thích cách thức các luồng được sinh ra trong các chương trình được viết bằng ngôn ngữ Rust, các khái niệm cơ bản liên quan và cách thức chia sẻ bộ nhớ một cách an toàn giữa các luồng thực thi. Bên cạnh các bài viết chi tiết trên nền tảng Viblo, các bạn cũng có thể xem các video về chủ đề này tại kênh Youtube RustDEV Vietnam !

Các luồng thực thi trong Rust

Tất cả các chương trình đều khởi động bằng một luồng duy nhất: luồng thi hành chính (”main thread”). Luồng này sẽ thực thi hàm main và từ đó có thể sinh ra nhiều luồng thực thi nếu được lập trình như vậy. Trong Rust, chúng ta dùng hàm std::thread::spawn trong thư viện std để sinh ra một luồng thực thi. Hàm này chỉ nhận duy nhất một tham số, đó chính là hàm mà luồng sắp sinh sẽ thi hành và luồng mới được sinh ra cũng sẽ kết thúc ngay khi hàm này kết thúc. Hãy xem ví dụ sau:

use std::thread;

fn main() {
    thread::spawn(f);
    thread::spawn(f);

    println!("Hello from the main thread.");
}

fn f() {
    println!("Hello from another thread!");
    let id = thread::current().id();
    println!("This is my thread id: {id:?}");
}

Chương trình trên sẽ sinh ra hai luồng và cả hai luồng này đều thi hành hàm f hay nói cách khác lấy hàm f làm hàm thi hành chính. Cả hai luồng này sẽ đều in ra cùng một nội dung thông điệp và số định danh luồng của nó (”thread id”), luồng chương trình chính cũng in ra thông điệp của riêng nó.

Định danh luồng Thư viện chuẩn std của Rust sẽ luôn gán cho mỗi luồng chương trình một số định danh duy nhất. Số định danh này sẽ có thể truy xuất qua hàm Thread::id() và có kiểu dữ liệu là ThreadId. Bạn sẽ không thể làm gì được nhiều với ThreadId ngoài việc sao chép và sử dụng nó để đối sánh. Lưu ý, không có gì đảm bảo rằng bạn sẽ nhận được các số định danh luồng nối tiếp liên tục, chúng ta chỉ chắc chắn rằng các số này là duy nhất cho mỗi luồng.

Nếu bạn chạy chương trình vài lần, bạn sẽ thấy nội dung in ra màn hình của các lần chạy sẽ không giống nhau. Đây là kết quả in trên màn hình máy tính của tôi trong một lần chạy, thậm chí một phần nội dung muốn in ra đã bị mất đi đâu không rõ:

Hello from the main thread.
Hello from another thread!
This is my thread id:

Hiện tượng này do luồng chương trình chính đã kết thúc thi hành hàm main trước khi các luồng mới sinh ra kết thúc thi hành hàm f của nó. main kết thúc đồng nghĩa với việc toàn bộ chương trình phải kết thúc, kể cả khi các luồng trong nó chưa kết thúc. Trong ví dụ cụ thể này, một hàm mới sinh chỉ kịp thi hành một phần lệnh đến một nửa thông điệp cần in thứ hai của nó thì đã bị dừng. Nếu chúng ta muốn các luồng thi hành đầy đủ và kết thúc trước khi main kết thúc, chúng ta cần có cơ chế chờ và đồng bộ các luồng. Để làm việc này, chúng ta sẽ cần dùng đến JoinHandle trả về từ hàm spawn:

fn main() {
    let t1 = thread::spawn(f);
    let t2 = thread::spawn(f);

    println!("Hello from the main thread.");

    t1.join().unwrap();
    t2.join().unwrap();
}

Trong mã nguồn trên, phương thức .join() sẽ chỉ thị chương trình main chờ cho đến khi hai luồng t1t2 kết thúc và trả về kết quả std::thread::Result. Nếu một trong hai luồng đó gặp lỗi và phát sinh “panic”, kết quả trả về này chứa thông tin “panic”, trong tình huống này chúng ta có thể cố gắng kiểm soát tình hình bằng cách xử lý thông tin “panic” trả về hoặc nếu không quan tâm thì chỉ cần .unwrap() như trong ví dụ để hàm main tạo “panic” và dừng chương trình. Khi chạy đoạn mã trên (và không có lỗi) thì kết quả sẽ đầy đủ giống như sau:

Hello from the main thread.
Hello from another thread!
This is my thread id: ThreadId(3)
Hello from another thread!
This is my thread id: ThreadId(2)

Mặc dù vậy vẫn còn sự khác nhau về trình tự thông tin được in ra màn hình trong các lần thực thi khác nhau, ví dụ nếu chạy lần thứ hai thì thông tin in ra sẽ có thể như sau:

Hello from the main thread.
Hello from another thread!
Hello from another thread!
This is my thread id: ThreadId(2)
This is my thread id: ThreadId(3)

Cơ chế khóa đầu ra

Lệnh bó println! sử dụng hàm std::io::Stdout::lock() để đảm bảo việc đưa thông tin ra đầu ra (Stdout mà ở đây là màn hình) sẽ không bị ngắt quãng giữa chừng. Một lệnh println! sẽ đợi cho đến khi các lệnh println! khác cũng đang đồng thời chạy kết thúc rồi mới in thông tin ra màn hình. Nếu không có cơ chế này, thông tin in ra mang hình có thể sẽ bị lộn xộn như sau:

Hello fromHello from another thread!
another This is my threthreadHello fromthread id: ThreadId!
( the main thread.
2)This is my thread
id: ThreadId(3)

Bên cạnh việc đưa tên hàm vào làm tham số của std::thread::spawn giống ví dụ trên còn một cách khá thường được sử dụng hơn đó là dùng “closure” làm tham số đầu vào. Dùng cách này sẽ linh hoạt hơn khi lập trình viên muốn sử dụng các giá trị trong cùng phạm vi hiệu lực khai báo.

let numbers = vec![1, 2, 3];

thread::spawn(move || {
    for n in &numbers {
        println!("{n}");
    }
}).join().unwrap();

Trong mã nguồn trên, quyền sở hữu numbers đã được chuyển vào luồng chương trình mới được sinh ra do chúng ta sử dụng move với “closure”. Nếu chúng ta không sử dụng từ khóa move, “closure” sẽ chỉ sử dụng numbers thông qua tham chiếu mượn chỉ đọc, điều này sẽ gây lỗi khi biên dịch vì luồng chương trinh mới sinh có thể tồn tại hay có vòng đời lâu hơn biến numbers đó.

Lý do như sau: một luồng chương trình có thể hoạt động đến tận khi kết thúc chương trình chính, do đó hàm spawn áp thuộc tính vòng đời 'static cho tham số đầu vào của nó. Hay nói cách khác, nó sẽ chỉ chấp nhận một hàm đầu vào có thể tồn tại mãi mãi cùng với nó. Nếu một “closure” dùng một biến cục bộ (ngoài “closure” và cùng phạm vi hiệu lực khai báo với spawn) thông qua tham chiếu thì rất có thể tham chiếu đó sẽ bị vô hiệu sớm hơn so với vòng đời của luồng chứa “closure” khi biến cục bộ mà nó tham chiếu đến bị vô hiệu sớm.

Giá trị ra từ luồng chương trình sẽ được thực hiện thông qua giá trị trả về từ “closure”. Giá trị trả về này sẽ có kiểu Result trả về từ phuong thức .join() :

let numbers = Vec::from_iter(0..=1000);

let t = thread::spawn(move || {
    let len = numbers.len();
    let sum = numbers.iter().sum::<usize>();
    sum / len                                   // (1)
});

let average = t.join().unwrap();                // (2)
println!("average: {average}");

Trong mã nguồn trên, giá trị trả về (1) của “closure” trong luồng chương trình sẽ được gửi tới luồng chương trình chính main thông qua phương thức .join().

Nếu numbes là rỗng, luồng chương trình có thể bị “panic” do thực hiện phép chia cho 0 ở dòng (1) và phương thức .join() có thể trả về thông điệp “panic” và chương trình chính cũng có thể bị “panic” theo do có sử dụng unwrap() ở dòng (2).

Cấu trúc Builder trong tạo luồng

Hàm std::thread::spawn thực chất là cách viết gọn cho std::thread::Builder::new().spawn().unwrap().

Cấu trúc std::thread::Builder cho phép chúng ta thực hiện thiết lập cấu hình các luồng chương trình mới trước khi chúng được sinh ra qua spawn. Với cấu trúc này chúng ta có thể cấu hình kích thước Stack cho luồng mới và đặt tên cho luồng mới. Chúng ta có thể lấy tên luồng hiện tại thông qua std::thread::current::name(), nó có thể được dùng trong thông báo “panic” và có thể thấy được trong hầu hết các công cụ giám sát hoặc gỡ rối.

Hơn nữa, hàm spawn của Builder được thiết kế để trả về std::io::Result cho phép chúng ta có thể kiểm soát tình huống khi sinh một luồng mới gặp lỗi. Có rất nhiều nguyên nhân có thể dẫn đến việc không sinh được luồng mới như không đủ bộ nhớ hệ thống hoặc bản thân chương trình của chúng ta bị gán giới hạn tài nguyên trên hê thống.

(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í