Menu

Rò rỉ bộ nhớ trong C: nguyên nhân và cách phát hiện

Rò rỉ bộ nhớ thực chất là gì, ba cách chương trình C gây ra nó, nguyên tắc sở hữu giúp phòng tránh, và cách tìm phần còn lại bằng valgrind và -fsanitize=address. Cuối bài là một chương trình bị rò rỉ được sửa từng bước.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Rò rỉ bộ nhớ (memory leak) không phải là bộ nhớ biến mất. Đó là vùng nhớ vẫn thuộc về bạn, vẫn đang bị giữ chỗ, nhưng bạn đã mất khả năng trả lại, vì con trỏ cuối cùng tới nó không còn nữa. Không có gì bị crash. Chương trình vẫn chạy, nặng thêm một chút sau mỗi lần, cho đến khi một thứ gì đó hỏng ở một nơi chẳng liên quan.

Bài này nói về cách rò rỉ phát sinh, nguyên tắc giúp tránh phần lớn trường hợp, và hai công cụ tìm ra phần còn lại.

Rò rỉ bộ nhớ trông như thế nào

Mỗi lần lặp lại ghi đè block bằng một con trỏ mới. Khối nhớ trước đó vẫn đang được cấp phát; không biến nào còn giữ địa chỉ của nó; nó không bao giờ được giải phóng nữa. Ba lần lặp mất mười hai kilobyte. Một máy chủ làm việc này mỗi request sẽ mất chỗ bộ nhớ đó vĩnh viễn, với tốc độ bằng đúng tốc độ request đến.

Cách sửa chỉ là một dòng, free(block); ở cuối thân vòng lặp, nhưng nhận ra nó cần đặt ở đâu mới là kỹ năng thật sự.

Rò rỉ bộ nhớ xảy ra như thế nào

1. Mất con trỏ

Bất kỳ phép gán nào cho một con trỏ đang giữ tham chiếu duy nhất tới một khối nhớ còn sống đều làm rò rỉ khối đó.

char *name = malloc(32);
name = malloc(64);     /* the first 32 bytes are now unreachable */

Vòng lặp ở trên chính là lỗi này, chỉ là nằm trong vòng lặp. Gán lại một trường của struct cũng vậy, và cách viết tắt với realloc trong bài calloc và realloc cũng thế:

p = realloc(p, n);     /* on failure: p becomes NULL, old block orphaned */

2. Return sớm

Mọi lối ra khỏi một hàm đều phải trả lại những gì hàm đã lấy. Lối bị quên luôn là nhánh xử lý lỗi.

Nhánh bình thường thì đúng còn nhánh lỗi thì rò rỉ, và vì thế lỗi này lọt qua kiểm thử: nhánh thất bại gần như không bao giờ chạy trong lúc phát triển. Cách sửa là một đoạn dọn dẹp duy nhất mà mọi nhánh đều nhảy tới:

Đây là cách dùng goto duy nhất mà các lập trình viên C nhiều kinh nghiệm chủ động khuyên dùng. Nó hoạt động vì mọi con trỏ đều bắt đầu bằng NULL và free(NULL) không làm gì cả, nên một khối thoát duy nhất luôn đúng, bất kể hàm đã chạy được đến đâu.

3. Quyền sở hữu không rõ ràng

Những chỗ rò rỉ khó thấy nhất hoàn toàn không phải lỗi viết code, mà là hai hàm bất đồng về việc ai có trách nhiệm.

char *build_message(void);   /* does the caller free this? */
void  store(char *text);     /* does store take ownership? */

Nếu build_message trả về vùng nhớ đã cấp phát và store sao chép nó, thì bên gọi phải giải phóng. Nếu store giữ lại con trỏ, bên gọi không được giải phóng. Không có gì trong code nói rõ trường hợp nào, nên một trong hai giả định bị đưa ra hai lần, và bạn nhận được hoặc rò rỉ, hoặc double free.

Cách chữa là một quy ước, ghi trong chú thích ngay cạnh mọi hàm có cấp phát:

