Ghi Lại Một Lỗi Phát Sinh Từ Xử Lý Song Song
Hôm nay tôi phát hiện ra một lỗi nghiêm trọng trong cơ chế xử lý tin nhắn của Skynet, xuất phát từ việc xử lý đa luồng. Lại một lần nữa tôi nhận ra việc viết đúng hoàn toàn các chương trình đa luồng là điều cực kỳ khó khăn. Vì còn thiếu sót trong kinh nghiệm, tôi quyết định ghi chép lại vấn đề này để làm bài học cho tương lai.
Cơ chế phân phối tin nhắn của Skynet được thiết kế như sau: Tất cả các đối tượng dịch vụ đều được biểu diễn dưới dạng cấu trúc C gọi là ctx, mỗi ctx sở hữu duy nhất một handle dạng số nguyên. Mỗi ctx có một hàng đợi tin nhắn riêng (mq). Khi có tin nhắn nội bộ được tạo ra, hệ thống sẽ ghi nhận handle của đối tượng nhận, tra cứu ctx tương ứng qua handle này và đẩy tin nhắn vào mq của ctx đó.
Việc xóa bỏ ctx phải được thực hiện an toàn. Do đó, Skynet áp dụng cơ chế đếm tham chiếu an toàn đa luồng. Mỗi lần truy xuất ctx qua handle, hệ thống sẽ tăng bộ đếm tham chiếu để đảm bảo ctx không bị giải phóng trong quá trình xử lý.
Skynet duy trì một hàng đợi toàn cục globalmq chứa các mq của nhiều ctx. Để tối ưu hiệu suất (vì đa phần thời gian các ctx không có tin nhắn để xử lý), mq sẽ được loại khỏi globalmq khi rỗng nhằm tránh lãng phí CPU.
Hệ thống khởi chạy nhiều luồng làm việc liên tục lấy mq từ globalmq ra xử lý. Để đảm bảo không có hai luồng nào xử lý cùng lúc một ctx, khi một mq được lấy ra khỏi globalmq, nó sẽ không được đưa lại vào cho đến khi hoàn tất xử lý.
Quy trình xử lý diễn ra như sau: pop một tin nhắn từ mq, gọi hàm callback của ctx, sau đó đẩy mq trở lại globalmq. Không xử lý hết toàn bộ tin nhắn trong mq một lần nhằm đảm bảo công bằng - tránh trường hợp một ctx chiếm dụng toàn bộ CPU. Khi phát hiện mq rỗng, hệ thống sẽ không đẩy mq trở lại để tiết kiệm tài nguyên.
Khi có tin nhắn mới được sinh ra, cần thực hiện logic: nếu mq chưa nằm trong globalmq thì đưa nó vào. Một vấn đề phức tạp khác là quá trình khởi tạo ctx - trong khi ctx có thể gửi tin nhắn ngay từ giai đoạn khởi tạo, nhưng các tin nhắn nhận được phải được lưu tạm trong mq cho đến khi hoàn tất khởi tạo mới xử lý.
Để giải quyết điều này, tôi áp dụng thủ thuật: trước khi bắt đầu khởi tạo, đánh dấu mq đang “giả vờ” nằm trong globalmq (qua một cờ hiệu trong cấu trúc mq). Điều này ngăn mq bị thêm vào globalmq thật, do đó không bị các luồng làm việc lấy ra xử lý sớm. Sau khi khởi tạo xong (dù thành công hay thất bại), mq bắt buộc phải được đẩy vào globalmq để bắt đầu nhận tin nhắn.
Vấn đề mấu chốt nằm ở việc xoá ctx không thể hủy mq ngay lập tức, vì mq có thể vẫn đang được globalmq tham chiếu. Đặc biệt, mq không lưu trữ con trỏ ctx (sẽ rất nguy hiểm trong môi trường đa luồng) mà chỉ lưu handle tương ứng.
Sai lầm trước đây của tôi là nghĩ rằng việc xoá mq có thể hoàn toàn giao phó cho globalmq. Khi hủy ctx, nếu mq không nằm trong globalmq thì đẩy lại vào đó. Khi luồng làm việc lấy mq ra và không tìm thấy ctx tương ứng với handle, mq mới được giải phóng.
Vấn đề nằm ở chỗ: việc liên kết handle với ctx được thực hiện bên ngoài module ctx (nếu không sẽ không thể hủy ctx đúng cách), nên không thể đảm bảo handle mất hiệu lực đồng thời với việc ctx bị xóa. Khi luồng làm việc xác định mq có thể hủy (handle vô hiệu), ctx có thể vẫn tồn tại (do luồng khác đang giữ tham chiếu), khiến mq bị giải phóng trong khi vẫn có thể nhận tin nhắn mới.
Trong quá trình phát triển Skynet, tôi đã từng thực hiện một cải tiến lớn: từ cơ chế hàng đợi tin nhắn một cấp chuyển sang hàng đợi hai cấp như hiện tại. Giải pháp cũ rất khó đảm bảo vừa bảo toàn thứ tự tin nhắn vừa ngăn xử lý song song trên cùng một ctx. Có lẽ trong quá trình cải tổ đó, tôi đã áp dụng một số giải pháp chưa tối ưu, khiến lỗi nêu trên phát sinh.
Giải pháp hiện tại tôi áp dụng như sau: Khi hủy ctx, hệ thống thiết lập một cờ hiệu dọn dẹp trong mq của nó. Khi luồng làm việc lấy mq ra khỏi globalmq và phát hiện handle không còn hợp lệ, trước tiên sẽ kiểm tra cờ hiệu này. Nếu chưa có, mq sẽ được đẩy trở lại vào globalmq để chờ xử lý tiếp. Chỉ khi cờ hiệu dọn dẹp được kích hoạt, mq mới được phép hủy bỏ hoàn toàn.