Cấp phát bộ nhớ

Nhiều ứng dụng Linux khi lập trình đòi hỏi cấp phát bộ nhớ với kích thước động cho các cấu trúc dữ liệu như link list hoặc binary tree. Cấp phát động nghĩa là kích thước bộ nhớ yêu cầu chỉ được biết tại thời điểm chương trình chạy.

Trước khi bắt đầu với cấp phát bộ nhớ, chúng ta hãy cùng ôn lại 1 chút về cấu trúc bộ nhớ của 1 tiến trình trong bài học “Khái quát về tiến trình”. Ngoài các phân vùng dữ liệu và text nạp vào không gian bộ nhớ, tiến trình có phân vùng heap để lưu các biến cấp phát động và stack để lưu các biến cấp phát tĩnh. Kích thước hai phân vùn này có thể tăng, giảm khi tiến trình được cấp phát hoặc giải phóng bộ nhớ trong quá trình chạy. Địa chỉ hiện tại của phân vùng heap được đánh dấu bởi giá trị “Program break” và phân vùng stack được đánh dấu bởi “Top of stack”. Trong bài học này, chúng ta sẽ tìm hiểu các hàm được dùng để cấp phát bộ nhớ trên cả heap và stack của tiến trình.

Cấp phát bộ nhớ trên Heap

Để cấp phát bộ nhớ trên heap, cách đơn giản và phổ biến nhất là sử dụng các hàm họ malloc. Tuy nhiên, trước khi đi vào cách hoạt động và sử dụng các hàm đó, chúng ta sẽ bắt đầu với brk() và sbrk(), là hai hàm cốt lõi malloc dựa trên.

  • Thay đổi program break: brk() và sbrk()

Hai system call brk() và sbrk() thay đổi kích thước phân vùng heap của tiến trình bằng cách tăng hoặc giảm giá trị của program break. Prototye của 2 hàm này như sau:

#include <unistd.h>

int brk(void *addr);
/*Trả về 0 nếu thành công, hoặc –1 nếu lỗi*/

void *sbrk(intptr_t increment);
/*Trả về program break trước nếu thành công, hoặc –1 nếu lỗi*/

System call brk() thiết lập program break đến vị trí “addr” nằm trong dải bộ nhớ của heap. Vì program break có giá trị khởi đầu là &end (ngay sau dải của uninitialize data-bss), nếu thiết lập program break dưới &end sẽ gây ra lỗi unexpected behaivor (thường là segmentation fault).

Hàm sbrk() là hàm thư viện được triển khai dựa trên system call brk(), hàm này thay đổi program break bằng cách cộng thêm giá trị “increment” vào program break hiện tại. Nếu thành công, sbrk() trả về giá trị program break cũ. Hay nói cách khác, sau khi tăng program break, hàm sbrk() sẽ trả về địa chỉ bắt đầu của vùng bộ nhớ vừa được cấp phát. Vì vậy, chúng ta có thể gọi sbrk(0) để trả về giá trị hiện tại của program break mà ko thay đổi nó (trong trường hợp muốn theo dõi kích thước của phân vùng heap).

Sau khi program break được tăng, tiến trình có thể truy cập vào địa chỉ nằm trong vùng mới được cấp phát đó. Tuy nhiên, phân vùng này chỉ là trên không gian địa chỉ ảo của tiến trình, địa chỉ vật lý của vùng này vẫn chưa được cấp phát. Kernel sẽ tự động cấp phát địa chỉ vật lý cho phân vùng này khi tiến trình truy cập lần đầu tiên vào chúng.

  • Cấp phát bộ nhớ trên Heap: malloc() và free()

Trong lập trình Linux, 2 hàm brk() và sbrk() rất ít khi được sử dụng. Thay vào đó, lập trình viên thường dùng các hàm của họ malloc để cấp phát và giải phóng bộ nhớ. Lý do là các hàm này đã được tiêu chuẩn hóa như là 1 phần của ngôn ngữ lập trình C, sử dụng các hàm này cũng đơn giản và tiện ích hơn.

Hàm điển hình trong họ malloc là malloc(), có prototype như sau:

#include <stdlib.h>

void *malloc(size_t size);
/*Trả về con trỏ địa chỉ của vùng vừa được cấp phát, hoặc NULL nếu lỗi*/

Vì hàm malloc() trả về địa chỉ có kiểu dữ liệu void*, chúng ta có thể gán nó cho bất kỳ kiểu dữ liệu nào trong C. Trong trường hợp malloc() không thể cấp phát bộ nhớ (ví dụ kích thước bộ nhớ yêu cầu quá lớn làm program break vượt qua ngưỡng cho phép), malloc() sẽ trả về NULL và ghi mã lỗi vào biến errno. Mặc dù xác suất cấp phát lỗi không lớn, lập trình viên vẫn nên kiểm tra giá trị trả về khi dùng các hàm họ malloc.

Hàm free() dùng để giải phóng bộ nhớ đã được cấp phát bởi các hàm malloc có prototype như sau:

#include <stdlib.h>

void free(void *ptr);

Nhiều người hiểu nhầm rằng sau khi gọi free(), vùng nhớ “ptr” sẽ có địa chỉ NULL. Tuy nhiên,hàm free() không hoạt động bằng cách giảm program break. Thay vào đó, nó sẽ thêm vùng nhớ này vào 1 danh sách liên kết chuyên dùng để lưu các vùng nhớ được giải phóng. Các vùng nhớ này sẽ được tái sử dụng bởi các lần gọi malloc() sau. Việc làm này nghe có vẻ phức tạp hóa vấn đề, nhưng kỳ thực có những lý do sau đây:

  • Vùng nhớ giải phóng có thể nằm ở đâu đó khoảng giữa của phân vùng heap thay vì nằm ở vùng cuối của heap, nên việc giảm program break là không thể.
  • Giảm số lần gọi sbrk() cho tiến trình để điều chỉnh program break, qua đó tăng được hiệu suất cho CPU
  • Có nhiều trường hợp tiến trình chạy giải phóng và cấp phát bộ nhớ liên tục, hoặc cần cấp phát vùng nhớ kích thước đúng bằng kích thước vừa giải phóng. Trong trường hợp này, việc giải phóng hẳn vùng nhớ và tăng giảm program break liên tục là không cần thiết, mà chỉ cần tái sử dụng các vùng nhớ đó.