/* Returns a newly allocated string; the caller must free it. */
char *build_message(void);

/* Takes ownership of 'text'; it will be freed by store_free(). */
void store(char *text);

Viết quy tắc ngay tại hàm, không phải trong một tài liệu thiết kế. Đây là thói quen đáng giá nhất trong quản lý bộ nhớ ở C.

Nguyên tắc sở hữu

Bốn quy tắc bao quát gần như mọi thứ:

  1. Mỗi lần cấp phát có đúng một chủ sở hữu, tức một phần code chịu trách nhiệm giải phóng nó.
  2. Ghép mỗi hàm cấp phát với một hàm giải phóng. vec_init / vec_free, config_load / config_free. Sự đối xứng giúp một lời gọi bị thiếu lộ ra ngay.
  3. Giải phóng ở cùng tầng đã cấp phát, trừ khi chú thích của hàm nói rõ là chuyển quyền sở hữu.
  4. Gán con trỏ về NULL sau khi giải phóng, để một lần vô tình dùng lại sau đó sẽ crash ngay tại chỗ lỗi thay vì âm thầm làm hỏng heap.

Tìm rò rỉ bộ nhớ: valgrind

Trên Linux, valgrind không cần biên dịch lại, dù debug symbol giúp báo cáo dễ đọc hơn:

gcc -g -O0 program.c -o program
valgrind --leak-check=full --show-leak-kinds=all ./program

Với vòng lặp bị rò rỉ ở đầu bài, báo cáo sẽ kết thúc bằng một đoạn kiểu như:

==12345== HEAP SUMMARY:
==12345==     in use at exit: 12,000 bytes in 3 blocks
==12345==   total heap usage: 3 allocs, 0 frees, 12,000 bytes allocated
==12345==
==12345== 12,000 bytes in 3 blocks are definitely lost in loss record 1 of 1
==12345==    at 0x4C2FB0F: malloc (vg_replace_malloc.c:299)
==12345==    by 0x108671: main (program.c:6)
==12345==
==12345== LEAK SUMMARY:
==12345==    definitely lost: 12,000 bytes in 3 blocks

Đọc từ dưới lên. "definitely lost" nghĩa là lúc kết thúc không còn con trỏ nào tới khối nhớ đó: một chỗ rò rỉ thật. Stack trace chỉ ra dòng malloc đã tạo khối nhớ, không phải dòng làm mất nó, nhưng thường vậy là đủ để tìm ra lệnh free bị thiếu.

Còn hai loại khác:

  • indirectly lost: những khối chỉ truy cập được thông qua một khối đã bị mất, chẳng hạn các phần tử của một danh sách liên kết bị rò rỉ. Sửa chỗ "definitely lost" là những khối này biến mất.
  • still reachable: vẫn được cấp phát lúc kết thúc nhưng còn con trỏ trỏ tới, thường là một bộ đệm toàn cục. Không phải rò rỉ theo nghĩa nguy hiểm, nhưng vẫn nên giải phóng để báo cáo luôn trống.

Valgrind còn bắt được việc đọc vùng nhớ chưa khởi tạo và ghi vượt quá cuối một khối, và đó thường là cách bạn phát hiện ra bug nằm phía sau chỗ rò rỉ.

Tìm rò rỉ bộ nhớ: AddressSanitizer

AddressSanitizer có sẵn trong GCC và Clang, chạy nhanh hơn valgrind rất nhiều, và hoạt động ở những nơi valgrind không chạy được (kể cả macOS hiện nay):

gcc -g -fsanitize=address -fno-omit-frame-pointer program.c -o program
./program

Báo cáo rò rỉ tự động in ra khi chương trình kết thúc:

=================================================================
==12345==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 12000 byte(s) in 3 object(s) allocated from:
    #0 0x7f... in malloc
    #1 0x1086... in main program.c:6

SUMMARY: AddressSanitizer: 12000 byte(s) leaked in 3 allocation(s).

