So sánh Concurrency với Event Loop và hướng kết hợp cả hai

Bài viết được dịch từ tựa

“Concurrency vs Event Loop vs Event Loop + Concurrency” 
của tác giả
Tigran Bayburtsyan
(Founder/CEO tại TreeScale.com).


Đầu tiên chúng ta sẽ đi cắt nghĩa các thuật ngữ.

Concurrency: Có nghĩa là bạn có nhiều tác vụ nằm ở queue trên các lõi xử lý / các luồng xử lý. Nhưng nó hoàn toàn khác với việc thực thi song song (Parallel), thực thi song song không bao gồm việc xử lý đa tác vụ trong các queue — chúng ta chỉ cần một lỗi xử lý/ luồng xử lý cho mỗi tác vụ được hoàn thành khi xử lý tính toán song song, mà trong nhiều trường hợp chúng ta không thể xác định. Đó là lý do tại sao trong phát triển phần mềm hiện đại thì lập trình song song đôi khi lại có nghĩa là “Concurrency”, tôi biết nó thật lạ lùng, nhưng chắc chắn nó là những gì chúng ta đang có trong thời điểm hiện tại (Nó phụ thuộc vào hệ điều hành — và vi xử lý/phân luồng kiểu mới).


Event Loop: Có nghĩa là một vòng lặp đơn luồng duy nhất tạo ra để xử lý một tác vụ tại một thời điểm và nó không chỉ tạo ra một tác vụ duy nhất trong queue, nó còn phân loại tác vụ theo độ ưu tiên, bởi với dạng event loop bạn chỉ có hữu hạn tài nguyên (1 luồng), vì vậy để thực hiện một vài tác vụ ngay lập tức, chúng ta cần có sự đánh giá mức độ ưu tiên cho các tác vụ. Cách lập trình này có tên là Thread Safe Programming, bởi chỉ duy nhất một tác vụ được xử lý tại một thời điểm, và nếu bạn muốn thay đổi thứ gì đó, nó nên xảy ra ở tác vụ kế tiếp.

....

Lập trình Concurrency

Trong các máy tính/máy chủ hiện đại có ít nhất 2 lõi trong vi xử lý — 4 luồng xử lý. Nhưng các máy chủ tầm trung, thì chúng sẽ có ít nhất là 16 luồng xử lý. Vậy nếu bạn muốn viết phần mềm cần đến chút hiệu năng bạn nên quan tâm đến việc khiến nó tận dụng tối đa vi xử lý có trên máy chủ.

1

Bức tranh phía trên cho thấy mô hình cơ bản của concurrency, nhưng tất nhiên nó không dễ dàng như vậy.

Lập trình concurrency trở thành thực sự khó khi đụng tới việc sử dụng chung tài nguyên, ví dụ đơn giản với mã nguồn viết bằng Golang:

2

Trong trường hợp này, mã nguồn trên sẽ triển khai hai việc đồng thời cho các lõi CPU khác nhau và chúng ta không thể đoán được cái nào sẽ được thực thi trước, vậy chúng ta không thể hiểu được cái gì sẽ được hiển thị cho đến khi kết thúc chương trình.

Tại sao vậy? — Nó thật đơn giản! Chúng ta lên lịch cho 2 tác vụ khác biệt cho hai lõi xử lý khác nhau nhưng chúng lại sử dụng chung một luồng xử lý như biến/thanh ghi, vậy chúng cùng lúc thay đổi trong thanh ghi, và trong một vài trường hợp thì sẽ xảy ra việc chương trình bị lỗi hay đẩy ra ngoại lệ.

Bởi vậy, để dự đoán được việc thực thi concurrency, chúng ta cần phải sử dụng một số tính năng như Mutex. Với nó chúng ta có thể khoá tài nguyên chia sẻ từ thanh ghi và làm cho nó chỉ xử lý cho một nhiệm vụ tại một thời điểm. Kiểu lập trình dạng này có tên là Blocking bởi vì chúng thực sự cản trở toàn bộ các tác vụ đằng sau cho đến khi tác vụ hiện tại được thực thi xong với cùng tài nguyên trên thanh ghi.

