Khi mới học viết các ứng dụng multi thread, mỗi khi cần xử lý một công việc song song, ta thường làm theo cách sau:
– Tạo một thread mới.
– Truyền tham số khởi tạo và cho thread chạy để xử lý công việc.
– Kết thúc thread và nhận kết quả.
Một trong những bài toán phổ biến nhất cho người mới bắt đầu là viết ứng dụng chat(*). Và cách làm cũng tương tự như trên:
– Mở server socket và listen trên server socket đó.
– Mỗi khi có một client kết nối mới, bạn sẽ tạo một thread để chờ dữ liệu trên kết nối đó, nếu có thì xử lý, hoặc gửi dữ liệu cho đầu bên kia thông qua socket.
– Khi hai bên hoàn thành công việc, ngắt kết nối và kết thúc thread.
Cách làm này khá đơn giản, nhưng có một số nhược điểm như sau:
– Nếu có nhiều client kết nối vào, đồng nghĩa với việc có nhiều thread được tạo ra, dẫn đến việc mất quá nhiều thời gian dành cho switching context, tức thời gian cần thiết để chuyển từ thread này sang thread khác[xem ghi chú bên dưới], càng nhiều thời gian chuyển, càng ít thời gian xử lý công việc, dẫn đến hiệu năng hệ thống bị giảm.
– Nếu quá nhiều thread được tạo, có thể hệ thống sẽ không còn tài nguyên CPU cần thiết để xử lý công việc.
– Nếu thời gian kết nối ngắn, đồng nghĩa với việc các thread được tạo ra – chạy – kết thúc một cách nhanh chóng. Việc tạo và kết thúc một thread là một quá trình tiêu tốn thời gian, nên khi thời gian sử dụng ngắn, cũng sẽ dẫn đến không hiệu quả.
– Nếu thời gian kết nối dài, nhưng trong quá trình kết nối đó, client hầu hết chỉ ở trạng thái chờ, tức không có gì để xử lý, đồng nghĩa với việc hệ thống phải duy trì một thread một cách không hiệu quả.
Vì vậy, để giải quyết các nhược điểm trên, trong hầu hết trường hợp người ta sẽ sử dụng mô hình thread pool:
– Thay vì tạo ra các thread theo yêu cầu, ta sẽ tạo ra hẳn một số lượng cố định thread.
– Công việc cần xử lý (task) sẽ được chuyển vào hàng đợi.
– Các thread sẽ liên tục kiểm tra hàng đợi, nếu một thread tìm thấy task, nó sẽ lấy ra, xử lý, rồi trả về kết quả.Ta có thể xem ví dụ về một thread trong thread pool như sau:
procedure TaskProcessor()
begin
do
Task task = TaskQueue.waitForTask(TIME_OUT);
if (task != null)
begin
Process(task);
end
while (running);
end
Quá trình tạo thread pool có thể được mô tả như sau:
TaskProcessor pool[100];
for (i = 0; i < 100; i++)
begin
pool[i] = new TaskProcessor();
pool[i].Start();
end
Mô hình thread pool có các ưu điểm như sau:
– Kiểm soát được số lượng thread, lưu ý là số lượng thread trong pool cũng có thể được điều chỉnh trong lúc hoạt động.
– Quá trình khởi tạo/kết thúc các thread được giảm thiểu.
– Một thread có thể chuyển sang một task khác nếu task hiện tại chưa thể hoàn thành được (ví dụ chưa đủ dữ liệu để xử lý).
Một số lưu ý khi sử dụng thread pool:
– Hàng đợi nhiệm vụ của bạn (TaskQueue trong ví dụ trên) phải là thread safe, tức cho phép truy cập từ nhiều thread khác nhau mà không bị lỗi.
– Số lượng thread có thể được điều chỉnh trong lúc chạy dựa trên một số thông số, chẳng hạn như độ dài hàng đợi hoặc hiệu năng hiện tại của hệ thống.- Các task phải chứa đủ thông tin để thread xử lý, bản thân dữ liệu trong task và thread phải độc lập, nhờ đó một task có thể được xử lý bởi bất kỳ thread nào.
– Các task lớn cũng có thể được xử lý nhiều lần, giúp cho việc xử lý tổng thể hiệu quả hơn. Ta có thể lấy ví dụ trong một chương trình nén file, ta có 10 thread để xử lý tối đa 10 file cùng lúc, nếu trong lúc chương trình đang xử lý 10 file lớn và phải mất tới 1 giờ để xử lý mỗi file, khi đó nếu bạn nhận được thêm 1 file rất nhỏ bạn vẫn phải chờ hơn 1 giờ sau mới nhận được kết quả, bởi file nhỏ của bạn chỉ được xử lý khi một trong các file lớn kia đã hoàn thành. Để giải quyết trường hợp này, bạn có thể yêu cầu mỗi thread chỉ được chạy trong một khoảng thời gian tối đa cho trước, sau đó nếu vẫn chưa xong thì cập nhật trạng thái hiện tại vào Task. Task của bạn khi đó ngoài thông tin về file đang xử lý cũng cần có thêm vị trí kế tiếp cần xử lý trong file. Tất nhiên chia task lớn ra xử lý nhiều lần như trên không hẳn giúp tăng hiệu năng tổng thể, nhưng nó sẽ giúp ứng dụng của bạn responsive hơn.
– Nếu một task chưa hoàn thành, bạn có thể đẩy nó vào TaskQueue để thread khác xử lý tiếp.
– Các nền tảng hỗ trợ multi thread như Java và .NET đều có sẵn thư viện cho Thread Pool.
Ghi chú:
– Mỗi một thread phải có một bộ nhớ stack riêng (https://daohainam.com/2021/08/13/bo-nho-stack-la-gi-tai-sao-lai-co-loi-stack-overflow/), và mỗi khi chuyển từ một thread này sang thread khác, hệ thống cũng phải lưu lại tất cả trạng thái của thread hiện tại.
– (*) Có lẽ bạn không biết, ứng dụng đã mang lại cảm hứng để Linus Torvalds viết ra HĐH Linux là một ứng dụng Terminal multi thread – cách hoạt động cũng khá giống một ứng dụng chat.