ASan còn biến lỗi use-after-free và tràn bộ đệm trên heap thành những lần dừng chương trình ngay lập tức với nhãn rõ ràng, thay vì làm hỏng dữ liệu một cách bí ẩn về sau. Hãy bật nó khi chạy kiểm thử và tắt khi build bản phát hành, vì nó tốn thêm bộ nhớ và làm chậm chương trình.

Nếu tính năng phát hiện rò rỉ không chạy trên nền tảng của bạn, hãy đặt ASAN_OPTIONS=detect_leaks=1 trong biến môi trường trước khi chạy.

Sửa một chương trình bị rò rỉ từng bước

Đây là một chương trình nhỏ có ba chỗ rò rỉ riêng biệt:

Valgrind báo ba bản ghi "definitely lost" ở ba số dòng khác nhau. Sửa lần lượt từng chỗ:

Bản sửa 1 là chú thích về quyền sở hữu được áp dụng thật: shout cấp phát, main giải phóng. Bản sửa 2 bỏ hẳn lần cấp phát bị lặp thay vì giải phóng lần đầu: code đơn giản hơn cũng là code đúng. Bản sửa 3 thêm lệnh free bị thiếu ở chỗ return sớm; khi có nhiều lần cấp phát hơn, nhãn cleanup: duy nhất như đã thấy ở trên sẽ mở rộng tốt hơn so với lặp lại các lệnh free.

Thói quen giúp tránh rò rỉ bộ nhớ

  • Viết lệnh free ngay sau khi viết malloc, rồi mới điền code ở giữa.
  • Cho mỗi hàm cấp phát một hàm giải phóng tương ứng.
  • Ghi rõ quyền sở hữu trong chú thích của mọi hàm trả về hoặc nhận một con trỏ mà nó đã cấp phát.
  • Dùng một khối thoát cleanup: trong các hàm giữ nhiều vùng nhớ cấp phát.
  • Luôn chạy kiểm thử với -fsanitize=address, không chỉ khi thấy có gì đó không ổn.
  • Coi "definitely lost: 0 bytes" là một phần của một lần chạy kiểm thử đạt.

Câu hỏi thường gặp

Rò rỉ bộ nhớ trong C là gì?

Là vùng nhớ bạn đã cấp phát bằng malloc nhưng không còn giải phóng được nữa, vì không còn chỗ nào trong chương trình trỏ tới nó. Khối nhớ đó vẫn bị giữ chỗ suốt vòng đời của tiến trình. Đây không phải crash: chương trình vẫn chạy bình thường, chỉ tốn thêm bộ nhớ sau mỗi vòng cho đến khi cạn hẳn.

Làm sao tìm rò rỉ bộ nhớ trong C?

Chạy chương trình dưới valgrind: valgrind --leak-check=full ./program. Công cụ này báo cáo mọi khối nhớ vẫn còn được cấp phát lúc chương trình kết thúc, kèm stack trace của lệnh malloc đã tạo ra nó. Trên macOS hoặc nơi không có valgrind, hãy biên dịch với -fsanitize=address và bạn sẽ nhận được báo cáo tương tự khi chương trình kết thúc.

Nguyên nhân gây rò rỉ bộ nhớ trong C là gì?

Ba kiểu lỗi chiếm gần như tất cả: ghi đè con trỏ duy nhất tới một khối nhớ (kể cả p = realloc(p, n) khi thất bại), return sớm khỏi một hàm đã cấp phát, và quyền sở hữu không rõ ràng: hai hàm đều nghĩ hàm kia sẽ giải phóng, nên không hàm nào làm.

Rò rỉ bộ nhớ có quan trọng không nếu chương trình đằng nào cũng kết thúc?

Với chương trình chạy một lần rồi thoát, hệ điều hành thu hồi mọi thứ, nên ảnh hưởng thực tế gần như bằng không. Nó quan trọng với mọi thứ chạy lâu dài như máy chủ, vòng lặp game hay daemon, nơi mỗi request rò rỉ một ít và tổng cộng tăng không giới hạn. Dù vậy, hãy luôn giải phóng đầy đủ: một báo cáo rò rỉ đầy nhiễu sẽ che mất những chỗ rò rỉ thật sự quan trọng.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu