Một Lỗi Nghiêm Trọng Trong Lua 5.3.4 - nói dối e blog

Một Lỗi Nghiêm Trọng Trong Lua 5.3.4

Vào hôm qua, một dự án của chúng tôi đã gặp phải lỗi vòng lặp vô hạn nghiêm trọng. Sau hơn 10 tiếng đồng hồ gỡ lỗi, cuối cùng chúng tôi xác định nguyên nhân xuất phát từ lỗi trong phiên bản Lua 5.3.4.

Sự việc bắt đầu khi chúng tôi tích hợp một thư viện do tôi phát triển gần đây nhằm tối ưu hóa việc tải hàng loạt dữ liệu cấu hình cho ứng dụng client. Cơ chế hoạt động của thư viện này là chuyển đổi bảng dữ liệu thành cấu trúc C, lưu trữ trong một vùng nhớ liên tục. Khi chạy chương trình, chỉ những phần dữ liệu cần thiết mới được nạp vào máy ảo. Giải pháp này giúp tăng tốc độ tải dữ liệu lên gấp nhiều lần. Trong quá trình tích hợp, nhóm phát triển có thực hiện một thay đổi nhỏ: thiết lập tất cả bảng dữ liệu thành kiểu weaktable để cho phép hệ thống thu gom rác (GC) tự động giải phóng các mục dữ liệu không còn sử dụng.

Chính thay đổi này đã vô tình kích hoạt lỗi tiềm ẩn trong Lua.

Sau khi loại trừ khả năng do thư viện của tôi gây ra, chúng tôi tập trung điều tra mã nguồn Lua. Lỗi biểu hiện dưới dạng vòng lặp vô hạn xảy ra trong quá trình sao chép bảng dữ liệu. Như đã biết, bảng trong Lua sử dụng bảng băm dạng đóng (closed hash table). Khi chèn phần tử mới gặp xung đột, hệ thống sẽ tìm vị trí trống tiếp theo và nối vào chuỗi liên kết của vị trí băm ban đầu.

Tuy nhiên, trong trường hợp này, chuỗi liên kết bị hỏng nghiêm trọng: một vị trí trống (empty slot) trỏ ngược về chính nó qua con trỏ next, dẫn đến vòng lặp vô hạn khi duyệt.

Phân tích từ file coredump cho thấy cấu trúc dữ liệu của một chuỗi dài (long string) dùng làm khóa trong bảng băm đã bị hỏng. Biến extra trong cấu trúc này, vốn chỉ có thể nhận giá trị 0 hoặc 1 để đánh dấu trạng thái băm, lại có giá trị bất thường là 67. Đồng thời giá trị băm (hash value) bị đặt về 0 - điều rất hiếm khi xảy ra. Điều này khiến hệ thống truy xuất sai vị trí slot 0 (vị trí trống rỗng).

Thông qua bộ phân bổ bộ nhớ tùy chỉnh, chúng tôi ghi nhận rằng đối tượng long string bị lỗi đã bị VM Lua giải phóng trước khi xảy ra sự cố. Ban đầu nghi ngờ do lỗi trong bộ phân bổ, nhưng sau đó chúng tôi chuyển hướng điều tra quá trình thu gom rác của Lua.

Lỗi chỉ xuất hiện khi bảng dữ liệu được thiết lập là weaktable với cả khóa và giá trị. Nếu chỉ thiết lập giá trị là weak thì lỗi không xảy ra. Vấn đề nằm ở giai đoạn GCSatomic trong lgc.c, cụ thể là hàm atomic chịu trách nhiệm dọn dẹp các mục weaktable. Hàm clearkeys(g, g->allweak, NULL); được gọi để thực hiện nhiệm vụ này.

Dưới đây là đoạn mã nguồn liên quan:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/*
** Dọn dẹp các mục trong bảng yếu có khóa chưa được đánh dấu
** từ danh sách 'l' đến phần tử 'f'
*/
static void clearkeys (global_State *g, GCObject *l, GCObject *f) {
 for (; l != f; l = gco2t(l)->gclist) {
  Table *h = gco2t(l);
  Node *n, *limit = gnodelast(h);
  for (n = gnode(h, 0); n < limit; n++) {
   if (!ttisnil(gval(n)) && (iscleared(g, gkey(n)))) {
    setnilvalue(gval(n)); /* Xóa giá trị ... */
    removeentry(n); /* Xóa mục khỏi bảng */
   }
  }
 }
}

Hàm này sẽ duyệt qua bảng băm và xóa các mục khi giá trị không phải nil và khóa có thể bị dọn dẹp. Với chuỗi (string), việc xử lý đặc biệt hơn vì chúng được coi là giá trị chứ không phải tham chiếu. Hàm iscleared thực hiện xử lý đặc biệt này:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
/*
** Xác định xem khóa/giá trị có thể bị xóa khỏi bảng yếu không
** Đối tượng không thể thu gom không bao giờ bị xóa
** Chuỗi được coi là giá trị nên cũng không bị xóa
*/
static int iscleared (global_State *g, const TValue *o) {
 if (!iscollectable(o)) return 0;
 else if (ttisstring(o)) {
  markobject(g, tsvalue(o)); /* Đánh dấu chuỗi là không thể xóa */
  return 0;
 }
 else return iswhite(gcvalue(o));
}

Vấn đề nằm ở chỗ hàm clearkeys chỉ gọi iscleared khi giá trị không phải nil. Khi người dùng chủ động đặt giá trị của một mục về nil, sau khi GC hoàn tất, con trỏ đối tượng trong bảng băm có thể trỏ đến vùng nhớ đã bị giải phóng.

Đối với chuỗi ngắn (short string), điều này không gây vấn đề vì việc so sánh dựa trên địa chỉ bộ nhớ. Tuy nhiên với chuỗi dài, việc so sánh thực hiện dựa trên nội dung, dẫn đến truy cập vào vùng nhớ đã bị giải phóng và gây ra vòng lặp vô hạn.

Để minh họa vấn đề, tôi đã viết đoạn mã C sau:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
#include <stdlib.h>
#include <lstring.h>

static void *l_alloc (void *ud, void *ptr, size_t osize, size_t nsize) {
  if (nsize == 0) {
    printf("Giải phóng %p\n", ptr);
    free(ptr);
    return NULL;
  } else {
    return realloc(ptr, nsize);
  }
}

static int lpointer(lua_State *L) {
  const char * str = luaL_checkstring(L, 1);
  const TString *ts = (const TString *)str - 1;
  lua_pushlightuserdata(L, (void *)ts);
  return 1;
}

const char *source = "\n\
local a = setmetatable({} , { __mode = 'kv' })\n\
a['
0%