Ghi Chép Nhỏ Về Hành Trình Truy Vết Rò Rỉ Bộ Nhớ
Gần đây, hệ thống máy chủ game của chúng tôi lại gặp sự cố nghiêm trọng về rò rỉ bộ nhớ. Điều đặc biệt ở đây là tốc độ rò rỉ cực kỳ chậm, khiến lỗi này ẩn giấu rất tinh vi và khó phát hiện.
Quy trình xác nhận và khắc phục bug này như sau: Vì Skynet về bản chất được điều khiển bởi một module đồng hồ duy nhất, nên trước tiên chúng tôi đã chỉnh sửa mã nguồn phần đồng hồ để hệ thống có thể chạy với tốc độ gấp 10 lần bình thường. Nhờ đó, hiện tượng vốn cần vài ngày mới phát hiện được đã được thu gọn còn nửa ngày. Điều này giúp khẳng định đây thực sự là lỗi rò rỉ bộ nhớ chứ không phải vấn đề tăng trưởng bộ nhớ do quy trình xử lý bình thường gây ra.
Chúng tôi trước đó đã thiết kế các giao diện gỡ lỗi dành cho từng dịch vụ độc lập. Phần lớn dịch vụ được viết bằng Lua, nhờ giao diện debug, chúng tôi có thể dễ dàng theo dõi lượng bộ nhớ mà từng máy ảo Lua đang chiếm dụng.
Tuy nhiên hệ thống debug cũ có một số hạn chế: nó phụ thuộc vào khả năng phản hồi của máy ảo Lua. Khi dịch vụ bị treo, hệ thống này không thể tự động báo cáo thông tin debug. Mặc dù lần này lỗi không liên quan đến điểm này, nhưng đây cũng là cơ hội để chúng tôi cải tiến cơ chế báo cáo bộ nhớ trong Lua.
Giải pháp đưa ra là thiết kế một bộ cấp phát bộ nhớ tùy chỉnh cho máy ảo Lua, gom toàn bộ báo cáo sử dụng bộ nhớ vào một mảng toàn cục trong C. Nhờ vậy, ngay cả khi dùng gdb attach vào tiến trình cũng có thể trực tiếp xem báo cáo. Giao diện debug hiện không còn lệ thuộc vào khả năng phản hồi của dịch vụ để báo cáo trạng thái bộ nhớ.
Trong quá trình xử lý sự cố, chúng tôi đã loại trừ khả năng rò rỉ các đối tượng Lua. Do lượng bộ nhớ chiếm dụng trong máy ảo Lua không có hiện tượng tăng bất thường, nên các công cụ debug nhỏ trước đây khó phát huy tác dụng.
Ở cấp độ mã C, chúng tôi sử dụng jemalloc để hỗ trợ phân tích rất hiệu quả.
Hiện tượng lần này khá điển hình: khi máy chủ tắt bình thường, hầu hết bộ nhớ đều được giải phóng đúng cách. Chúng tôi đã thêm nhiều log chi tiết hơn trong quá trình shutdown, từ đó phát hiện ra tại thời điểm đóng một bản đồ, khi máy ảo Lua bị huỷ, lượng lớn bộ nhớ trong C được giải phóng - con số này vượt xa lượng bộ nhớ Lua đang chiếm giữ.
Điều này gần như khẳng định rằng có rất nhiều đối tượng C bị tham chiếu trong Lua State nhưng không được giải phóng trong quá trình chạy, chỉ được giải phóng khi dịch vụ dừng. Tuy nhiên, chúng tôi lại không quan sát thấy sự gia tăng đột ngột về bộ nhớ Lua State trong quá trình vận hành. Ban đầu hiện tượng này thật sự gây nhầm lẫn.
Sau nhiều lần suy nghĩ, tôi đã xác định được nguyên nhân. Vấn đề nằm ở module quản lý AOI (Area of Interest) viết bằng C được ứng dụng trong dịch vụ bản đồ. Module này sử dụng các handle số nguyên để đóng gói các đối tượng bên trong. Khi viết lớp bao bọc Lua, lập trình viên chỉ đơn giản cung cấp vài API để tạo và xoá handle. Do đó các đối tượng C tồn tại dưới dạng handle trong máy ảo Lua. Những người sử dụng module này đã vô tình quên không xoá handle ở thời điểm thích hợp.
Cụ thể hơn, khi quái vật chết, hệ thống phải xoá handle đối tượng AOI của nó. Lỗi này tồn tại âm ỉ lâu nay vì tần suất quái vật chết không cao, đồng thời mỗi đối tượng C trong module AOI tiêu hao lượng bộ nhớ rất nhỏ. Cho đến khi thiết kế lại bản đồ, các phe quái vật mới tàn sát lẫn nhau liên tục, hiện tượng rò rỉ mới trở nên rõ rệt.
Tôi học được bài học sâu sắc: đừng bao giờ giao trách nhiệm huỷ bỏ đối tượng tường minh cho người dùng ngôn ngữ động. Hãy tận dụng tối đa cơ chế garbage collection, đồng thời thiết kế giao diện API phải phù hợp hơn với đặc điểm của ngôn ngữ đó. Điều này đặt ra yêu cầu cao hơn cho người viết thư viện bindings cho Lua.
Ví dụ trong trường hợp này, nếu thay vì dùng handle số nguyên, chúng tôi tạo các userdata độc lập chứa handle C, kèm theo phương thức __gc
để đảm bảo hủy bỏ đối tượng kịp thời, dù chi phí vận hành có tăng nhẹ nhưng tính ổn định sẽ cao hơn nhiều.
Về chủ đề thiết kế thư viện bao bọc này, tôi vừa mới có dịp viết chi tiết gần đây.