Quản Lý Bộ Nhớ Động trong C (Dynamic Memory Management in C)

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ý.

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ư mallocrealloc. 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à:

  1. Sử dụng hàm malloc để cấp phát (allocate) bộ nhớ
  2. Sử dụng bộ nhớ này cho ứng dụng của bạn
  3. 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.

Figure 2-1. Allocating memory for an integer

Đố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”.

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).

Figure 2-2. Extra memory used by heap manager

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.

Figure 2-3. Losing an address

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.

Figure 2-4. Losing address of dynamically allocated memory

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àmMô tả
mallocCấp phát bộ nhớ trong heap
reallocCấ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 đó
callocCấp phát và điền giá trị 0 vào bộ nhớ trong heap
freeTrả lại block bộ nhớ cho heap
Table 2-1. Dynamic memory allocation functions.

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à:

    1. Bộ nhớ được cấp phát từ heap
    2. Bộ nhớ không được modify hoặc clear
    3. Đị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.

    Figure 2-5. Failure to allocate memory

    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 numElementelementSize. 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ố 1Tham số 2Hành vi
    nullNAGiốn như malloc
    Not null0Block ban đầu bị free
    Not nullNhỏ hơn kích thước của block ban đầuMột block nhỏ hơn được cấp phát sử dụng block hiện tại
    Not nullLớn hơn kích thước của block ban đầuMộ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
    Table 2-2. Behavior of realloc function

    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.

    Figure 2-6. realloc example

    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ớ.

    Figure 2-7. Allocating additional memory

    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”.

    Figure 2-10. Double free

    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.

    Figure 2-9. Assigning NULL after using free

    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.

    Figure 2-10. Double free

    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;
    Figure 2-11. Dangling pointer

    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ả p1p2 đề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.

    Figure 2-12. Dangling pointer with aliased pointers

    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.

    Figure 2-13. Block statement problem

    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 mallocfree 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 mallocfree. 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ệ.

    Icons made by Freepik from www.flaticon.com