Nhiều lập trình viên không thích lập trình concurrent bởi vì không phải lúc nào nó cũng có hiệu năng tốt.


Xử lý đơn luồng với Event Loop

Trong phát triển phần mềm chấp nhận rằng việc lập trình theo dạng này dễ dàng hơn lập trình concurrency. Bởi nguyên lý thực sự rất đơn giản. Bạn chỉ có một tác vụ được thực hiện trong một thời điểm. Và trong trường hợp này bạn không gặp bất kì rắc rối nào với việc sử dụng chung biến số/thanh ghi, bởi chương trình chạy hoàn toàn có thể đoán được với một tác vụ tại một thời điểm được thực thi.

3

Luồng chương trình sẽ có dạng như sau:

  1. Event Emitter sẽ thêm tác vụ vào Event queue (hàng đợi sự kiện) để được xử lý trong vòng thực thi tiếp theo.
  2. Event Loop đẩy tác vụ từ Event queue và xử lý chúng tại Event Handler.

Ví dụ dựa trên

Node.js

4

Như bạn có thể hình dung trong mã nguồn trên có thể dễ dàng đoán được kết quả hơn là ví dụ ở phần concurrency được mô phỏng bằng Golang, bởi vì

Node.js
chỉ sử dụng duy nhất một luồng xử lý khi tận dụng Javascript với mô hình event loop.

Trong một số trường hợp Event Loop sẽ giúp đạt hiệu năng tốt hơn là concurrency, bởi hành vi của nó là Non-Blocking. Ứng dụng của nó tốt nhất là trong các ứng dụng liên quan tới Networking. Chúng sẽ sử dụng một tài nguyên kết nối và chỉ xử lý dữ liệu khi nó sẵn sàng sử dụng Thread Safe Event Loop.


Concurrency + Event Loop — Thread Pool và Thread Safe

Khởi tạo các ứng dụng chỉ sử dụng concurrent sẽ gặp nhiều thách thức, bởi thanh ghi sẽ xảy ra các lỗi bất cứ lúc nào, hoặc đơn giản ứng dụng của bạn sẽ bắt đầu rơi vào quá trình quá tải tác vụ khi công việc cần xử lý phải chờ xếp hàng. Đặc biệt nếu bạn muốn khai thác tối đa hiệu năng thì đây là lúc ta cần kết hợp Event Loop + Concurrency!

Cùng ngó qua việc kết hợp mô hình Thread Pool với Event Loop trên kiến trúc của Nginx Web Server nhé.

5

Phần cấu hình và networking chính được xử lý bởi Worker Event Loop trong một thread safe, nhưng khi Nginx cần đọc một tập tin hoặc cần xử lý một HTTP request, mà quá trình thực thi Blocking đang điễn ra, nó sẽ gửi tác vụ đó tới Thread Pool để xử lý đồng thời. Và khi tác vụ ấy hoàn thành, kết quả sẽ được gửi lại event loop để kết quả được thực thi dưới dạng thread safe.

Bởi vậy việc kết hợp sử dụng cả hai kiến trúc cho thấy được cách tối ưu hiệu năng sử dụng vi xử lý và giữ cho nguyên lý Non-Blocking hoạt động xuyên suốt với một luồng duy nhất trong event loop.


Kết luận

Rất nhiều phần mềm hay chương trình được viết theo hướng thuần concurrency hoặc event loop, nhưng việc kết hợp cả hai hướng bên trong một ứng dụng sẽ đơn giản hoá vấn đề về hiệu năng và tối ưu hoá được tai nguyên của vi xử lý tốt hơn.


Hãy tiếp tục ủng hộ và giữ kết nối với Vnknowledge các bạn nhé:

  • Vnknowledge Page
  • Vnknowledge Youtube
  • Vnknowledge Patreon

Xin cảm ơn các bạn!

Từ khóa: vnknowledge, js, nodejs, Lập trình