Chương Trình Đa Luồng Và Vấn Đề Bất Hợp Lý Khi Sử Dụng Fork - nói dối e blog

Chương Trình Đa Luồng Và Vấn Đề Bất Hợp Lý Khi Sử Dụng Fork

Tiếp nối chủ đề từ vài ngày trước về việc tối ưu hóa máy chủ game Đại Mộng Tây Du. Trong mã nguồn cũ, công việc lưu dữ liệu định kỳ được chia làm hai giai đoạn: đầu tiên là tuần tự hóa dữ liệu động trong máy ảo, sau đó ghi dữ liệu đã tuần tự hóa xuống ổ đĩa. Tuy nhiên, công đoạn tuần tự hóa không được tách ra chạy độc lập trong một luồng/tiến trình riêng biệt mà lại thực hiện trực tiếp trên luồng chính. Chỉ có phần thao tác I/O là được xử lý bởi một tiến trình độc lập.

Quá trình tuần tự hóa dữ liệu là một công việc phức tạp và tiêu tốn nhiều tài nguyên hệ thống. Trong môi trường game MMORPG yêu cầu phản hồi nhanh nhạy với các yêu cầu của người chơi, việc này đặc biệt gây áp lực. Khi số lượng người chơi trực tuyến tăng lên, một giải pháp tối ưu đơn giản là chia nhỏ công việc tuần tự hóa thành nhiều phần nhỏ, thực hiện dần qua nhiều chu kỳ hệ thống (heartbeat). Dù phương pháp này có thể gây ra một số vấn đề về tính nhất quán dữ liệu, nhưng cũng có nhiều kỹ thuật để giải quyết.

Tuy nhiên, khi số lượng người chơi đạt đến ngưỡng nhất định, quá trình tuần tự hóa vẫn gây ảnh hưởng nghiêm trọng đến hiệu năng hệ thống. Trong thời gian lưu dữ liệu định kỳ, độ trễ phản hồi từ máy chủ game tăng rõ rệt, dẫn đến hiện tượng giật lag chu kỳ. Để giải quyết vấn đề này, tôi muốn cải tiến hệ thống bằng cách tách riêng công việc tuần tự hóa sang một tiến trình độc lập.

Phương pháp tưởng chừng đơn giản: tại thời điểm lưu dữ liệu định kỳ, gọi hàm fork để tạo tiến trình con, sau đó từ từ thực hiện công việc tuần tự hóa trong tiến trình con (có thể sử dụng lệnh nice để giảm độ ưu tiên). Khi hoàn tất, tiến trình con sẽ chuyển dữ liệu cho tiến trình I/O để ghi đĩa. Tuy nhiên do thiết kế ban đầu của hệ thống, tôi phải sử dụng bộ nhớ chia sẻ để chuyển kết quả tuần tự hóa từ tiến trình con về tiến trình cha, sau đó mới gửi tiếp cho tiến trình I/O.

Vì fork tạo ra bản sao bộ nhớ của tiến trình cha nên về mặt lý thuyết không có vấn đề về tính nhất quán dữ liệu. Đây vốn là mô hình phổ biến trong các game mạng. Nhưng vấn đề phát sinh khi máy chủ hiện tại đã chuyển sang sử dụng đa luồng, khiến việc fork tiến trình con trở nên tiềm ẩn rủi ro.

Một chương trình kết hợp cả đa tiến trình và đa luồng nghe có vẻ phi thực tế - kiểu thiết kế chỉ có thể đến từ những người “rảnh đến phát chán”. Tuy nhiên, như mọi khi, chúng ta lại phải viện đến lý do quen thuộc: “đây là di sản lịch sử để lại”.

Theo tiêu chuẩn POSIX, hành vi của fork được mô tả như sau: sao chép toàn bộ dữ liệu không gian người dùng (thường sử dụng cơ chế copy-on-write để thực hiện nhanh chóng) cùng tất cả các đối tượng hệ thống, nhưng chỉ sao chép duy nhất luồng gọi fork sang tiến trình con. Điều này đồng nghĩa với việc tất cả các luồng khác trong tiến trình cha sẽ “bốc hơi” hoàn toàn khi tiến trình con được tạo ra.

Sự biến mất đột ngột của các luồng khác chính là nguồn gốc của mọi vấn đề.

Mặc dù trước đây tôi chưa từng làm việc với chương trình kết hợp đa tiến trình và đa luồng, nhưng công ty có đồng nghiệp David Xu - người đang duy trì thư viện luồng của FreeBSD - là chuyên gia trong lĩnh vực này. Sau một buổi chiều thảo luận sôi nổi với anh ấy, tôi mới thực sự hiểu rõ những rắc rối tiềm ẩn. Hãy cùng phân tích kỹ hơn.

Vấn đề nghiêm trọng nhất có thể phát sinh liên quan đến khóa (lock).

Để tối ưu hiệu năng, hầu hết các hệ thống hiện đại đều triển khai khóa trong không gian người dùng. Điều này đồng nghĩa với việc các đối tượng khóa sẽ bị sao chép sang tiến trình con khi fork.

Về bản chất, mỗi khóa đều có một chủ sở hữu - chính là luồng cuối cùng đã khóa nó. Giả sử trong tiến trình cha, một luồng con đã khóa một tài nguyên nào đó. Sau khi fork, tất cả các luồng khác (trừ luồng gọi fork) sẽ biến mất khỏi tiến trình con. Trong khi đó, khóa vẫn được sao chép nguyên vẹn. Từ góc nhìn của tiến trình con, khóa này không còn chủ sở hữu, nên không có cách nào để mở khóa được nữa.

Khi tiến trình con cố gắng khóa tài nguyên này, sẽ không có bất kỳ cơ chế nào có thể giải phóng nó, dẫn đến tình trạng deadlock (chặn nghẽn vĩnh viễn).

Tại sao tiêu chuẩn POSIX lại cho phép tồn tại quy tắc rõ ràng bất hợp lý này - cho phép sao chép một khóa đã chết? Câu trả lời nằm ở hai yếu tố lịch sử và hiệu năng. Việc triển khai khóa trong không gian người dùng vốn thuận tiện hơn (và vẫn vậy cho đến ngày nay), thường chỉ cần một lệnh nguyên tử. Fork chỉ đơn thuần sao chép không gian người dùng mà không quan tâm đến chi tiết các đối tượng bên trong.

Một quy tắc thông thường là trước khi fork, luồng gọi fork phải khóa tất cả các khóa mà tiến trình con có thể sử dụng, sau đó lần lượt mở khóa sau khi fork. Tuy nhiên, cách làm này tiềm ẩn rủi ro deadlock nếu thứ tự khóa/mở khóa không khớp với quy trình thông thường.

Không chỉ các khóa tường minh mới gây vấn đề - nhiều hàm thư viện chuẩn (CRT) cũng sử dụng khóa ngầm. Chẳng hạn như hàm fprintf dùng để ghi log. Vì các thao tác trên đối tượng FILE* đều dựa vào khóa để đảm bảo an toàn đa luồng, nên việc gọi fprintf trong luồng con trước fork có thể gây ra sự cố nghiêm trọng

0%