Nếu đối số truyền vào hàm free() là con trỏ NULL, hàm free() sẽ không làm gì cả. Nói cách khác, gọi free(NULL) không làm lỗi chương trình như nhiều người nghĩ. Ngoài ra, gọi free() 2 lần cho 1 vùng nhớ sẽ làm cho tiến trình bị crash.

  • Cấp phát bộ nhớ trên Heap: calloc() và realloc()

Hàm calloc() cấp phát bộ nhớ cho 1 dãy các kiểu dữ liệu giống nhau. Prototype của nó như sau:

#include <stdlib.h>

void *calloc(size_t numitems, size_t size);
   /*Trả về con trỏ đến vùng nhớ vừa cấp phát, hoặc NULL nếu lỗi*/

Đối số “numitems” chỉ số lượng của phần tử có kích thước “size” cần được cấp phát. Cách sử dụng calloc() có khác malloc() 1 chút, ví dụ khi muốn cấp phát động 1 mảng gồm 10 số nguyên, ta dùng malloc() và calloc() như sau:

int *array = NULL;

Với malloc:
array = malloc(10 * sizeof(int));

Với calloc():
array = calloc(10, sizeof(int));

Ngoài ra, có 1 điểm khác nữa là calloc() sau khi cấp phát vùng nhớ xong sẽ khởi tạo giá trị của các phần tử trong vùng nhớ về 0, còn malloc() chỉ cấp phát chứ không khởi tạo giá trị cho các phần tử. Do đó, malloc() được dùng khi tiến trình đòi hỏi về tốc độ, nhưng cần chú ý đến việc khởi tạo các phần tử sau đó (ví dụ dùng hàm memset để khởi tạo).

Ngoài hàm calloc(), còn có thể dùng hàm realloc(), dùng để thay đổi kích thước (thường là tăng lên) của vùng nhớ đã được khởi tạo trước đó. Prototype của realloc() như sau:

#include <stdlib.h>

void *realloc(void *ptr, size_t size);
     /*Trả về con trỏ đến vùng nhớ được cấp phát, hoặc NULL nếu lỗi*/

Con trỏ ptr trỏ đến địa chỉ của vùng nhớ cần thay đổi kích thước, đối số “size” là kích thước mới muốn thay đổi cho vùng nhớ đó. Thông thường, realloc() được dùng để mở rộng vùng nhớ, nên“size” sẽ lớn hơn kích thước hiện tại của nó. Nếu vùng nhớ “ptr” nằm ở cuối phân vùng heap, realloc() sẽ mở rộng phân vùng heap; còn nếu vùng nhớ nằm ở giữa heap mà kích thước khả dụng ở sau vùng nhớ đó không đủ, realloc() sẽ cấp phát 1 vùng nhớ mới và sao chép dữ liệu ở vùng nhớ cũ sang. Việc này ngốn khá nhiều tài nguyên của CPU, vì vậy có 1 lời khuyên cho lập trình viên là hạn chế sử dụng realloc().

Cấp phát bộ nhớ trên Stack: alloca()

Giống như các hàm họ malloc(), hàm alloca() cũng dùng để cấp phát động trong tiến trình. Tuy nhiên thay vì cấp phát trên phân vùng heap, alloca() cấp phát trên phân vùng stack bằng cách tăng kích thước của stack frame của hàm gọi cấp phát. Prototype của alloca() như sau:

#include <alloca.h>

void *alloca(size_t size);
    /*Trả về địa chỉ của vùng nhớ được cấp phát*/

Không giống như malloc, hàm alloca() được compiler triển khai bằng code trực tiếp thay đổi stack pointer, và không cần phải duy trì link list của hàm free(), nên alloca() sẽ cấp phát nhanh hơn malloc(). Vì alloca() cấp phát trên phân vùng stack, nó sẽ tự động bị thu hồi sau khi hàm gọi cấp phát kết thúc (vì stack frame của nó cũng được giải phóng). Do đó, không cần thiết phải gọi free() để giải phóng bộ nhớ sau khi sử dụng, và cũng không thể gọi hàm realloc() để thay đổi kích thước vùng nhớ đó. Hàm alloca() đặc biệt hữu ích trong trường hợp hàm đang chạy gọi longjmp() hoặc siglongjmp() để nhảy sang đoạn code khác không nằm trong stack frame của hàm gọi (chi tiết về longjmp() ở link http://man7.org/linux/man-pages/man3/setjmp.3.html). Khi đó rất khó thậm chí là không thể tránh được memory leak nếu cấp phát bằng malloc().

Kết luận

Qua bài này, chúng ta đã hiểu rõ hơn về cách cấp phát bộ nhớ trong 1 tiến trình và cách sử dụng hợp lý các hàm cấp phát bộ nhớ. Các hàm họ malloc() dùng để cấp phát trên phân vùng heap, cần phải dùng free() để giải phóng sau khi dùng xong. Ngược lại, hàm alloca() cấp phát trên phân vùng stack và sẽ tự động được giải phóng cùng với stack frame của hàm gọi sau khi nó return. Bài học sau sẽ nói về chủ đề rất thú vị là ánh xạ bộ nhớ (memory mapping).

Trả lời

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *