Cách Skynet Xử Lý Trạng Thái Đóng Một Nửa Kết Nối TCP
Giao thức TCP cho phép truyền dữ liệu song công, bao gồm cả chiều gửi lên (upstream) và nhận xuống (downstream). Khi một kết nối bị ngắt, hai kênh truyền này có thể đóng độc lập với nhau. Ở cấp độ API, nếu hàm read() trả về giá trị 0, điều này cho thấy kênh upstream đã bị đóng và không còn dữ liệu nào được gửi đến nữa. Tuy nhiên, điều này không đồng nghĩa với việc kênh downstream cũng bị đóng - đối phương vẫn có thể tiếp tục mong chờ nhận dữ liệu từ phía chúng ta. Ngược lại, khi write() trả về lỗi -1 với mã EPIPE, điều này xác nhận kênh downstream đã bị ngắt, không nên tiếp tục gửi dữ liệu. Tuy nhiên, kênh upstream vẫn có thể còn hoạt động, nghĩa là chúng ta vẫn có thể nhận được dữ liệu từ read().
Cơ chế shutdown cho phép chủ động đóng từng kênh riêng biệt (chỉ đóng read hoặc write). Khi chỉ một phía của kết nối bị đóng, Skynet gọi đây là trạng thái “đóng một nửa”. Trong giai đoạn đầu phát triển, Skynet đơn giản hóa xử lý bằng cách xem trạng thái này như là kết nối đã bị đóng hoàn toàn. Quyết định này xuất phát từ mục tiêu ban đầu của Skynet tập trung vào các ứng dụng game online, nơi mà kết nối mạng luôn được coi là không đáng tin cậy. Cả client và server đều phải chuẩn bị sẵn sàng xử lý tình huống đối phương ngắt kết nối bất ngờ. Lớp nghiệp vụ thường xây dựng giao thức xác nhận riêng, không hoàn toàn phụ thuộc vào cơ chế底层 của TCP. Thông tin trạng thái thường không được lưu trữ ở phía client. Nếu có dữ liệu chưa được gửi hết trong phiên kết nối trước, hệ thống sẽ tự động lấy lại dữ liệu cần thiết khi thiết lập kết nối mới.
Vì vậy, khi gặp trường hợp read() trả về 0 hoặc write() gặp lỗi, Skynet sẽ trực tiếp gọi close() để đóng kết nối. Cách tiếp cận này giúp đơn giản hóa đáng kể việc triển khai ở tầng底层. Trong trường hợp cần đảm bảo độ tin cậy cho việc trao đổi dữ liệu nghiệp vụ, giải pháp được khuyến nghị là xây dựng cơ chế xác nhận ở tầng ứng dụng (xem thêm vấn đề 50).
Tuy nhiên, khi Skynet được ứng dụng rộng rãi trong các lĩnh vực mới, chúng tôi nhận thấy nhiều trường hợp phải tuân thủ các giao thức hiện có (không tiện thêm cơ chế xác nhận). Ví dụ điển hình là trường hợp server cần đẩy một lượng lớn dữ liệu rồi đóng kết nối. Nếu không có cơ chế xác nhận từ phía client, việc thực hiện đơn giản theo kiểu write()…write()…close() sẽ dẫn đến lỗi đóng kết nối quá sớm trước khi dữ liệu được gửi hết hoàn toàn.
Để giải quyết vấn đề này, Skynet đã bổ sung cơ chế xử lý trạng thái đóng một nửa. Khi gọi close() mà vẫn còn dữ liệu chưa gửi xong từ tầng nghiệp vụ, file descriptor (fd) sẽ được chuyển sang trạng thái đóng một nửa. Trong trạng thái này, fd không còn cho phép đọc/ghi từ phía nghiệp vụ, nhưng hệ thống底层 vẫn giữ kết nối cho đến khi toàn bộ dữ liệu được gửi thành công hoặc xác nhận thất bại (do đối phương đóng kết nối).
Cơ chế này vận hành ổn định trong nhiều năm cho đến khi xuất hiện các ứng dụng web server dựa trên Skynet. Một số trình duyệt khi tải file từ web server có hành vi đặc biệt: gửi yêu cầu HTTP xong lập tức đóng kênh write nhưng vẫn giữ kênh read để chờ phản hồi. Từ phía server, khi đọc xong request, hệ thống底层 sẽ nhận được giá trị 0 từ read(). Nếu lúc này server vội vàng đóng kết nối, phản hồi sẽ không thể gửi đi được. Vấn đề nằm ở chỗ việc đọc dữ liệu ở Skynet được thực hiện bởi thread底层, không phải do nghiệp vụ chủ động gọi read(), khiến nghiệp vụ không thể kiểm soát được trạng thái này.
Để giải quyết triệt để vấn đề, vào đầu năm 2021 tôi đã gửi một pull request cải tiến cách xử lý trạng thái đóng một nửa. Trong quá trình phát triển, tôi nhận ra rằng để giữ tính tương thích và không đẩy độ phức tạp lên tầng trên (yêu cầu nghiệp vụ phải cẩn thận đóng từng kênh riêng biệt), cần phải xử lý chi tiết hơn các trạng thái đóng một nửa. Không chỉ đơn thuần dùng một trạng thái chung, mà phải phân biệt rõ ràng giữa HALFCLOSE_READ và HALFCLOSE_WRITE (giống như cách nhân hệ điều hành xử lý). Đồng thời cũng cần phân biệt trạng thái đóng một nửa do chính server chủ động tạo ra hay do hành động từ phía client.
Việc xử lý trạng thái chủ động tương đối đơn giản: mặc dù nghiệp vụ không được phép chỉ đóng một kênh (read hoặc write), nhưng nếu trước khi đóng vẫn còn dữ liệu chưa gửi xong, hệ thống sẽ tự động chuyển sang trạng thái đóng một nửa bằng cách gọi shutdown() để đóng kênh đọc, đồng thời tiếp tục chờ cho đến khi dữ liệu được gửi hết.
Với trạng thái bị động, cần phân biệt hai trường hợp: nhận được giá trị 0 từ read() hoặc gặp lỗi EPIPE khi write(). Mỗi trường hợp cần được đánh dấu bằng trạng thái đóng một nửa tương ứng. Tuy nhiên, do thiếu kinh nghiệm trong lĩnh vực này, bản cập nhật đầu tiên đã để lại nhiều lỗi tiềm ẩn. May mắn nhờ cộng đồng sử dụng rộng rãi của Skynet, các phản hồi đã nhanh chóng được gửi về (xem chi tiết ở vấn đề 1346 và các thảo luận liên quan). Trong hai tuần qua, chúng tôi đã tập trung sửa chữa các lỗi này.
Phiên bản hiện tại đã khắc phục được các vấn đề đã biết. Phần lớn lỗi phát sinh liên quan đến việc hiểu chưa đúng cách hoạt động của epoll và kqueue - hai cơ chế I/O multiplexing có nhiều khác biệt tinh tế. Việc nghiên cứu kỹ tài liệu chính thức và tham khảo các câu hỏi trên StackOverflow như “TCP: Khi nào EPOLLHUP được kích hoạt?” đã giúp chúng tôi hiểu sâu hơn về các cơ chế底层 này.