IOCP, Kqueue, Epoll... Quan Trọng Đến Mức Nào?
Khi thiết kế máy chủ game MMO, tôi thường nghe những lời khuyên truyền thống rằng hàm select()
rất kém hiệu quả khi xử lý hàng ngàn kết nối đồng thời. Thay vào đó, chúng ta nên dùng các công nghệ hiện đại như IOCP (trên Windows), kqueue (trên FreeBSD) hay epoll (trên Linux). Điều này hoàn toàn chính xác - select()
thực sự chậm vì mỗi lần kernel phải quét toàn bộ danh sách socket được truyền vào. Anh Chen Rong từng ví von tại Thượng Hải rằng đây là kiểu “kỹ thuật lùng sục thôn xóm”: kiểu gì cũng phải hỏi đi hỏi lại “Kẻ địch đã vào làng chưa? Kẻ địch đã vào làng chưa?”… khiến CPU luôn trong tình trạng bận rộn. Chưa kể trên Windows còn có giới hạn ngặt nghèo là chỉ xử lý được tối đa 64 socket mỗi lần!
Các công nghệ như kqueue lại tiếp cận theo hướng thông minh hơn: thay vì tự đi kiểm tra, chúng ta cử “người canh gác” và chỉ báo động khi có sự kiện thực sự xảy ra. Hiệu suất vì thế tăng lên rõ rệt. Tuy nhiên gần đây tôi bắt đầu nghi ngờ: phải chăng nhất thiết phải xây dựng kiến trúc máy chủ dựa trên những công nghệ này?
Một ý tưởng mới hình thành trong đầu tôi là: hãy tách biệt hoàn toàn việc xử lý kết nối mạng bên ngoài và logic trò chơi bằng cách dùng hai máy chủ riêng biệt. Để tiện diễn giải, tạm gọi chúng là máy chủ kết nối (connection server) và máy chủ logic (logic server).
Máy chủ kết nối sẽ có nhiệm vụ cực kỳ đơn giản: tập hợp dữ liệu từ nhiều kết nối lại thành một luồng duy nhất. Giả sử tổng số kết nối không vượt quá 65,536, ta chỉ cần thêm 2 byte định danh vào mỗi gói dữ liệu là đủ nhận biết nguồn gốc. Sau đó, máy chủ kết nối chỉ cần dùng một kết nối duy nhất để truyền toàn bộ dữ liệu này sang máy chủ logic.
Điều này cho phép máy chủ kết nối tận dụng tối đa hiệu suất (có thể dùng epoll hay IOCP), trong khi logic xử lý lại cực kỳ đơn giản, mã nguồn gọn nhẹ. Về phía máy chủ logic, dù dùng phương pháp nào cũng không còn là vấn đề vì nó chỉ phải xử lý một kết nối duy nhất.
Tiếp tục mở rộng ý tưởng, giả sử logic trò chơi xử lý ở tần suất 10Hz (10 lần/giây), ta có thể thiết kế máy chủ kết nối gửi dữ liệu theo từng “xung nhịp” định kỳ 100ms. Mỗi xung sẽ bắt đầu bằng một gói thông báo độ dài dữ liệu, sau đó mới là dữ liệu thực tế. Ngay cả khi không có dữ liệu mới, vẫn phải gửi một gói báo độ dài = 0 để đảm bảo tính liên tục. Đồng thời, máy chủ kết nối cần kiểm soát tổng lượng dữ liệu mỗi xung, tránh việc gửi quá nhiều khiến máy chủ logic không xử lý kịp.
Tuyệt vời hơn nữa: máy chủ logic thậm chí có thể dùng hàm recv()
chặn (blocking) để nhận dữ liệu, hoàn toàn không cần select()
. Tính ổn định của việc nhận dữ liệu sẽ được đảm bảo bởi logic ở máy chủ kết nối. Khi thảo luận ý tưởng này với đồng nghiệp, anh ấy lo lắng về việc hàm chặn có thể gây treo máy chủ logic nếu có sự cố. Tuy nhiên tôi cho rằng đây không phải vấn đề lớn (sẽ giải thích kỹ hơn ở bài viết khác). Thực ra tôi thiết kế như vậy không phải để tiết kiệm một hàm select()
, mà là để phục vụ gỡ lỗi dễ dàng hơn. (Dĩ nhiên, nếu thực tế chứng minh không khả thi, thay đổi cũng rất đơn giản.)
Ưu điểm lớn nhất của cách nhận dữ liệu chặn chính là tính tuần tự tuyệt đối. Khi lưu lại toàn bộ luồng dữ liệu trao đổi giữa hai máy chủ, ta có thể tái hiện chính xác từng bước xử lý logic trò chơi bất kỳ lúc nào. Dù chạy thử nghiệm bao nhiêu lần đi nữa, hành vi của máy chủ logic luôn hoàn toàn giống nhau: cứ mỗi 0.1 giây lại nhận một gói dữ liệu xác định rồi xử lý.
Hệ quả là mã nguồn liên quan mạng lưới của máy chủ logic được rút gọn đáng kể, giúp tập trung hoàn toàn vào xây dựng logic trò chơi phức tạp.