Trong các ứng dụng single thread, ta chỉ có duy nhất một thread chạy trong một không gian địa chỉ (1), do vậy ta có thể đảm bảo không ai thay đổi dữ liệu trong suốt quá trình chạy. Tuy nhiên trong các ứng dụng multi-thread sẽ có 2 hoặc nhiều hơn thread chạy đồng thời và chia sẻ chung không gian địa chỉ, do vậy một trong những bài toán quan trọng nhất là đồng bộ dữ liệu dùng chung giữa các thread.
Nên lưu ý khái niệm chạy đồng thời ở đây là tương đối, vì một máy tính có thể chỉ có rất ít CPU, nên nó phải chia sẻ giữa nhiều thread khác nhau. Trong thực tế, số thread luôn lớn hơn số CPU có trong hệ thống, trong một thời điểm có vài trăm đến vài ngàn thread chạy đồng thời là điều bình thường, mỗi thread sẽ chỉ được cấp một khoảng thời gian để chạy (gọi là time slide), sau đó HĐH sẽ lấy lại quyền điều khiển và chuyển sang thread khác.
Trong các HĐH hiện đại, danh sách các thread thường được quản lý trên một danh sách liên kết (vâng, lại liên quan đến môn cấu trúc dữ liệu), bạn có thể tưởng tượng hệ điều hành chia sẻ CPU giữa các thread như sau:
Thread thread = SystemThreads->First;
while (running)
{
if (thread->IsSleeping == false)
{
LoadThreadState(thread);
int timeSlide = GetTimeSlide(thread);
CurrentInstructionIndex = thread.InstructionIndex;
RunAndWait(thread, timeSlide);
SaveThreadState(thread);
}
thread = thread->Next;
}
Việc quản lý và phân chia CPU như trên được thực hiện bởi một thành phần trong CPU gọi là Scheduler.
Ví dụ trên mô tả một vòng lặp qua các node trên danh sách SystemThreads, HĐH sẽ nạp lại trạng thái của thread (được lưu lại từ lần chạy trước đó), những thông tin cần nạp lại có thể là địa chỉ của Stack và câu lệnh tiếp theo sẽ được thực thi, tính toán time slide cho thread hiện tại, việc tính toán này rất phức tạp (2), sau đó chuyển đến câu lệnh kế tiếp cần thực hiện của thread. Sau khi đã hết thời gian trong time slide, HĐH sẽ lấy lại điều khiển, lưu lại trạng thái của thread và chuyển sang thread kế tiếp.Các bạn không cần lo lắng khi chạy đến cuối danh sách, vì đây là một danh sách vòng, node cuối cùng sẽ chỉ đến node đầu tiên, nên chỉ việc next, next và next.
Trạng thái sleep: Một thread có thể nằm ở trạng thái sleep, tức tạm dừng không chạy cho đến khi hết thời gian hoặc một điều kiện nào đó xảy ra, điều kiện đó có thể là bàn phím nhận được tín hiệu gõ, con chuột nhận được tín hiệu di chuyển, một tài nguyên nào đó trở nên sẵn sàng để dùng, hay một thread khác kết thúc… Nhìn vào vòng lặp ở ví dụ trên, bạn sẽ thấy rằng cơ chế sleep này rất đơn giản, hệ điều hành chỉ cần bỏ qua và không cấp CPU cho nó, vậy là nó “ngủ” – vì sẽ chẳng bao giờ được cấp một time slide để chạy. Nếu bạn viết một chương trình có dùng đến các hàm kiểu như ReadLine để chờ đọc một đoạn văn bản từ bàn phím, thì trong lúc chờ thực chất chương trình của bạn đang sleep (3).
Nếu một thread dùng hầu hết thời gian để chờ các điều kiện nhập xuất (Word, Excel chẳng hạn), thì ta gọi nó là IO-bound, ngược lại nếu thread đó chủ yếu tính toán và không phải “ngủ” để chờ I/O thì ta gọi thread đó là CPU-bound. Hiểu rõ thread của mình là CPU-bound hay IO-bound rất quan trọng, vì nó ảnh hưởng đến việc bạn thiết kế chương trình có hiệu quả không. (Ghi nhớ các khái niệm này để học phần await/sync)
Như các bạn thấy, trước và sau khi chuyển điều khiển cho một thread, HĐH phải làm khá nhiều việc, load/save trạng thái thread, tính toán timeslide… thời gian này chỉ để chuyển thread mà không thực sự mang lại lợi ích cho người dùng. Vì vậy khi số lượng thread càng nhiều hoặc time slide được cấp cho các ứng dụng càng ngắn thì thời gian lãng phí càng lớn, bạn thử tưởng tượng nếu quá trình chuyển này mất 1ms, và mỗi time slide là 4ms, vậy đã có 20% tài nguyên CPU lãng phí. Nhưng nếu tăng timeslide lên, các ứng dụng sẽ phải chờ lâu hơn trước khi tới lượt, và người dùng sẽ cảm thấy máy bị “lag”, vì ứng dụng của bạn không thể phản hồi một cách nhanh chóng. (4)
Ghi chú:
(1) Cơ chế cho phép các địa chỉ logic được diễn dịch thành các địa chỉ vật lý, làm cho các ứng dụng tưởng như mình đang nằm trong một dải địa chỉ riêng. Xem lại các bài về con trỏ/bộ nhớ để hiểu thêm.
(2) Có rất nhiều phương pháp tính toán, và dựa trên nhiều tham số: đây là một IO-bound hay CPU-bound thread, thread này đã ngủ bao lâu, độ ưu tiên… Trên hệ điều hành Linux, hiện tại người ta dùng một scheduler có tên là SFC (https://www.kernel.org/…/scheduler/sched-design-CFS.html).
(3) Khi thread đang ngủ vì phải chờ dữ liệu I/O, ta cũng có thể gọi nó là bị IO blocked.
(4) Ở đây bạn sẽ thấy, lag và chậm là 2 khái niệm khác nhau: Chậm nghĩa là chương trình của bạn không được cấp đủ CPU để hoàn thành công việc nhanh hơn, còn lag là chương trình không được cấp CPU thường xuyên để có thể xử lý ngay khi một sự kiện xảy ra. Thường thì các ứng dụng CPU-bound sẽ được cấp time slide dài, nhưng không thường xuyên, còn các ứng dụng IO-bound, nhất là các ứng dụng tương tác nhiều với người dùng sẽ được cấp time slide ngắn, nhưng thường xuyên. Các phiên bản HĐH chuyên cho server và chuyên cho desktop cũng có thể xác định ưu tiên khác nhau giữa các thread CPU-bound và IO-bound.
5. Còn một nhược điểm khi chuyển thread quá nhanh nữa là không tận dụng tốt được bộ nhớ cache của CPU, vì các thread khác nhau sẽ cần đọc mã lệnh, hay truy xuất bộ nhớ từ các vùng khác nhau, vì vậy khi một thread chạy lâu hơn, nó sẽ tận dụng cache tốt hơn.
Qua bài viết này, bạn có thể thấy việc tối ưu một ứng dụng đòi hỏi phải hiểu thực sự sâu về những gì diễn ra bên trong. Với một ứng dụng lớn, nhiều thành phần khác nhau đều có thể ảnh hưởng đến hiệu năng: DB, network, disk, memory, CPU… Vì vậy, hãy cố gắng hiểu từng vấn đề đến gốc rễ, đừng hài lòng với bản thân, đừng coi việc biết một ngôn ngữ lập trình là cao siêu, hãy tìm hiểu xem người ta đã thiết kế nó như thế nào, dịch nó như thế nào, viết ra các runtime để chạy chương trình như thế nào. Khi đạt tới mức độ đó rồi, việc học một công nghệ mới sẽ chỉ là một cuộc dạo chơi chứ không phải là một cuộc hành xác. Muốn bay lên cao, bạn phải có một bệ phóng tốt.
Bạn muốn vài chục năm sau này sẽ là dạo chơi hay đi hành xác?