Thiết Kế Mới Cho Hàng Đợi Tin Nhắn Của Skynet (Tiếp Theo Phần Trước) - nói dối e blog

Thiết Kế Mới Cho Hàng Đợi Tin Nhắn Của Skynet (Tiếp Theo Phần Trước)

Tiếp nối bài viết trước, hôm nay chúng ta cùng bàn luận chi tiết hơn về những ý tưởng mới liên quan đến hàng đợi tin nhắn trong skynet.

Trước đây chúng ta đã đề cập rằng hàng đợi tin nhắn nhận của mỗi dịch vụ có thể thiết lập độ dài cố định và không cần quá dài. Lý do là bởi khi hệ thống vận hành bình thường, mọi dịch vụ nên tự xử lý trơn tru luồng tin nhắn đến, nếu không sẽ phản ánh vấn đề thiết kế từ tầng trên. Tuy nhiên, việc đơn giản loại bỏ tin nhắn khi hàng đợi đầy là hoàn toàn vô lý. Điều này đòi hỏi cơ chế lan truyền lỗi toàn diện hơn, để phía gửi không thành công có thể phát sinh lỗi và ngắt đứt quy trình nghiệp vụ. Việc cho phép lỗi khi gửi tin nhắn có thể khiến việc thiết kế tầng trên trở nên phức tạp hơn.

Việc làm phía gửi bị block trong skynet cũng không phải giải pháp tối ưu. Điểm khác biệt lớn giữa skynet và Erlang nằm ở chỗ các dịch vụ skynet cho phép thực thi song song nhiều session mới khi bị block, điều này giúp tối ưu hiệu năng máy ảo Lua, cho phép chia sẻ trạng thái khi cần thiết thay vì bắt buộc tất cả nghiệp vụ đều phải thông qua cơ chế truyền tin nhắn tương đối kém hiệu quả. Tuy nhiên hệ quả của việc thực thi song song là có thể phát sinh một số lỗi tiềm ẩn phức tạp. Nhiều dự án skynet hiện tại đều dựa vào tính chất không block của hàm send để đảm bảo logic hoạt động đúng, nên không thể tùy tiện thay đổi.

Giải pháp của tôi là bổ sung một nhóm hàng đợi gửi riêng cho mỗi dịch vụ. Khi đối phương bận, tin nhắn chờ gửi sẽ được lưu vào hàng đợi gửi của chính dịch vụ gửi. Như vậy khung xử lý sẽ đảm bảo các tin nhắn được gửi đi lần lượt đúng thứ tự (không đảm bảo thứ tự giữa các đích đến khác nhau, nhưng đảm bảo thứ tự đối với cùng một đích đến).

Qua hai ngày suy nghĩ kỹ lưỡng, tôi thấy việc chuyển từ hàng đợi nhận đơn thuần sang kết hợp hàng đợi nhận và nhiều hàng đợi gửi thực sự tăng độ phức tạp triển khai, nhưng chưa đến mức không thể chấp nhận. Thực ra giải pháp này không mới, nhưng tại sao 3 năm trước khi phát triển skynet tôi lại không thực hiện? Có những lý do đặc biệt cần xem xét.

Một vấn đề cốt lõi là các luồng xử lý IO và timer khác biệt rõ rệt với dịch vụ thông thường. Chúng trực tiếp giao tiếp với hệ thống, cần nhận tin nhắn bên ngoài với tốc độ đều đặn và lập tức chuyển tiếp vào nội bộ skynet, đồng thời làm việc liên tục với đa phần các dịch vụ skynet. Do đó việc áp dụng cơ chế tương tự cho chúng là không hợp lý, bởi chúng không nên bị ảnh hưởng bởi cơ chế lập lịch phức tạp.

(P/S: IO và timer thread vốn không phải dịch vụ skynet tiêu chuẩn. Tôi từng thử biến IO thành dịch vụ độc lập nhưng sau đó từ bỏ để tích hợp vào nhân hệ thống.)

Một phương pháp xử lý linh hoạt là thiết lập hai dịch vụ đặc biệt đối ứng 1-1 với các luồng IO và timer. Kênh giao tiếp giữa chúng có thể thiết kế vô hạn độ dài, vì chỉ có duy nhất một bên ghi và một bên đọc, điều này cho phép triển khai hướng tối ưu riêng.

