Phần lớn sức mạnh của con trỏ bắt nguồn từ khả năng theo dõi bộ nhớ được cấp phát động. Khả năng quản lý bộ nhớ thông qua con trỏ tạo ra cơ sở cho nhiều phép toán (operation), bao gồm những phép toán được sử dụng để thao tác với các cấu trúc dữ liệu phức tạp (complex data structure). Để khai thác triệt để những khả năng này, chúng ta cần hiểu quá trình quản lý bộ nhớ động diễn ra trong C.
Một chương trình C chạy trong một hệ thống chạy (runtime system). Thông thường môi trường chạy được cung cấp bởi hệ điều hành (operating system). Runtime system hỗ trợ stack và heap cùng với những hành vi chương trình (program behavior) khác.
Quản lý bộ nhớ là trung tâm của cả chương trình. Đôi khi bộ nhớ được ngầm quản lý bởi runtime system, chẳng hạn như khi bộ nhớ được cấp phát cho các biến tự động (automatic variable). Các biến này được cấp phát vào trong stack frame của hàm. Còn trong trường hợp là biến tĩnh (static variable) và biến toàn cục (global variable), bộ nhớ được đặt trong data segment của ứng dụng, nơi nó được để giá trị 0 (zero). Data segment là một khu vực riêng biệt với khu vực lưu mã thực thi (executable code) và những khu vực lưu dữ liệu (data) khác do runtime system quản lý.
Chú ý: Biến automatic hay biến thông thường được cấp bộ nhớ nằm trong stack frame của hàm chứa biến này. Biến static, biến global được cấp bộ nhớ nằm trong data segment của ứng dụng, giá trị khởi tạo là 0.
Khả năng cấp phát (allocate) và sau đó giải phóng (deallocate) bộ nhớ cho phép ứng dụng quản lý bộ nhớ hiệu quả hơn và linh hoạt hơn. Thay vì phải cấp phát bộ nhớ để phù hợp với kích thước lớn nhất có thể cần cho cấu trúc dữ liệu, thì ta chỉ cần cấp phát chính xác một lượng cần thiết.
Ví dụ: mảng (array) thường có kích thước cố định ở các phiên bản C trước C99. Nếu chúng ta cần lưu một số lượng phần tử thay đổi, chẳng hạn như bảng điểm danh nhân viên, thì cần khai báo một mảng đủ lớn để chứa số lượng nhân viên tối đa mà chúng ta tin rằng sẽ cần. Nếu dự đoán kích thước không đủ, chúng ta buộc phải biên dịch lại ứng dụng với kích thước lớn hơn hoặc thực hiện các phương pháp khác. Nếu dự đoán kích thước quá lớn, thì sẽ bị lãng phí không gian bộ nhớ. Khả năng cấp phát bộ nhớ động cũng hữu ích khi xử lý các cấu trúc dữ liệu có số phần tử thay đổi, chẳng hạn như danh sách liên kết (linked list) hoặc hàng đợi (queue).
C99 ra mắt Mảng Chiều Dài Thay Đổi (Variable Length Arrays – VLAs). Kích thước (size) của mảng được xác định vào lúc chạy chương trình (runtime), không phải lúc biên dịch (compile time). Tuy nhiên, một khi đã tạo thì nó cũng không được thay đổi kích thước.
Các ngôn ngữ như C có hỗ trợ quản lý bộ nhớ động, trong đó các đối tượng (object) này được cấp phát bộ nhớ ở heap. Việc này được thực hiện thủ công bằng cách sử dụng các hàm để cấp phát và giải phóng bộ nhớ. Quá trình này được gọi là quản lý bộ nhớ động (dynamic memory management).
Ở bài này chúng ta sẽ bắt đầu với một tổng quan về cách cấp phát và giải phóng bộ nhớ. Tiếp theo, chúng ta sẽ trình bày các hàm cấp phát cơ bản như malloc
và realloc
. Thảo luận về hàm free
cùng với việc sử dụng NULL
và các vấn đề khác như double free.
Con trỏ lơ lửng (dangling pointer) cũng là một vấn đề thường gặp. Chúng ta sẽ trình bày những ví dụ thường xảy ra hiện tượng dangling pointer và kỹ thuật để xử lý sự cố này. Phần cuối cùng sẽ trình bày các kỹ thuật để quản lý bộ nhớ. Sử dụng con trỏ không đúng cách có thể gây ra nhiều hành vi không thể đoán trước được. Đồng nghĩa với chương trình có thể tạo ra kết quả không hợp lệ (invalid result), dữ liệu bị hỏng (corrupt data) hoặc dừng chương trình (terminate the program).
1. Dynamic Memory Allocation
1.1 Memory Leaks
1.1.1 Losing the address
1.1.2 Hidden memory leaks
2. Dynamic Memory Allocation Functions
2.1 Using the malloc Function
2.1.1 To cast or not to cast
2.1.2 Failing to allocate memory
2.1.3 Not using the right size for the malloc function
2.1.4 Determining the amount of memory allocated
2.1.5 Using malloc with static and global pointers
2.2 Using the calloc Function
2.3 Using the realloc Function
2.4 The alloca Function and Variable Length Arrays
3. Deallocating Memory Using the free Function
3.1 Assigning NULL to a Freed Pointer
3.2 Double Free
3.3 The Heap and System Memory
3.4 Freeing Memory upon Program Termination
4. Dangling Pointers
4.1 Dangling Pointer Examples
4.2 Dealing with Dangling Pointers
4.3 Debug Version Support for Detecting Memory Leaks
5. Dynamic Memory Allocation Technologies
5.1 Garbage Collection in C
5.2 Resource Acquisition Is Initialization
5.3 Using Exception Handlers
6. Summary
1. Dynamic Memory Allocation
Các bước cơ bản để cấp phát bộ nhớ động trong C là:
- Sử dụng hàm
malloc
để cấp phát (allocate) bộ nhớ - Sử dụng bộ nhớ này cho ứng dụng của bạn
- Giải phóng (deallocate) bộ nhớ này bằng hàm free
Đây là kỹ thuật thông dụng nhất, ngoài ra còn những cách khác nữa. Trong ví dụ sau, chúng ta sẽ cấp phát bộ nhớ cho một số nguyên (integer) bằng hàm malloc
. Con trỏ gán 5 cho địa chỉ được cấp phát, và cuối cùng bộ nhớ được giải phóng bằng hàm free
.
int *pi = (int*) malloc(sizeof(int)); *pi = 5; printf("*pi: %d\n", *pi); free(pi);
Khi chạy đoạn code này, nó sẽ in ra 5. Figure 2-1 mô tả cách bộ nhớ được cấp phát ngay trước khi hàm free
thực thi. Theo mục tiêu của bài này, chúng ta sẽ giả sử những đoạn code này nằm trong hàm main
, trừ khi có ghi chú khác.
Đối số (argument) duy nhất của hàm malloc
là số byte cần được cấp phát. Nếu successful, nó trả về một con trỏ tới bộ nhớ được cấp phát trong heap. Nếu fail, nó trả về một con trỏ rỗng (null pointer). Việc kiểm tra tính hợp lệ của một con trỏ được cấp phát sẽ được thảo luận ở phần “2.1 Using the malloc Function”. Toán tử (operator) sizeof
giúp cho ứng dụng dễ port hơn, nó giúp xác định chính xác số byte cần thiết để cấp phát cho hệ thống.
Trong ví dụ này, chúng ta muốn cấp phát đủ bộ nhớ cho một số nguyên. Nếu giả sử kích thước của nó là 4 byte, chúng ta có thể sử dụng:
int *pi = (int*) malloc(4));
Tuy nhiên, kích thước (size) của một số nguyên có thể khác nhau, tùy thuộc vào model bộ nhớ được sử dụng. Để có thể port sang những hệ thống khác một cách linh hoạt, ta cần sử dụng toán tử sizeof
. Nó sẽ trả về kích thước chính xác bất kể chương trình đang thực thi ở hệ thống nào.
Có một lỗi phổ biến liên quan đến toán tử tham chiếu (dereference operator) như dưới đây:
int *pi; *pi = (int*) malloc(sizeof(int));
Điểm cần chú ý là phía bên trái của phép gán, đây là một tham chiếu con trỏ (dereferencing the pointer). Nó sẽ gán địa chỉ được trả về bởi malloc
vào địa chỉ chứa trong pi
. Nếu đây là lần đầu tiên phép gán được thực hiện cho con trỏ, thì địa chỉ chứa trong con trỏ có thể không hợp lệ. Cách làm đúng như sau:
pi = (int*) malloc(sizeof(int));
Do đó toán tử tham chiếu (dereference operator) không nên sử dụng trong tình huống này.
Hàm free
được sử dụng kết hợp với hàm malloc
để giải phóng bộ nhớ khi không cần tới nó nữa. Vấn đề này sẽ được thảo luận chi tiết sau.
Mỗi lần hàm malloc (hoặc một hàm tương tự) được gọi, một lệnh gọi hàm free tương ứng phải được thực hiện sau khi ứng dụng làm xong việc với bộ nhớ này, để tránh tình trạng rò rỉ bộ nhớ (memory leak).
Khi bộ nhớ đã được giải phóng, nó không nên được truy cập lại. Thông thường, bạn sẽ không cố ý truy cập nó sau khi nó đã được giải phóng. Tuy nhiên, điều này có thể vô tình xảy ra, như được minh họa trong phần “4. Dangling Pointers”. Nó sẽ gây ra nhiều hành vi khó lường tùy theo hoàn cảnh. Một phương pháp thông dụng là luôn luôn gán NULL
cho con trỏ sau khi free, được nói ở phần “3.1 Assigning NULL to a Freed Pointer”.
Khi bộ nhớ được cấp phát, thông tin bổ sung được lưu trữ như một phần của cấu trúc dữ liệu. Cấu trúc dữ liệu này được duy trì bởi trình quản lý heap (heap manager). Thông tin này bao gồm size của block cùng những thứ khác, và thường được đặt ngay sau với block được cấp phát. Nếu ứng dụng ghi (write) ra bên ngoài memory block này thì cấu trúc dữ liệu có thể bị hỏng (corrupt). Điều này có thể gây ra hành vi lạ của chương trình hoặc làm hỏng heap.
Hãy xem xét đoạn code sau đây. Bộ nhớ được cấp phát cho một chuỗi (string), cho phép nó chứa tối đa năm ký tự cộng với một byte cho ký tự kết thúc – NUL
. Vòng lặp for
ghi số 0 vào từng vị trí nhưng không dừng lại sau khi ghi sáu byte. Điều kiện dừng của vòng lặp for
là sau khi ghi tám byte. Các số 0 được ghi là số 0 nhị phân, không phải là giá trị ASCII cho ký tự số 0:
char *pc = (char*) malloc(6); for(int i=0; i<8; i++) { *pc[i] = 0; }
Trong Figure 2-2, bộ nhớ bổ sung (extra memory) được cấp phát ở cuối chuỗi sáu byte này. Ở đây cho thấy extra memory được heap manager dùng để theo dõi sự cấp phát bộ nhớ. Nếu chúng ta ghi lố phần cuối chuỗi, extra memory này sẽ bị corrupt. Trong ví dụ này, extra memory được vẽ nối sau chuỗi. Tuy nhiên, vị trí thực tế và giá trị ban đầu của nó thì phụ thuộc vào trình biên dịch (compiler).
1.1 Memory Leaks
Lỗi rò rỉ bộ nhớ (memory leak) xảy ra khi bộ nhớ được cấp phát không bao giờ được sử dụng lại nhưng lại không được free. Điều này xảy ra khi:
- Bị mất địa chỉ bộ nhớ (losing the address)
- Hàm free() không bao giờ được sử dụng, mặc dù cần nó (đôi khi còn gọi là rò rỉ ẩn – hidden leak)
Vấn đề gây ra bởi lỗi memory leak là bộ nhớ không đòi lại (reclaim) được và không sử dụng được nữa. Do đó dung lượng bộ nhớ khả dụng còn lại của heap manager bị giảm. Nếu bộ nhớ cứ liên tục được cấp phát rồi mất, thì chương trình sẽ đến lúc ngưng hoạt động (terminate) khi nó cần thêm bộ nhớ nhưng malloc
không thể allocate do hết bộ nhớ. Trong trường hợp xấu nhất, hệ điều hành có thể bị hư (crash).
Ví dụ dưới đây mô phỏng quá trình gây ra tình trạng memory leak đơn giản:
char *chunk; while (1) { chunk = (char*) malloc(1000000); printf("Allocating\n"); }
Biến chunk
được gán bộ nhớ từ heap. Tuy nhiên, bộ nhớ này không được free
trước khi một block bộ nhớ khác được gán cho nó. Cuối cùng, chương trình sẽ hết bộ nhớ và ngưng hoạt động bất thường. Ở mức tối thiểu thì bộ nhớ không được sử dụng hiệu quả.
1.1.1 Losing the address
Một ví dụ về việc bị mất địa chỉ bộ nhớ được mô tả trong đoạn code sau. Khi biến pi
được gán tiếp một địa chỉ mới. Địa chỉ của lần cấp phát bộ nhớ đầu tiên bị mất khi pi
được cấp phát bộ nhớ lần thứ hai.
int *pi = (int*) malloc(sizeof(int)); *pi = 5; ... pi = (int*) malloc(sizeof(int));
Figure 2-3 mô tả bằng hai hình trước (before) và sau (after), tương đương với trạng thái của chương trình trước và sau khi hàm malloc
thứ hai thực thi. Bộ nhớ tại địa chỉ 500 đã không được giải phóng, và chương trình không còn chỗ nào nắm giữ địa chỉ này.
Một ví dụ khác cấp phát bộ nhớ cho một chuỗi, khởi tạo nó, rồi hiển thị từng ký tự của nó:
char *name = (char*)malloc(strlen("Susan")+1); strcpy(name,"Susan"); while(*name != 0) { printf("%c",*name); name++; }
Tuy nhiên, name
tăng 1 ở mỗi vòng lặp. Tại ký tự cuối cùng, name
trỏ vào NUL
ký tự dừng của chuỗi, xem Figure 2-4. Địa chỉ bắt đầu của bộ nhớ được cấp phát bị mất.
1.1.2 Hidden memory leaks
Memory leak cũng có thể xảy ra khi chương trình cần phải giải phóng bộ nhớ, nhưng lại không làm. Rò rỉ bộ nhớ ẩn (hidden memory leak) xảy ra khi một object vẫn bị giữ trong heap mặc dù không cần dùng nó nữa. Lỗi này thường là hậu quả do lập trình viên bất cẩn. Vấn đề chính của loại rò rỉ này là object đang chiếm bộ nhớ mặc dù không dùng nữa. Bộ nhớ này cần trả về heap. Trong trường hợp xấu nhất, heap manager không cấp phát được bộ nhớ khi được yêu cầu, và có thể buộc chương trình phải kết thúc (terminate). Tốt nhất, chúng ta nên thu lại bộ nhớ sau khi sử dụng xong.
Memory leak còn có thể xảy ra khi free những cấu trúc (structure) được tạo bằng cách sử dụng từ khóa struct
. Nếu trong structure chứa con trỏ tới bộ nhớ cấp phát động, thì những con trỏ này phải được free trước khi structure được free. Xem ví dụ ở bài Pointer và Structure.
2. Dynamic Memory Allocation Functions
Một số hàm cấp phát bộ nhớ là có sẵn để quản lý bộ nhớ động (dynamic memory). Tuy nhiên hàm có sẵn thường bị phụ thuộc vào hệ thống. Những hàm sau được tìm thấy ở hầu hết hệ thống trong header file stdlib.h
malloc
realloc
calloc
free
Các hàm này được tóm tắt trong Table 2-1
Hàm | Mô tả |
malloc | Cấp phát bộ nhớ trong heap |
realloc | Cấp phát bộ nhớ lại bằng một lượng bộ nhớ lớn hoặc nhỏ hơn dựa trên block bộ nhớ được cấp phát trước đó |
calloc | Cấp phát và điền giá trị 0 vào bộ nhớ trong heap |
free | Trả lại block bộ nhớ cho heap |
Bộ nhớ động (dynamic memory) được cấp phát từ heap. Với mỗi lệnh yêu cầu cấp phát thành công, thì không có gì đảm bảo thứ tự của bộ nhớ hoặc tính liên tục của bộ nhớ được cấp phát. Tuy nhiên, bộ nhớ được cấp sẽ được xếp hàng (align) theo kiểu dữ liệu của con trỏ (pointer’s data type). Ví dụ, một số nguyên 4 byte (a four-byte integer) sẽ được cấp phát trên biên địa chỉ chia hết cho 4 (allocate on an address boundary evenly divisible by four). Địa chỉ trả về từ heap manager sẽ chứa địa chỉ của byte thấp nhất.
Trong Figure 2-3, hàm malloc
cấp phát 4 byte tại địa chỉ 500. Ở lần thứ hai, hàm malloc
cấp phát bộ nhớ ở địa chỉ 600. Cả hai đều trên biên địa chỉ 4 byte, và chúng cũng không cấp phát địa chỉ nằm nối tiếp nhau.
2.1 Using the malloc Function
Hàm malloc
cấp phát từng block bộ nhớ từ heap. Số byte được cấp phát dựa trên một đối số (argument) của hàm. Nếu không có sẵn bộ nhớ, hàm trả về NULL
. Hàm này không xóa (clear) hoặc sửa đổi (modify) bộ nhớ, do đó nội dung của bộ nhớ được cấp phát sẽ được xem như là chứa rác. Prototype của hàm như sau:
void* malloc(size_t);
Hàm malloc
sử dụng một argument có kiểu size_t
. Bạn cần thận trọng khi truyền biến cho hàm này, vấn đề có thể xảy ra nếu truyền vào số âm (negative number). Trên một vài hệ thống, giá trị NULL
được trả về nếu đối số là âm.
Khi sử dụng malloc
với đối số 0 (zero), hành vi của nó sẽ tùy theo cách được implement. Nó có thể trả về một con trỏ tới NULL
(a pointer to NULL
) hoặc có thể trả về một con trỏ tới một vùng có 0 byte được cấp phát và có thể truyền vào hàm free
mà không bị lỗi. Nếu sử dụng malloc
với đối số NULL
, thì nó sẽ đưa ra một warning và thực thi trả về 0 byte.
Thông thường, hàm malloc
được xài như sau:
int *pi = (int*) malloc(sizeof(int));
Các bước xử lý khi chạy một hàm malloc
là:
- Bộ nhớ được cấp phát từ heap
- Bộ nhớ không được modify hoặc clear
- Địa chỉ của byte đầu tiên của bộ nhớ này được return
Hàm malloc
trả về giá trị NULL
nếu không thể cấp phát bộ nhớ. Do đó, chúng ta nên có một thói quen tốt là kiểm tra giá trị NULL
của con trỏ trước khi sử dụng nó, như dưới đây:
int *pi = (int*) malloc(sizeof(int)); if(pi != NULL) { // Pointer should be good } else { // Bad pointer }
2.1.1 To cast or not to cast
Trước khi con trỏ void (pointer to void) được giới thiệu ở C, ép kiểu tường minh (explicit cast) đã được yêu cầu dùng chung với malloc
để không bị cảnh báo (warning) khi phép gán được thực hiện giữa các kiểu con trỏ không phù hợp. Do con trỏ void có thể được gán cho bất kỳ kiểu con trỏ nào, explicit cast không còn cần thiết nữa. Nhiều developer xem việc explicit cast là một thói quen tốt, bởi vì:
- Giúp ghi chú mục đích của hàm
malloc
- Giúp làm code phù hợp với C++ (hoặc giúp C compiler dễ hiểu hơn), vì nó yêu cầu phải ép kiểu tường minh
Sử dụng ép kiểu (cast) có thể gặp vấn đề nếu bạn include headler file cho malloc
sai. Trình biên dịch (compiler) sẽ đưa ra cảnh báo. Mặc định, C giả sử các hàm trả về số nguyên (integer). Nếu bạn include sai một prototype cho malloc
, nó sẽ complain rằng bạn đang cố assign một integer cho một con trỏ.
2.1.2 Failing to allocate memory
Nếu khai báo (declare) một con trỏ nhưng không cấp phát được bộ nhớ cho địa chỉ mà nó trỏ tới trước khi sử dụng nó, thì bộ nhớ này thường sẽ chứa giá trị rác, kết quả là tham chiếu tới bộ nhớ bị không hợp lệ (invalid memory reference). Xem xét đoạn code sau:
int *pi; ... printf("%d\n",*pi);
Sự cấp phát được vẽ trong Figure 2-5. Vấn đề này sẽ được đề cập chi tiết ở một bài sau.
Khi thực thi, nó có thể gây ra biệt lệ lúc chạy (runtime exception). Loại lỗi này thường xảy ra với string, như ví dụ sau:
char *name; printf("Enter a name: "); scanf("%s",name);
Trông có vẻ code sẽ chạy đúng. Chúng ta đang muốn sử dụng bộ nhớ được tham chiếu bởi name
. Tuy nhiên, bộ nhớ này chưa được cấp phát. Ví dụ này cũng có thể được mô tả bằng cách thay biến pi
trong Figure 2-5 thành name
.
2.1.3 Not using the right size for the malloc function
Hàm malloc
cấp phát bộ nhớ dựa trên số byte được truyền vào đối số của nó. Khi sử dụng hàm cần cẩn thận cấp phát chính xác số byte. Ví dụ, nếu muốn cấp bộ nhớ cho 10 double, thì bạn cần cấp phát 80 byte. Được thực hiện như sau:
double *pd = (double*)malloc(NUMBER_OF_DOUBLES * sizeof(double));
Sử dụng toán tử sizeof mỗi khi chỉ định số byte để cấp phát cho loại kiểu dữ liệu cần thiết.
Ở ví dụ tiếp theo là một đoạn code để cấp phát bộ nhớ cho 10 double (chú ý, cách làm này SAI):
const int NUMBER_OF_DOUBLES = 10; double *pd = (double*)malloc(NUMBER_OF_DOUBLES);
Đoạn code này sai vì chỉ cấp phát 10 byte thôi!
2.1.4 Determining the amount of memory allocated
Không có cách chuẩn nào để xác định tổng dung lượng bộ nhớ được cấp phát bởi heap. Tuy nhiên, một số compiler cung cấp phần mở rộng (extensions) cho việc này. Ngoài ra, cũng không có cách chuẩn nào để xác định size của một memory block do heap manager cấp phát.
Ví dụ, nếu chúng ta cấp phát 64 byte cho một string, thì heap manager sẽ cấp phát bộ nhớ bổ sung để quản lý block này. Tổng kích thước được cấp phát thật sự bao gồm kích thước được yêu cầu và một lượng được heap manager sử dụng. Điều này đã được minh họa trong Figure 2-2.
Kích thước tối đa được cấp phát bằng malloc
thì phụ thuộc vào hệ thống. Có vẻ như kích thước này sẽ bị giới hạn bởi size_t
. Tuy nhiên, sự hạn chế có thể bị ép buộc bởi lượng bộ nhớ vật lý hiện có và các hạn chế khác của hệ điều hành.
Khi malloc
thực thi, nó có nhiệm vụ cấp phát lượng bộ nhớ được yêu cầu rồi trả về địa chỉ bộ nhớ. Điều gì xảy ra nếu hệ điều hành bên dưới sử dụng “khởi tạo lười biếng” (lazy initialization) trong đó nó không thực sự cấp phát bộ nhớ cho đến khi được truy cập? Một vấn đề có thể phát sinh vào thời điểm này nếu không có đủ bộ nhớ để cấp phát. Câu trả lời phụ thuộc vào thời gian chạy và hệ điều hành. Một nhà phát triển bình thường không cần phải giải quyết câu hỏi này vì các sơ đồ khởi tạo như vậy khá hiếm.
2.1.5 Using malloc with static and global pointers
Không thể gọi hàm khi đang khởi tạo (initialize) biến static hoặc biến global. Như trong đoạn code sau, chúng ta khai báo (declare) một biến static đồng thời khởi tạo nó:
static int *pi = malloc(sizeof(int));
Dòng lệnh này gây ra tin nhắn báo lỗi khi compile. Lỗi tương tự cũng xảy ra với biến global, nhưng đối với biến static thì có thể khắc phục bằng cách tách riêng thành dòng lệnh cấp phát bộ nhớ cho biến như bên dưới:
static int *pi; pi = malloc(sizeof(int));
Chúng ta không thể tách lệnh gán đối với trường hợp biến global vì biến global được declare bên ngoài hàm và executable code, còn lệnh gán cần phải ở bên trong hàm.
Từ quan điểm compiler, có một sự khác biệt giữa toán tử khởi tạo ‘=’ (assignment operator), và toán tử gán ‘=’ (assignment operator)
2.2 Using the calloc Function
Hàm calloc
cấp phát (allocate) và xóa (clear) bộ nhớ cùng lúc. Prototype của nó là:
void *calloc(size_t numElements, size_t elementSize);
Clear bộ nhớ là set tất cả bit về 0 (zero).
Hàm này sẽ cấp phát (allocate) bộ nhớ bằng tích của numElement
và elementSize
. Một con trỏ được trả về là địa chỉ của byte đầu tiên của bộ nhớ này. Nếu hàm cấp phát không thành công thì NULL
được trả về. Nguồn gốc ban đầu, hàm này được tạo ra để hỗ trợ cấp phát bộ nhớ cho mảng (array).
Xét ví dụ sau, ở đây pi
được cấp phát 20 byte, tất cả đều chứa giá trị zero.
int *pi = calloc(5,sizeof(int));
Thay vì sử dụng calloc
, thì hàm malloc
cùng với hàm memset
có thể xài chung để đạt kết quả tương tự, như dưới đây:
int *pi = malloc(5 * sizeof(int)); memset(pi, 0, 5* sizeof(int));
Hàm memset
sẽ thay đổi giá trị trong một buffer. Đối số đầu tiên là con trỏ tới buffer cần điều chỉnh nội dung. Đối số thứ hai là giá trị để thay đổi nội dung của buffer, và đối số cuối cùng là số byte bị thay đổi.
Sử dụng calloc
khi nào cần bộ nhớ mang giá trị 0. Tuy nhiên, calloc
thực thi có thể tốn thời gian hơn malloc
.
Hàm cfree
không còn được sử dụng nữa. Trong những ngày đầu của C, nó từng được dùng để giải phóng bộ nhớ được cấp phát bởi calloc
.
2.3 Using the realloc Function
Thông thường, người ta có nhu cầu tăng hoặc giảm số lượng bộ nhớ được cấp phát cho một con trỏ. Điều này đặc biệt hữu dụng khi ta cần một mảng kích thước thay đổi (variable size array), sẽ được thảo luận ở bài khác. Hàm realloc
sẽ tái cấp phát (reallocate) bộ nhớ. Prototype của nó như sau:
void *realloc(void *ptr, size_t size);
Hàm realloc
trả về một con trỏ tới một block bộ nhớ. Hàm có 2 đối số. Đầu tiên là con trỏ tới block nguyên gốc và thứ hai là kích thước mới sẽ cấp phát. Kích thước của block tái cấp phát sẽ khác với kích thước của block được tham chiếu (reference) bởi đối số thứ nhất. Giá trị trả về là một con trỏ tới bộ nhớ được tái cấp phát.
Kích thước được yêu cầu có thể nhỏ hơn hoặc lớn hơn lượng cấp phát đang có. Nếu bộ nhớ mới được yêu cầu cấp phát nhỏ hơn bộ nhớ hiện tại, thì phần bộ nhớ dư (excess memory) bị trả lại heap. Không có gì đảm bảo rằng bộ nhớ dư sẽ bị clear. Nếu kích thước lớn hơn hiện tại, nếu được thì, bộ nhớ sẽ được cấp phát thêm ngay sau vị trí cấp phát hiện tại. Ngược lại, bộ nhớ được sẽ được cấp phát ở vị trí mới trong heap và dữ liệu cũ được copy sang vùng mới.
Nếu kích thước là 0 (zero) và con trỏ khác null, thì con trỏ sẽ được free. Nếu space không thể được cấp phát, thì block bộ nhớ ban đầu sẽ giữ lại và không bị đổi. Tuy nhiên, con trỏ trả về là null pointer và errno được set lên ENOMEM
.
Hành vi của hàm được tóm tắt trong Table 2-2.
Tham số 1 | Tham số 2 | Hành vi |
null | NA | Giốn như malloc |
Not null | 0 | Block ban đầu bị free |
Not null | Nhỏ hơn kích thước của block ban đầu | Một block nhỏ hơn được cấp phát sử dụng block hiện tại |
Not null | Lớn hơn kích thước của block ban đầu | Một block lớn hơn được cấp phát từ vị trí hiện tại hoặc từ một vị trí khác trong heap |
Trong ví dụ sau, chúng ta sử dụng hai biến để cấp phát bộ nhớ cho một string. Lúc khởi tạo, chúng ta cấp phát 16 byte, nhưng chỉ xài 13 byte đầu (12 số hexa và một ký tự kết thúc null (0)):
char *string1; char *string2; string1 = (char*) malloc(16); strcpy(string1, "0123456789AB");
Tiếp theo, chúng ta sử dụng hàm realloc
để chỉ định một vùng nhỏ hơn của bộ nhớ. Địa chỉ và nội dung của hai biến này được in ra màn hình:
string2 = realloc(string1, 8); printf("string1 Value: %p [%s]\n", string1, string1); printf("string2 Value: %p [%s]\n", string2, string2);
Output như sau:
string1 Value: 0x500 [0123456789AB] string2 Value: 0x500 [0123456789AB]
Quá trình cấp phát bộ nhớ được mô tả trong Figure 2-6.
Heap manager đã sử dụng lại block cũ, và nội dung không thay đổi. Tuy nhiên, chương trình tiếp tục sử dụng nhiều hơn 8 byte đã request, bởi vì chúng ta đã không thay đổi string để vừa với block 8 byte. Ở ví dụ này, chúng ta phải điều chỉnh chiều dài của string để vừa với 8 byte được tái cấp phát. Cách đơn giản nhất là gán ký tự NUL
cho địa chỉ 507. Sử dụng nhiều hơn bộ nhớ đã cấp phép là một thói quen không tốt và nên tránh.
Ở ví dụ tiếp theo, chúng ta sẽ reallocate tăng thêm bộ nhớ:
string1 = (char*) malloc(16); strcpy(string1, "0123456789AB"); string2 = realloc(string1, 64); printf("string1 Value: %p [%s]\n", string1, string1); printf("string2 Value: %p [%s]\n", string2, string2);
Khi thực thi, có thể bạn sẽ nhận được kết quả như sau:
string1 Value: 0x500 [0123456789AB] string2 Value: 0x600 [0123456789AB]
Trong ví dụ trên, realloc
đã cấp phát một block bộ nhớ mới. Figure 2-7 mô tả vị trí của block bộ nhớ.
2.4 The alloca Function and Variable Length Arrays
Hàm alloca
(malloca của Microsoft) cấp phát bộ nhớ bằng cách đặt nó trong stack frame của hàm. Khi hàm return, thì bộ nhớ tự động free. Hàm này có thể khó implement nếu bên dưới hệ thống chạy nó không dựa trên stack (stack-based). Do đó, đây không phải là hàm tiêu chuẩn và nên tránh sử dụng nếu ứng dụng của bạn cần khả năng port.
Trong C99, Mảng Chiều Dài Thay Đổi (Variable Length Array – VLA) được giới thiệu, cho phép khai báo và tạo một mảng trong một hàm mà kích thước của mảng dựa trên một biến. Ở ví dụ sau, một mảng char
được cấp phát để sử dụng trong một hàm:
void compute(int size) { char* buffer[size]; ... }
Nghĩa là việc cấp phát bộ nhớ được thực hiện lúc chạy chương trình và bộ nhớ được cấp phát là một phần của stack frame. Toán tử sizeof
cũng được sử dụng cùng với mảng, nó sẽ được thực thi lúc chạy chứ không phải lúc biên dịch.
Đồng thời, khi hàm kết thúc, bộ nhớ cũng bị ảnh hưởng, nó sẽ bị giải phóng. Vì chúng ta không sử dụng một hàm kiểu malloc
để tạo bộ nhớ, nên không cần sử dụng hàm free
để giải phóng nó. Cũng không nên cho hàm trả về một con trỏ tới bộ nhớ này.
VLA không thay đổi kích thước. Kích thước của chúng được cố định một khi đã cấp phát. Nếu bạn thực sự cần một mảng có thể thay đổi kích thước, thì hãy sử dụng hàm realloc
, như đã đề cập ở phần “2.3 Using the realloc Function”.
3. Deallocating Memory Using the free Function
Với cấp phát bộ nhớ động, lập trình viên có thể trả lại bộ nhớ khi nó không còn được sử dụng, do đó giải phóng nó cho các mục đích sử dụng khác. Điều này thường được thực hiện bằng cách sử dụng hàm free
, hàm có prototype được như sau:
void free(void *ptr);
Đối số con trỏ phải chứa địa chỉ bộ nhớ được cấp phát bởi hàm loại malloc
. Bộ nhớ này được trả về heap. Mặc dù con trỏ vẫn có thể trỏ đến vùng này, nhưng chúng ta luôn phải cho rằng nó trỏ đến giá trị rác. Vùng này có thể được cấp phát lại sau đó và chứa các dữ liệu khác nhau.
Trong ví dụ đơn giản bên dưới, pi
được cấp phát bộ nhớ và cuối cùng được giải phóng:
int *pi = (int*) malloc(sizeof(int)); ... free(pi);
Figure 2-8 minh họa việc cấp phát bộ nhớ ngay trước và ngay sau khi hàm free
thực thi. Ô nét đứt ở địa chỉ 500 minh họa bộ nhớ đã được giải phóng nhưng nó vẫn có thể chứa giá trị của nó. Biến pi
vẫn chứa địa chỉ 500. Đây được gọi là một con trỏ lơ lửng (dangling pointer) và được đề cập chi tiết ở phần “4. Dangling Pointers”.
Nếu hàm free
được truyền vào một con trỏ null thì thông thường nó sẽ không làm gì cả. Nếu con trỏ được truyền đã được cấp phát bởi một hàm không phải loại malloc
thì hành vi của hàm đó là không xác định. Trong ví dụ sau, pi
được cấp địa chỉ của num
. Tuy nhiên, đây không phải là địa chỉ heap hợp lệ, địa chỉ của biến num
không nằm trong heap:
3.1 Assigning NULL to a Freed Pointer
Con trỏ có thể gây rắc rối thậm chí sau khi đã được giải phóng (free). Nếu cố ý tham chiếu (dereference) một con trỏ đã giải phóng, thì không thể xác định hành vi của nó là gì. Thành ra, nhiều lập trình viên sẽ gán tường minh giá trị NULL
cho con trỏ để nói rằng nó không hợp lệ. Sử dụng một con trỏ như vậy sẽ dẫn đến biệt lệ lúc chạy (runtime exception).
Một ví dụ mô tả cách làm trên:
int *pi = (int*) malloc(sizeof(int)); ... free(pi); pi = NULL;
Quá trình cấp phát được minh họa ở Figure 2-9.
Kỹ thuật này nhằm giải quyết các vấn đề như con trỏ lơ lửng (dangling pointer). Tuy nhiên, tốt hơn là dành thời gian giải quyết các điều kiện gây ra sự cố hơn là bắt chúng một cách thô bạo bằng con trỏ null. Ngoài ra, bạn không thể gán NULL
cho một con trỏ hằng (constant pointer) trừ khi nó đang được khởi tạo.
3.2 Double Free
Thuật ngữ giải phóng gấp đôi (double free) dùng để chỉ việc cố ý giải phóng một block bộ nhớ hai lần. Một ví dụ đơn giản như sau:
int *pi = (int*) malloc(sizeof(int)); *pi = 5; free(pi); ... free(pi);
Việc thực thi hàm free
thứ hai sẽ dẫn đến ngoại lệ khi chạy (runtime exception). Một ví dụ ít rõ ràng hơn liên quan đến việc sử dụng hai con trỏ, cả hai đều trỏ đến cùng một khối bộ nhớ. Như được hiển thị bên dưới, runtime exception tương tự sẽ xảy ra khi chúng ta vô tình giải phóng cùng một bộ nhớ ở lần thứ hai:
p1 = (int*) malloc(sizeof(int)); int *p2 = p1; free(p1); ... free(p2);
Quá trình cấp phát bộ nhớ được minh họa trong Figure 2-10.
Khi hai con trỏ tham chiếu cùng một vị trí, người ta gọi nó là aliasing.
Thật không may, heap manager gặp khó khăn trong việc xác định liệu một block đã được giải phóng hay chưa. Vì vậy, nó không cố gắng phát hiện cùng một bộ nhớ được giải phóng hai lần. Điều này thường dẫn đến heap bị hỏng và chấm dứt chương trình. Ngay cả khi chương trình chưa kết thúc, nó vẫn thể hiện tính logic của vấn đề có vấn đề. Không có lý do gì để giải phóng cùng một bộ nhớ hai lần.
Có ý kiến cho rằng hàm free
nên gán một giá trị NULL
hoặc một số giá trị đặc biệt khác cho đối số của nó khi nó trả về. Tuy nhiên, vì con trỏ được truyền theo giá trị (passed by value) nên hàm free
không thể gán NULL
một cách tường minh cho con trỏ.
3.3 The Heap and System Memory
Heap thường sử dụng các chức năng của hệ điều hành (operating system function) để quản lý bộ nhớ của nó. Kích thước của heap có thể được cố định khi chương trình được tạo hoặc có thể được phép tăng lên. Tuy nhiên, trình quản lý heap (heap manager) không nhất thiết phải trả lại bộ nhớ cho hệ điều hành khi hàm free
được gọi. Bộ nhớ được giải phóng sẽ được cung cấp cho ứng dụng sử dụng tiếp theo. Do đó, khi một chương trình cấp phát và sau đó giải phóng bộ nhớ, việc giải phóng bộ nhớ thường không được phản ánh trong việc sử dụng bộ nhớ của ứng dụng như được thấy từ góc độ hệ điều hành.
3.4 Freeing Memory upon Program Termination
Hệ điều hành chịu trách nhiệm duy trì tài nguyên (resource) của ứng dụng, bao gồm cả bộ nhớ của nó. Khi một ứng dụng kết thúc (terminate), hệ điều hành có trách nhiệm cấp phát lại bộ nhớ này cho các ứng dụng khác. Trạng thái bộ nhớ của ứng dụng đã chấm dứt, bị hỏng (corrupt) hoặc không bị hỏng (uncorrupt), không phải là vấn đề. Trên thực tế, một trong những nguyên nhân khiến ứng dụng có thể ngừng hoạt động là do bộ nhớ của nó bị hỏng. Nếu xảy ra chấm dứt chương trình bất thường (abnormal program termination), quá trình dọn dẹp (cleanup) có thể không thực hiện được. Vì vậy, không có lý do gì để giải phóng bộ nhớ được cấp phát trước khi ứng dụng kết thúc.
Như vậy, có thể có những lý do khác khiến bộ nhớ này cần được giải phóng. Một lập trình viên tận tâm có thể muốn giải phóng bộ nhớ vì vấn đề chất lượng. Giải phóng bộ nhớ sau khi không cần nữa luôn là một thói quen tốt, ngay cả khi ứng dụng đang ngừng hoạt động. Nếu bạn sử dụng một công cụ (tool) để phát hiện rò rỉ bộ nhớ (memory leak) hoặc các vấn đề tương tự thì việc giải phóng bộ nhớ sẽ dọn sạch đầu ra của những tool đó. Trong một số hệ điều hành ít phức tạp hơn, có thể hệ điều hành không tự động đòi lại bộ nhớ, mà chương trình lại có trách nhiệm lấy lại bộ nhớ trước khi kết thúc. Ngoài ra, phiên bản mới hơn của ứng dụng có thể thêm code vào cuối chương trình. Nếu bộ nhớ trước đó chưa được giải phóng, vấn đề có thể phát sinh.
Do đó, đảm bảo rằng tất cả bộ nhớ đều được free trước khi kết thúc chương trình, và những vấn đề kèm theo là:
- Có thể gặp nhiều rắc rối hơn mức đáng có
- Có thể tốn thời gian và phức tạp đối với việc giải phóng các cấu trúc phức tạp (complex structure)
- Có thể thêm kích thước của ứng dụng
- Kết quả là thời gian chạy dài hơn
- Tạo cơ hội cho nhiều lỗi lập trình hơn
Việc bộ nhớ có nên được giải phóng trước khi kết thúc chương trình hay không còn tùy thuộc vào từng ứng dụng.
4. Dangling Pointers
Nếu một con trỏ vẫn tham chiếu đến bộ nhớ gốc sau khi nó được giải phóng (free) thì nó được gọi là con trỏ lơ lửng, con trỏ treo (dangling pointer). Con trỏ lơ lửng không trỏ đến một đối tượng hợp lệ (valid object). Điều này đôi khi được gọi là giải phóng sớm (premature free).
Việc sử dụng con trỏ lơ lửng có thể dẫn đến một số vấn đề khác nhau, như là:
- Hành vi không thể đoán trước nếu bộ nhớ bị truy cập
- Lỗi phân đoạn (segmentation fault) khi không thể truy cập bộ nhớ nữa
- Rủi ro bảo mật tiềm ẩn
Những vấn đề này có thể xảy ra khi:
- Bộ nhớ được truy cập sau khi được giải phóng
- Một con trỏ được trả về một biến tự động (automatic variable) trong lệnh gọi hàm trước đó.
4.1 Dangling Pointer Examples
Dưới đây là một ví dụ đơn giản về cấp phát bộ nhớ động cho một số nguyên (integer) bằng hàm malloc
. Sau đó giải phóng bộ nhớ bằng hàm free
:
int *pi = (int*) malloc(sizeof(int)); *pi = 5; printf("*pi: %d\n", *pi); free(pi);
Biến pi
sẽ vẫn giữ địa chỉ của số nguyên này. Tuy nhiên, bộ nhớ này sẽ bị heap manager sử dụng sau đó và có thể lưu giữ dữ liệu bị thay đổi khác. Figure 2-11 mô tả trạng thái của chương trình ngay trước và sau khi hàm free
thực thi. Giả sử rằng pi
nằm trong hàm main và có địa chỉ là 100. Bộ nhớ được cấp phát bằng hàm malloc
thì ở địa chỉ 500.
Khi hàm free
được thực thi, bộ nhớ ở địa chỉ 500 bị giải phóng và không nên sử dụng. Tuy nhiên, hầu hết hệ thống khi chạy sẽ không ngăn cản sự truy cập và điều chỉnh sau đó. Chúng ta vẫn có thể ghi lên vị trí này nếu muốn, như dưới đây. Kết quả của hành động này thì không đoán được.
free(pi); *pi = 10;
Một ví dụ nguy hiểm hơn xảy ra khi có nhiều hơn một con trỏ tham chiếu đến cùng một vùng bộ nhớ và một trong số chúng bị free. Như được hiển thị bên dưới, cả p1
và p2
đều đề cập đến cùng một vùng bộ nhớ, được gọi là pointer aliasing. Tuy nhiên, p1
bị free:
int *p1 = (int*) malloc(sizeof(int)); *p1 = 5; ... int *p2; p2 = p1; ... free(p1); ... *p2 = 10; // Dangling pointer
Figure 2-12 minh họa sự cấp phát bộ nhớ, ô nét chấm là bộ nhớ đã free.
Một vấn đề có thể xảy ra khi sử dụng khối câu lệnh (block statement), như dưới đây. Ở đây pi
được gán địa chỉ của tmp
. Biến pi
có thể là biến toàn cục (global variable) hoặc biến cục bộ (local variable). Tuy nhiên, khi khối lệnh chứa tmp
bị lấy ra khỏi (pop off) program stack, địa chỉ này sẽ không còn hợp lệ:
int *pi; ... { int tmp = 5; pi = &tmp; } // pi is now a dangling pointer foo();
Hầu hết trình biên dịch (compiler) sẽ coi block statement như là một stack frame. Biến tmp
được cấp phát trên stack frame rồi sau đó bị pop off khỏi stack khi block statement đã thoát. Con trỏ pi
bây giờ bị trỏ vào một vùng bộ nhớ mà cuối cùng sẽ bị ghi đè (overridden) bởi một hành động khác, giống như hàm foo
. Điều kiện này được minh họa ở Figure 2-13.
4.2 Dealing with Dangling Pointers
Debug con trỏ và những rắc rối liên quan có thể tốn nhiều thời gian để giải quyết. Có vài cách tiếp cận để đương đầu với dangling pointer như là:
- Set giá trị cho con trỏ là
NULL
sau khi free. Ở lần sử dụng kế của nó sẽ khiến chương trình bị ngưng (terminate). Tuy nhiên, rắc rối vẫn có thể tiếp diễn nếu nhiều bản sao của con trỏ tồn tại. Điều này là do phép gán chỉ ảnh hưởng tới một trong những bản sao đó, như đã mô tả ở phần “3.2 Double Free”. - Viết thêm hàm đặc biệt để thay thế hàm free.
- Trên vài hệ thống (runtime/debugger) sẽ ghi đè dữ liệu nếu được free (ví dụ 0xDEADBEEF – Visual Studio sẽ sử dụng 0xCC, 0xCD, hoặc 0xDD, tùy vào cái gì được free). Trong khi không xuất hiện exception nào, lập trình viên nhìn thấy bộ nhớ chứa những giá trị này ở nơi chúng không nên có, thì họ sẽ biết rằng chương trình đang truy cập vào bộ nhớ đã free.
- Sử dụng phần mềm bên thứ ba (third-party tool) để kiểm tra dangling pointer và những rắc rối khác.
Hiển thị giá trị con trỏ thường có ích cho việc debug dangling pointer. Nhưng bạn cần cẩn thật cách chúng được hiển thị. Đảm bảo bạn hiển thị chúng một cách nhất quán để tránh bối rối khi so sánh các giá trị của con trỏ. Macro assert cũng có thể hữu dụng.
4.3 Debug Version Support for Detecting Memory Leaks
Microsoft đưa ra những kỹ thuật để giải quyết vấn đề ghi đè của bộ nhớ được cấp phát động (dynamically allocated memory) và rò rỉ bộ nhớ (memory leak). Cách làm này sử dụng kỹ thuật quản lý bộ nhớ trong debug version của một chương trình để:
- Kiểm tra tính toàn vẹn của heap
- Kiểm tra rò rỉ bộ nhớ
- Mô phỏng tình huống bộ nhớ heap thấp (low heap memory situation)
Microsoft làm việc này sử dụng một cấu trúc dữ liệu đặc biệt để quản lý sự cấp phát bộ nhớ. Cấu trúc này duy trì thông tin debug, như là tên file, số dòng nơi gọi hàm malloc
. Ngoài ra, buffer được cấp phát trước và sau khi cấp phát bộ nhớ để kiểm tra việc ghi đè của bộ nhớ thật. Để có thêm thông tin về kỹ thuật này, bạn có thể tìm thấy tại Microsoft Developer Network.
Thư viện Mudflap cũng cung cấp một khả năng tương tự cho GCC compiler. Runtime library của nó hỗ trợ kiểm tra memory leak, cùng với những thứ khác. Cách kiểm tra này đạt được nhờ trang bị phép toán tham chiếu con trỏ (pointer dereferencing operation).
5. Dynamic Memory Allocation Technologies
Tới đây, chúng ta đã nói về cấp phát và giải phóng bộ nhớ của heap manager. Tuy nhiên, cách implement của kỹ thuật này khác nhau ở mỗi loại compiler. Đa số heap manager sử dụng một heap hoặc đoạn dữ liệu (data segment) để làm bộ nhớ nguồn (source for memory). Tuy nhiên, cách làm này có thể bị phân mảnh (fragmentation) và có thể xung đột với program stack. Tuy nhiên, đó là cách phổ biến nhất để triển khai (implement) vùng heap.
Heap manager cần phải giải quyết nhiều vấn đề, chẳng hạn như liệu các heap có được phân bổ trên cơ sở mỗi tiến trình (process) và/hoặc mỗi luồng (thread) hay không và cách bảo vệ heap khỏi các vi phạm bảo mật như thế nào.
Có một số loại heap manager, bao gồm malloc của OpenBSD, malloc của Hoard và TCMalloc do Google phát triển. Bộ cấp phát thư viện GNU C dựa trên bộ cấp phát đa năng (general-purpose allocator) dlmalloc. Nó cung cấp các phương tiện để gỡ lỗi và có thể trợ giúp trong việc theo dõi rò rỉ bộ nhớ (memory leak). Tính năng ghi log của dlmalloc theo dõi việc sử dụng bộ nhớ (memory usage) và truyền nhận bộ nhớ (memory transaction), cùng với các hoạt động khác.
5.1 Garbage Collection in C
Hàm malloc
và free
cho ta một cách thủ công để cấp phát và giải phóng bộ nhớ. Tuy nhiên, có vô số vấn đề kèm theo khi sử dụng quản lý bộ nhớ bằng thủ công trong C, như là hiệu suất, đạt được vị trí tham chiếu tốt, các vấn đề về thread và dọn dẹp bộ nhớ một cách duyên dáng.
Vài kỹ thuật không chuẩn khác được sử dụng để giải quyết một số vấn đề này, và phần này sẽ khám phá chúng. Một tính năng chính của các kỹ thuật này là tự động giải phóng bộ nhớ (automatic deallocation of memory). Khi bộ nhớ không còn cần thiết nữa, nó sẽ được thu thập và cung cấp để sử dụng sau này trong chương trình. Bộ nhớ bị giải phóng được coi là rác.
Do đó, thuật ngữ thu gom rác (garbage collection) biểu thị việc xử lý bộ nhớ này. Thu gom rác rất hữu ích vì một số lý do như:
- Giải thoát lập trình viên khỏi phải quyết định khi nào cần giải phóng bộ nhớ
- Cho phép lập trình viên tập trung với những rắc rối khác của ứng dụng
Một giải pháp thay thế cho việc quản lý bộ nhớ thủ công là Boehm-Weiser Collector. Tuy nhiên, đây không phải là một phần của ngôn ngữ.
5.2 Resource Acquisition Is Initialization
Khởi tạo thu thập tài nguyên (Resource Acquisition Is Initialization – RAII) là một kỹ thuật được phát minh bởi Bjarne Stroustrup. Nó giải quyết việc cấp phát và giải phóng tài nguyên trong C++. Kỹ thuật này rất hữu ích để đảm bảo việc cấp phát và sau đó giải phóng tài nguyên, khi có các ngoại lệ (exception) hiện diện. Các tài nguyên được cấp phát cuối cùng sẽ được giải phóng.
Đã có một số cách tiếp cận để sử dụng RAII trong C. Trình biên dịch GNU cung cấp một phần mở rộng không chuẩn (nonstandard extension) để hỗ trợ việc này. Chúng ta sẽ minh họa phần mở rộng này bằng cách chỉ ra cách cấp phát và giải phóng bộ nhớ trong một hàm. Khi các biến nằm ngoài phạm vi (out of scope), quá trình giải phóng sẽ tự động diễn ra.
GNU extension sử dụng macro có tên RAII_VARIABLE
. Nó khai báo một biến và liên kết với biến đó:
- Một kiểu (type)
- Một hàm thực thi khi biến này được tạo
- Một hàm thực thi khi biến nằm ngoài phạm vi (out of scope)
Định nghĩa của macro như sau:
#define RAII_VARIABLE(vartype,varname,initval,dtor) \ void _dtor_ ## varname (vartype * v) { dtor(*v); } \ vartype varname __attribute__((cleanup(_dtor_ ## varname))) = (initval)
Trong ví dụ sau, chúng ta khai báo (declare) một biến gọi là name
là một con trỏ char
. Khi nó được tạo, hàm malloc
được thực thi, cấp phát 32 byte cho nó. Khi hàm kết thúc (terminiate), name
nằm ngoài phạm vi của hàm và hàm free
được thực thi:
void raiiExample() { RAII_VARIABLE(char*, name, (char*)malloc(32), free); strcpy(name,"RAII Example"); printf("%s\n",name); }
Khi hàm này được thực thi, thì string “RAII_Example” sẽ được hiển thị ra màn hình.
Kết quả tương tự có thể đạt được mà không sử dụng GNU extension. Xem thêm.
5.3 Using Exception Handlers
Một cách tiếp cận khác để xử lý việc giải phóng bộ nhớ (deallocation of memory) là sử dụng xử lý ngoại lệ (exception handling). Tuy xử lý ngoại lệ không phải là một phần chuẩn của C, nhưng nó có thể hữu dụng nếu có sẵn và không cần quan tâm đến vấn đề có khả năng port. Dưới đây mô tả cách làm bằng phiên bản Microsoft Visual Studio của ngôn ngữ C.
void exceptionExample() { int *pi = NULL; __try { pi = (int*)malloc(sizeof(int)); *pi = 5; printf("%d\n",*pi); } __finally { free(pi); } }
Ở đây khối try đóng gói những lệnh có thể gây ra một ngoại lệ (exception) để bị quăng ra (to be thrown) lúc chạy chương trình. Khối finally sẽ được thực thi bất kể có ngoại lệ bị quăng ra hay không. Hàm free
do đó đảm bảo luôn được thực thi.
Bạn có thể tạo ra execption handling trong C bằng nhiều cách khác nhau.
6. Summary
Cấp phát bộ nhớ động là một tính năng quan trọng của ngôn ngữ C. Trong bài này, chúng ta tập trung vào việc cấp phát bộ nhớ thủ công bằng cách sử dụng các hàm malloc
và free
. Chúng tôi đã giải quyết một số vấn đề phổ biến liên quan đến các hàm này, bao gồm cả lỗi cấp phát bộ nhớ và con trỏ treo.
Có các kỹ thuật không chuẩn khác để quản lý bộ nhớ động trong C. Chúng ta đã đề cập đến một số kỹ thuật thu thập rác này, bao gồm RAII và xử lý ngoại lệ.