Vấn Đề Hiệu Năng Do Thuật Toán Lên Lịch Tác Vụ Gây Ra
Gần đây tôi gặp phải một vấn đề hiệu năng nghiêm trọng liên quan đến thuật toán lập lịch trong thư viện đa tác vụ ltask do tôi phát triển. Vấn đề này đòi hỏi nhiều thời gian gỡ lỗi và phân tích kỹ lưỡng. ltask ban đầu được hình thành từ những suy ngẫm về kiến trúc skynet, với mục tiêu tiếp cận theo hướng hoàn toàn khác: xây dựng thành thư viện thay vì framework, giảm thiểu cạnh tranh khóa, tránh tình trạng dịch vụ bị quá tải do hàng đợi tin nhắn tích tụ…
Trong quá trình phát triển, chúng tôi bắt đầu áp dụng ltask vào engine game để tận dụng đa nhân trên thiết bị di động. Điều này khiến ltask dần hoàn thiện và phát triển theo hướng khác biệt so với skynet. Đặc biệt, trong hai năm gần đây khi tập trung tối ưu cho ứng dụng client trên di động, phần mạng không còn là trọng tâm như trong skynet. Thay vào đó, mạng được thiết kế thành dịch vụ độc lập, tách biệt khỏi kiến trúc底层. Các thành phần như IO mạng, IO tệp tin và cửa sổ client được tách riêng thành các luồng độc quyền vì chúng cần tương tác trực tiếp với hệ điều hành, khác biệt với các dịch vụ liên quan đến render. Đặc biệt trên iOS, luồng cửa sổ bắt buộc phải chạy trên luồng chính, điều này đòi hỏi cơ chế hỗ trợ đặc biệt trong ltask.
Vấn đề mới phát sinh này rất đặc trưng cho ứng dụng client game, phản ánh rõ sự khác biệt trong trọng tâm thiết kế giữa skynet (dùng cho server) và ltask (dùng cho client). Cụ thể, chúng tôi phát hiện một hệ thống tưởng chừng rất đơn giản trong vòng lặp logic chính lại tiêu tốn trung bình 4ms mỗi frame (trên tổng thời gian frame khoảng 10ms, tức chiếm 40%). Qua phân tích, tôi nhận ra hàm thống kê thời gian trong ltask có điểm chưa hợp lý khi vận hành trong môi trường đa nhiệm. Khi thay thế bằng cơ chế thống kê của skynet, kết quả cho thấy chi phí CPU thực tế của hệ thống này gần như không đáng kể. Vấn đề thực sự nằm ở việc hệ thống này đã chủ động nhường quyền điều khiển, khiến dịch vụ logic chính bị treo trong thời gian dài.
Điều kỳ lạ là hệ thống này không có lệnh chặn chờ phản hồi từ dịch vụ khác, chỉ đơn thuần gửi đi một tin nhắn (không cần chờ kết quả). Tại sao thao tác đơn giản này lại gây treo dịch vụ? Sau khi kích hoạt log của bộ lập lịch ltask và phân tích hàng chục triệu bản ghi, chúng tôi đã tìm ra nguyên nhân gốc rễ liên quan đến sự khác biệt trong thuật toán lập lịch giữa ltask và skynet.
Trong skynet, việc gửi tin nhắn là phi chặn, tin nhắn được đưa trực tiếp vào hàng đợi của người nhận. Cách tiếp cận này tồn tại hai hạn chế: (1) nhiều tác nhân cùng cạnh tranh truy cập hàng đợi tin nhắn, (2) hàng đợi không giới hạn chiều dài khiến dịch vụ dễ bị quá tải. Trong khi giải pháp dùng spinlock hoặc cấu trúc không khóa có thể giải quyết vấn đề thứ nhất, thì vấn đề thứ hai mới là mối quan tâm chính vì hàng đợi vô hạn thường che giấu các lỗi thiết kế nền tảng.
Trong ltask, tôi đã thiết kế lại hoàn toàn: giới hạn độ dài hàng đợi nhận tin nhắn, loại bỏ hàng đợi gửi tin nhắn cho dịch vụ thường. Mỗi dịch vụ chỉ có một khe gửi tin nhắn, nếu tin nhắn chưa được xử lý, dịch vụ sẽ bị treo. Toàn bộ thao tác gửi tin nhắn được quản lý bởi một bộ lập lịch toàn cục sử dụng khóa lớn duy nhất. Để giảm cạnh tranh khóa toàn cục, tôi thiết kế chiến lược lập lịch mới: bất kỳ luồng làm việc nào cũng có thể cạnh tranh giành quyền lập lịch. Người giành chiến thắng sẽ xử lý toàn bộ công việc lập lịch cho tất cả luồng, đảm bảo các luồng khác không bị lãng phí CPU khi tranh giành khóa.
Quy trình làm việc thực tế của mỗi dịch vụ được chia thành các đoạn nhiệm vụ nhỏ (thông qua coroutine yield của Lua). Bộ lập lịch đảm nhiệm hai nhiệm vụ chính: (1) kiểm tra các đoạn nhiệm vụ đã hoàn thành có sinh tin nhắn gửi đi không, nếu có thì xử lý và cho phép nhận đoạn nhiệm vụ mới; (2) phân bổ nhiệm vụ tiếp theo cho các luồng làm việc rảnh. Nhờ thiết kế này, các luồng làm việc gần như không cần dừng đột ngột, chỉ cần cố gắng giành quyền lập lịch giữa các nhiệm vụ.
Tưởng chừng hoàn hảo, nhưng thiết kế này tạo ra vấn đề mới: mỗi luồng không thể kiểm soát nhiệm vụ tiếp theo của mình. Trong trường hợp cần tối ưu thời gian hoàn thành toàn bộ tác vụ, điều này không quan trọng nếu bộ lập lịch đảm bảo công bằng. ltask áp dụng cơ chế công bằng tuyệt đối thông qua hàng đợi vòng, giúp giảm thiểu cạnh tranh giữa các luồng - yếu tố quan trọng cho tiết kiệm năng lượng trên di động.
Vấn đề cụ thể trong hệ thống của chúng tôi liên quan đến dịch vụ logic chính - dịch vụ tiêu tốn nhiều thời gian nhất, quyết định giới hạn tốc độ khung hình. Vì phải chờ tất cả dịch vụ hoàn thành mới chuyển sang frame mới, việc sắp xếp không hợp lý giữa các luồng gây ra hiện tượng nối tiếp (serial) không mong muốn. Dịch vụ hiệu ứng đặc biệt (particle system) với đặc tính biến động cao và yêu cầu xử lý không thể ngắt quãng đã trở thành “nút cổ chai” khi bị xếp chung luồng với dịch vụ chính.
Ban đầu tôi cố gắng điều chỉnh bộ lập lịch để các luồng ưu tiên xử lý tiếp dịch vụ đang thực hiện, tương tự cách skynet giảm cạnh tranh bằng cơ chế xử lý riêng luồng cho từng dịch vụ. Tuy nhiên trong ltask, việc gửi tin nhắn liên quan mật thiết đến bộ lập lịch nên không thể giữ quyền điều khiển dịch vụ khi gửi tin. Tôi thử nghiệm nhiều giải pháp như: khóa dịch vụ sau khi gửi tin để tự phân bổ tiếp, nhưng gây ra deadlock do tăng độ phức tạp đồng bộ hóa và cạnh tranh khóa.
Cuối cùng, tôi tìm ra giải pháp từ chiến lược “nghỉ ngơi” của luồng làm việc. Thay vì cố gắng giữ số luồng hoạt động tối thiểu để tiết kiệm pin, tôi cho phép đánh thức thêm luồng khi cần thiết. Dù điều này làm tăng tiêu thụ năng lượng nhẹ, nhưng giảm đáng kể độ trễ khi phân bổ lại nhiệm vụ. Trong ví dụ của chúng tôi, việc tạm thời đánh thức thêm hai luồng làm việc giúp phân tách xử lý song song giữa dịch vụ chính và hiệu ứng, dù phải đánh đổi một chút hiệu năng.
Giải pháp này không hoàn hảo vì không đảm bảo dịch vụ A luôn chạy trên luồng 1 và dịch vụ B trên luồng 2, nhưng nó hiệu quả hơn trong việc giảm độ trễ tổng thể. Đây là minh chứng cho sự cân bằng tinh tế giữa hiệu năng, tiết kiệm năng lượng và độ phản hồi tức thời