Bài 14.1022 phút đọc
Rò rỉ bộ nhớ
Sau bài này bạn sẽ làm được
- Nhận ra bốn nguyên nhân rò rỉ phổ biến nhất
- Dùng valgrind và trình dò rò rỉ để tìm chỗ rò
- Áp dụng mẫu một lối ra với nhãn dọn dẹp
- Đặt quy ước sở hữu rõ ràng cho mọi hàm cấp phát
Rò rỉ bộ nhớ không làm chương trình sập ngay. Nó âm thầm ăn dần bộ nhớ cho tới khi máy chủ của bạn hết bộ nhớ sau ba tuần chạy. Đó là lý do nó khó chịu: triệu chứng xuất hiện rất xa nguyên nhân, cả về mã lẫn thời gian.
#Rò rỉ là gì
Rò rỉ bộ nhớ
Tình trạng một vùng nhớ đã cấp phát nhưng chương trình không còn con trỏ nào trỏ tới nó, nên không bao giờ giải phóng được nữa.
ro-ri.c
void ro_ri(void)
{
int *a = malloc(1000 * sizeof *a);
if (a == NULL) return;
a[0] = 42;
/* Hàm kết thúc. Biến a chết theo ngăn xếp.
4000 byte kia vẫn nằm đó, và không ai còn biết địa chỉ của chúng. */
}
for (int i = 0; i < 1000000; ++i)
ro_ri(); /* rò rỉ 4 gigabyte */terminal
valgrind --leak-check=full ./chuong-trinh
==1234== LEAK SUMMARY: ==1234== definitely lost: 4,000 bytes in 1 blocks ==1234== indirectly lost: 0 bytes in 0 blocks ==1234== possibly lost: 0 bytes in 0 blocks ==1234== still reachable: 1,024 bytes in 1 blocks ==1234== suppressed: 0 bytes in 0 blocks
| Mục | Nghĩa là | Cần sửa |
|---|---|---|
| definitely lost | Không còn con trỏ nào tới khối này | Có, ưu tiên cao nhất |
| indirectly lost | Khối này bị mất vì khối chứa con trỏ tới nó đã mất | Sửa khối cha là hết |
| possibly lost | Chỉ còn con trỏ vào giữa khối, không phải đầu khối | Thường có, cần xem kỹ |
| still reachable | Vẫn còn con trỏ, chỉ là chưa gọi free trước khi thoát | Nên sửa nhưng không gấp |
#Bốn nguyên nhân
Nguyên nhân 1: quên gọi free
char *doc_file(const char *duong_dan)
{
FILE *f = fopen(duong_dan, "r");
if (f == NULL) return NULL;
char *bo_dem = malloc(4096);
if (bo_dem == NULL) {
fclose(f);
return NULL;
}
/* ... đọc ... */
fclose(f);
return bo_dem; /* người gọi phải free, và họ hay quên */
}
doc_file("a.txt"); /* giá trị trả về bị vứt: rò rỉ 4 KB */Nguyên nhân 2: thoát sớm bỏ quên đường dọn dẹp
Mỗi nhánh thoát tự dọn, và có nhánh quên
int lam_viec(void)
{
char *a = malloc(100);
if (a == NULL) return -1;
char *b = malloc(200);
if (b == NULL) {
free(a);
return -1;
}
FILE *f = fopen("du-lieu.txt", "r");
if (f == NULL) {
free(a);
return -1; /* QUÊN free(b) */
}
if (xu_ly(a, b, f) != 0) {
fclose(f);
free(b);
return -1; /* QUÊN free(a) */
}
fclose(f);
free(b);
free(a);
return 0;
}Một lối ra với nhãn dọn dẹp
int lam_viec(void)
{
int ma_loi = -1;
char *a = NULL;
char *b = NULL;
FILE *f = NULL;
a = malloc(100);
if (a == NULL) goto don_dep;
b = malloc(200);
if (b == NULL) goto don_dep;
f = fopen("du-lieu.txt", "r");
if (f == NULL) goto don_dep;
if (xu_ly(a, b, f) != 0) goto don_dep;
ma_loi = 0;
don_dep:
if (f != NULL) fclose(f);
free(b); /* free(NULL) hợp lệ nên không cần kiểm tra */
free(a);
return ma_loi;
}Nguyên nhân 3: ghi đè con trỏ khi chưa giải phóng
char *s = malloc(100);
s = malloc(200); /* 100 byte đầu tiên rò rỉ ngay lập tức */
/* Cùng vấn đề với realloc viết sai, xem Bài 14.8 */
s = realloc(s, 300); /* nếu thất bại, mất luôn con trỏ cũ */
/* Và trong vòng lặp */
for (int i = 0; i < 1000; ++i)
s = doc_dong(f); /* mỗi lượt rò rỉ dòng của lượt trước */Nguyên nhân 4: cấu trúc dữ liệu hủy không hết
huy-thieu.c
typedef struct Nut {
char *ten;
struct Nut *tiep;
} Nut;
/* SAI: chỉ giải phóng danh sách, quên trường ten của từng nút */
void huy_sai(Nut *dau)
{
while (dau != NULL) {
Nut *tiep = dau->tiep;
free(dau); /* rò rỉ mọi chuỗi ten */
dau = tiep;
}
}
/* ĐÚNG */
void huy_dung(Nut *dau)
{
while (dau != NULL) {
Nut *tiep = dau->tiep;
free(dau->ten); /* giải phóng phần bên trong trước */
free(dau);
dau = tiep;
}
}#Phát hiện bằng công cụ
| Công cụ | Cách dùng | Ưu điểm |
|---|---|---|
| Trình dò rò rỉ tích hợp | gcc -fsanitize=address | Nhanh, báo ngay khi chương trình kết thúc |
| Valgrind | valgrind --leak-check=full | Không cần biên dịch lại, báo cáo chi tiết nhất |
| Trình phân tích tĩnh của GCC | gcc -fanalyzer | Tìm được lỗi mà không cần chạy chương trình |
| Theo dõi bộ nhớ tiến trình | ps -o rss hoặc top | Phát hiện rò rỉ chậm trong chương trình chạy dài |
terminal
# Cách nhanh nhất, dùng khi phát triển
gcc -std=c17 -g -fsanitize=address ro-ri.c -o t && ./t
=================================================================
==1234==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 4000 byte(s) in 1 object(s) allocated from:
#0 0x7f2a in malloc
#1 0x109165 in ro_ri ro-ri.c:3
#2 0x1091a2 in main ro-ri.c:14
SUMMARY: AddressSanitizer: 4000 byte(s) leaked in 1 allocation(s).terminal
# Chi tiết hơn, và không cần biên dịch lại
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./t
==5678== 4,000 bytes in 1 blocks are definitely lost in loss record 1 of 1 ==5678== at 0x4848899: malloc (vg_replace_malloc.c:381) ==5678== by 0x109165: ro_ri (ro-ri.c:3) ==5678== by 0x1091A2: main (ro-ri.c:14)
terminal
# Tìm lỗi mà không cần chạy
gcc -fanalyzer -std=c17 ro-ri.c -o t
ro-ri.c:10:1: warning: leak of 'a' [CWE-401] [-Wanalyzer-malloc-leak]
10 | }
| ^
'ro_ri': events 1-3
| 3 | int *a = malloc(1000 * sizeof *a);
| | ^~~~~~ (1) allocated here
|10 | }
| | ^ (3) 'a' leaks here; was allocated at (1)Rò rỉ chậm trong chương trình chạy dài
terminal
# Theo dõi cột RSS mỗi mười giây
while true; do ps -o rss= -p $(pgrep may-chu); sleep 10; done
12480 12736 12992 13248 13504 # tăng đều 256 KB mỗi mười giây
#Bốn mẫu phòng tránh
Mẫu 1: cấp và giải phóng trong cùng một hàm
int xu_ly(void)
{
char *bo_dem = malloc(4096);
if (bo_dem == NULL) return -1;
int kq = lam_viec(bo_dem, 4096);
free(bo_dem); /* cùng hàm cấp thì cùng hàm giải phóng */
return kq;
}Đây là mẫu an toàn nhất và nên là lựa chọn mặc định. Khi cấp và giải phóng nằm trong tầm mắt của nhau, gần như không thể quên.
Mẫu 2: nhãn dọn dẹp cho hàm nhiều lối thoát
nhan-don-dep.c
int lam_viec(const char *duong_dan)
{
int ma_loi = -1;
char *bo_dem = NULL;
int *bang = NULL;
FILE *f = NULL;
bo_dem = malloc(4096);
if (bo_dem == NULL) goto don_dep;
bang = calloc(256, sizeof *bang);
if (bang == NULL) goto don_dep;
f = fopen(duong_dan, "r");
if (f == NULL) goto don_dep;
if (doc(f, bo_dem, 4096) < 0) goto don_dep;
if (tinh(bo_dem, bang) < 0) goto don_dep;
ma_loi = 0;
don_dep:
if (f != NULL) fclose(f);
free(bang);
free(bo_dem);
return ma_loi;
}Mẫu 3: cặp hàm tạo và hủy
cap-ham.c
typedef struct {
char *ten;
int *diem;
size_t n;
} SinhVien;
/* Tạo: hoặc thành công hoàn toàn, hoặc thất bại và không để lại gì. */
SinhVien *sv_tao(const char *ten, size_t n)
{
SinhVien *sv = calloc(1, sizeof *sv);
if (sv == NULL) return NULL;
sv->ten = malloc(strlen(ten) + 1);
if (sv->ten == NULL) { sv_huy(sv); return NULL; }
strcpy(sv->ten, ten);
sv->diem = calloc(n, sizeof *sv->diem);
if (sv->diem == NULL) { sv_huy(sv); return NULL; }
sv->n = n;
return sv;
}
/* Hủy: gọi được với NULL, và gọi được với đối tượng tạo dở. */
void sv_huy(SinhVien *sv)
{
if (sv == NULL) return;
free(sv->ten);
free(sv->diem);
free(sv);
}Mẫu 4: quy ước sở hữu ghi rõ trong tài liệu
/* Trả về chuỗi mới. Người gọi PHẢI gọi free.
Trả về NULL nếu không đủ bộ nhớ. */
char *tao_ban_sao(const char *s);
/* Trả về con trỏ vào chính chuỗi đầu vào. KHÔNG được free.
Chỉ hợp lệ khi chuỗi đầu vào còn sống. */
const char *tim_duoi(const char *ten_file);
/* Ghi vào bộ đệm do người gọi cấp. Không cấp phát gì. */
size_t dinh_dang(char *ra, size_t co, int n);#Khi nào rò rỉ thật sự quan trọng
| Loại chương trình | Mức nghiêm trọng | Vì sao |
|---|---|---|
| Máy chủ chạy nhiều tháng | Rất nghiêm trọng | Rò rỉ nhỏ nhân với hàng triệu yêu cầu thành hết bộ nhớ |
| Thư viện người khác dùng | Rất nghiêm trọng | Bạn không kiểm soát được nó bị gọi bao nhiêu lần |
| Chương trình chạy nền dài ngày | Nghiêm trọng | Cùng lý do với máy chủ |
| Hệ nhúng bộ nhớ hạn chế | Nghiêm trọng | Vài kilobyte rò rỉ đã đủ làm thiết bị treo |
| Công cụ dòng lệnh chạy vài giây | Nhẹ | Hệ điều hành thu hồi toàn bộ khi tiến trình kết thúc |
Lý luận sai từ sự thật đúng
int main(void)
{
char *a = malloc(1000000);
/* ... dùng ... */
return 0; /* hệ điều hành sẽ thu hồi, nên tôi không cần free */
}Vẫn nên giải phóng
int main(void)
{
char *a = malloc(1000000);
if (a == NULL) return 1;
/* ... dùng ... */
free(a);
return 0;
}terminal
# Mục tiêu của mọi chương trình bạn viết trong khóa này
valgrind --leak-check=full ./chuong-trinh
==1234== HEAP SUMMARY: ==1234== in use at exit: 0 bytes in 0 blocks ==1234== total heap usage: 42 allocs, 42 frees, 8,192 bytes allocated ==1234== ==1234== All heap blocks were freed -- no leaks are possible
Tự làm thử
- Viết hàm rò rỉ và gọi nó một triệu lần, theo dõi cột RSS trong lúc chạy.
- Chạy chương trình rò rỉ dưới cả ba công cụ trong bài, so sánh ba báo cáo.
- Viết hàm có bốn lối thoát tự dọn dẹp riêng, cố tình quên một chỗ, rồi viết lại bằng mẫu nhãn dọn dẹp.
- Cài cặp hàm tạo và hủy cho một struct có ba trường cấp phát, ép hàm tạo thất bại ở trường thứ hai và xác nhận không rò rỉ.
- Viết hàm hủy danh sách liên kết đúng cách, thử cả hai lỗi kinh điển trong bài rồi sửa.
- Cho một chương trình chạy dài, đo RSS sau hai lần cùng khối lượng công việc để xác định có rò rỉ hay không.
- Đưa một chương trình cũ của bạn về trạng thái không rò rỉ, xác nhận bằng valgrind.
Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.
Tóm tắt
- Rò rỉ là mất con trỏ tới vùng chưa giải phóng. Nó khác với việc chưa gọi free trước khi thoát.
- Bốn nguyên nhân: quên free, thoát sớm bỏ quên đường dọn, ghi đè con trỏ, và hàm hủy không đi hết cấu trúc.
- Mẫu nhãn dọn dẹp với
gotolà cách chuẩn của C cho hàm nhiều lối thoát, và đây là ngoại lệ chính đáng của lời khuyên tránhgoto. - Cặp hàm tạo và hủy nên dùng
callocvà hàm hủy phải chấp nhậnNULL, để đạt bảo đảm tất cả hoặc không gì cả. - Hệ điều hành thu hồi mọi thứ khi tiến trình kết thúc, nhưng vẫn nên giải phóng đầy đủ để báo cáo công cụ sạch và để mã tái dùng được.