Vấn đề thứ hai cần giải quyết là cách xử lý tin nhắn còn lại khi dịch vụ ngừng hoạt động. Chúng ta phải đảm bảo các yêu cầu chờ xử lý đều được phản hồi thích đáng. Trong skynet 1.0, việc này do tầng Lua thực hiện bằng cách duyệt mọi yêu cầu chưa phản hồi khi exit và gửi tin nhắn lỗi. Tuy nhiên khi việc gửi tin nhắn không còn đảm bảo thành công, vấn đề trở nên phức tạp hơn. Trong thiết kế mới, các tin nhắn chưa gửi đi sẽ lưu trong hàng đợi gửi của chính dịch vụ gửi. Vậy khi dịch vụ này không còn tồn tại, ai sẽ chịu trách nhiệm phát tin nhắn đó đi?

Tương tự, khi tin nhắn chờ gửi thực sự được phát đi mà đối phương đã ngừng hoạt động, cần tạo ra phản hồi lỗi thích hợp. Giải pháp của tôi là:

  1. Phân biệt rõ ràng từ tầng nền các loại tin nhắn: yêu cầu/phản hồi, đẩy một chiều, lan truyền lỗi.
  2. Tập trung xử lý hủy bỏ dịch vụ vào một dịch vụ duy nhất. Khi hủy, tiến hành thu thập các hàng đợi tin nhắn chờ gửi, lấy ra các tin nhắn cần xử lý, sau đó tiếp tục phát đi.

Về phần tạo/lập và hủy bỏ dịch vụ, tôi còn nhiều ý tưởng mới sẽ dành riêng cho một bài viết khác.

Tổng kết, bài viết này tập trung vào thiết kế mới cho hàng đợi tin nhắn. Tôi dự kiến sẽ tùy biến ba loại hàng đợi theo nhu cầu cụ thể:

Thứ nhất: Hàng đợi đồng phát (multi-writer) đơn đọc (fixed-length), áp dụng cho trường hợp chỉ có một độc giả nhưng nhiều bên ghi. Vì độ dài cố định nên chỉ cần khóa khi thêm vào hàng đợi, không cần khóa khi lấy ra. Tuy nhiên không nhất thiết phải dùng thiết kế lock-free để giảm spin lock, bởi bất kỳ bên ghi nào gặp hàng đợi đầy hoặc có bên ghi khác đang xử lý, đều có thể coi hàng đợi bận và đơn giản gửi tin nhắn sang hàng đợi gửi của chính mình.

Thứ hai: Hàng đợi phi đồng phát (non-concurrent) với cả đọc/ghi cùng lúc, dùng để lưu tin nhắn chờ khi đối phương bận và thu thập nghiệp vụ tồn đọng khi hủy dịch vụ. Loại hàng đợi này có thể mở rộng không giới hạn trong giới hạn bộ nhớ. Vì không có cạnh tranh đồng phát, dễ triển khai đúng. Mỗi dịch vụ sẽ có nhiều hàng đợi như vậy, tuy nhiên vì số lượng sử dụng không nhiều nên mặc định thiết lập độ dài ngắn, dùng mảng đơn giản thay vì bảng băm. Khi cần duyệt, độ phức tạp O(n) hoàn toàn chấp nhận được (vì các dịch vụ quá tải thường không quá nhiều, nếu quá nhiều hệ thống cũng không thể vận hành trơn tru).

Thứ ba: Hàng đợi ống dẫn (pipeline) một đọc một ghi chuyên dụng cho kết nối giữa các luồng IO/timer và dịch vụ skynet nội bộ. Bên ghi và đọc thuộc hai luồng khác nhau. Hàng đợi này có thể tự mở rộng theo nhu cầu thông qua cơ chế khóa đọc/ghi. Khi đầy, thao tác mở rộng chỉ được thực hiện đồng thời bởi một thread duy nhất. Trước khi mở rộng, có thể tạo bản sao song song rồi dùng khóa để hoán đổi con trỏ mới/cũ thay vì khóa toàn bộ hàng đợi khi sao chép.

Cuối cùng, hôm qua tôi không thể tiếp tục trì hoãn ý tưởng này, đã dành cả ngày để

0%