Ghi Chú Phát Triển (18): Khóa Đọc/Ghi Và an Toàn Luồng
Trong giai đoạn gần đây, các vấn đề kỹ thuật tôi tập trung giải quyết đều xoay quanh đa luồng và xử lý song song. Mục tiêu của tôi là khi sản phẩm chính thức vận hành, hệ thống có thể tận dụng tối thiểu máy chủ 32 nhân. Điều này đòi hỏi việc phân bổ tải tính toán lên các nhân phải hiệu quả. Mặc dù số lượng người dùng trên một máy vật lý không cần quá cao, nhưng tôi không muốn người chơi gặp tình trạng nghẽn khi thực hiện các thao tác đặc biệt.
Điểm mấu chốt ở đây là đảm bảo xử lý song song trên phạm vi một máy, trong khi các tiến trình chạy trên thiết bị khác sẽ được xem xét riêng biệt. Do đó, tôi ưu tiên sử dụng bộ nhớ chia sẻ và khóa trên một máy để giải quyết bài toán trao đổi thông tin và đồng bộ trạng thái.
Tháng trước, tôi dành nhiều thời gian để xử lý vấn đề đọc đồng thời cùng một cấu trúc dữ liệu cấu hình trong Lua. Ban đầu tôi nghĩ rằng việc dùng một Lua State để lưu trữ dữ liệu cấu trúc hóa, sau đó cho phép các State khác đọc song song sẽ đảm bảo an toàn luồng. Tuy nhiên tôi đã sai lầm! Lý do là bởi thao tác đọc trên Lua State cần sửa đổi Lua Stack, mà thiếu khóa bảo vệ thì sẽ không thể an toàn với đa luồng.
Từ đó, tôi chuyển sang giải pháp cải tiến: Khởi tạo trước một số lượng Lua Thread, mỗi OS Thread sử dụng không gian Stack độc lập để thao tác. Vì khung công tác của chúng tôi chỉ khởi động một số lượng giới hạn OS Thread để quản lý nhiều Lua State hơn, phương pháp này khả thi và đảm bảo an toàn. Tuy nhiên vẫn tồn tại lỗ hổng: Nếu người dùng cố gắng nạp một key chuỗi không tồn tại vào Lua State, thao tác sửa đổi chuỗi pool trong Lua lại phát sinh vấn đề an toàn luồng. Dĩ nhiên vấn đề này có thể giải quyết phức tạp hơn (ví dụ: cấp cho mỗi luồng một State độc lập để kiểm tra tính hợp lệ của chuỗi).
Cuối cùng, tôi từ bỏ ý định lưu dữ liệu chia sẻ trực tiếp trong Lua State và thay vào đó xây dựng một bảng băm tự thiết kế có khả năng đọc an toàn đa luồng. Lúc này Lua chỉ đóng vai trò công cụ phân tích và nạp dữ liệu ban đầu. Bộ mã nguồn này đã được tôi mở rộng và công khai.
Tuần trước, đồng nghiệp Mike phụ trách module dịch vụ chiến đấu đưa ra yêu cầu mới.
Mike cho rằng sharedb mà tôi thiết kế có thể dùng để trao đổi dữ liệu giữa các nhân vật. Tuy nhiên, nếu một hiệu ứng BUFF cần sửa đổi đồng thời nhiều thuộc tính trên nhân vật, giải pháp hiện tại sẽ không đảm bảo an toàn. Về mặt logic, chúng tôi cần cơ chế thay đổi nhiều thuộc tính một cách nguyên tử (atomic), tuyệt đối không để tình trạng một tiến trình đọc dữ liệu “lỗi thời” trong khi BUFF đang cập nhật nhóm thuộc tính.
Chúng tôi cũng không muốn dùng cơ chế RPC để truyền tải các thuộc tính này. Cuối cùng tôi quyết định triển khai một cơ chế khóa, lấy từng nhân vật làm đơn vị khóa toàn bộ thuộc tính liên quan, đảm bảo tính nguyên tử khi đọc/ghi.
Ban đầu tôi tưởng tượng một giải pháp phức tạp hơn: Cho phép bộ lập lịch của khung công tác nhận biết khóa. Khi có khóa ghi được giữ, các coroutine đọc có thể bị treo và được đánh thức chính xác khi khóa được giải phóng. Điều này đòi hỏi mỗi đối tượng có thể bị khóa phải gắn kèm một hàng đợi an toàn luồng. Sau một ngày suy nghĩ, tôi tạm gác ý tưởng này vì độ phức tạp quá cao.
Cuối cùng tôi chọn giải pháp đơn giản hơn: Triển khai một khóa xoay (spinlock) đọc/ghi cơ bản. Không thông báo cho bộ lập lịch, mà chặn trực tiếp ở cấp CPU. Cách này gây ít thay đổi nhất cho mã nguồn hiện có và giao diện lập trình. Khi bọc giao diện khóa trong Lua, tôi cố gắng hạn chế tối đa quyền tự do sử dụng của người dùng. Thay vì cung cấp API khóa/mở khóa rõ ràng, tôi chỉ cho phép chạy một đoạn mã Lua ngắn không bị treo giữa chừng.
Spinlock hoạt động dựa trên sharedb, giúp các Lua State khác nhau có thể truy cập cùng một khóa trên dữ liệu nhân vật, từ đó đồng bộ hóa hiệu quả.
À nhân tiện, trong quá trình triển khai spinlock đọc/ghi, các hàm built-in của GCC hỗ trợ truy cập bộ nhớ nguyên tử thực sự rất hữu ích.