malloc thực sự làm gì bên dưới lớp vỏ?

Công nghệ09 tháng 11, 2026·9 phút đọc

Bài phân tích đi sâu vào cách malloc của glibc quản lý bộ nhớ: từ lời gọi hệ thống brk/sbrk, cấu trúc chunk và header, danh sách free list, cơ chế demand paging, đến việc dùng mmap cho khối lớn. Tác giả còn tự viết các bộ cấp phát thử nghiệm để so sánh tốc độ và mức tiêu thụ RAM với glibc.

malloc thực sự làm gì bên dưới lớp vỏ?

malloc có một giao diện rất đơn giản: bạn xin một lượng bộ nhớ, và bạn nhận được một con trỏ void trỏ tới khối byte liền mạch vừa được "phù phép" ra đời. Khi dùng xong, bạn gọi free với đúng con trỏ đó và vùng nhớ biến mất vào hư không.

Nhưng thực sự chuyện gì đang diễn ra? Vì chúng ta chạy trong không gian người dùng (userspace), malloc chắc chắn phải xin bộ nhớ từ kernel thông qua các lời gọi hệ thống (syscall). Tuy nhiên, chương trình có thể cấp phát rất nhiều khối nhỏ, mà syscall lại đắt đỏ (một syscall rỗng mất 575 ns, theo đo đạc trong bài), nên có một phần "sổ sách" được quản lý ngầm phía sau.

Lời gọi brk và sbrk

Bài viết minh họa trên glibc (thư viện C chuẩn của GNU) với một đoạn chương trình gọi malloc(16) một nghìn lần. Khi theo dõi bằng strace, ta thấy chỉ có các lời gọi brk:

brk(NULL)              = 0x59faca231000
brk(0x59faca252000)    = 0x59faca252000

brk là syscall đặt program break (điểm ngắt chương trình) tới địa chỉ được cung cấp. sbrk là hàm bao tiện dụng của libc, nhận vào một delta và tăng program break thêm chừng đó.

Không gian địa chỉ của tiến trình

Một tiến trình đang chạy có không gian địa chỉ riêng, chia thành nhiều vùng: text cho mã thực thi, data cho biến toàn cục đã khởi tạo, bss cho biến toàn cục chưa khởi tạo, cùng các vùng khác. Khi in địa chỉ đại diện của từng vùng, ta thấy chúng tăng dần từ mã nhị phân lên tới stack.

Một điểm đáng chú ý: trong lần chạy đó, program break và printf trong libc cách nhau tới 23,4 TiB, gấp khoảng 1.530 lần dung lượng RAM 15,6 GiB của máy. Gần như toàn bộ khoảng đó là vùng chưa ánh xạ (unmapped), nên không tốn bộ nhớ vật lý.

Bộ cấp phát đồ chơi với sbrk

Vì có thể dịch chuyển program break trực tiếp, ta có thể tự viết một bộ cấp phát nhỏ. Đây gọi là bump allocator, vì mỗi lần gọi chỉ đơn giản "đẩy" mức bộ nhớ được phép dùng lên:

void *my_malloc(size_t size) {
    void *p = sbrk(align_up(size));
    return p == (void *)-1 ? NULL : p;
}

Bộ cấp phát này không tốt lắm: nó dịch break ở mọi lần xin, và mỗi lần dịch là một syscall. Khi đo hiệu năng trên một triệu lần gọi:

  • syscall(SYS_getppid): 575,2 ns mỗi lần
  • sbrk(16): 610,1 ns mỗi lần
  • malloc(16): 26,9 ns mỗi lần

Nghĩa là malloc(16) nhanh hơn sbrk(16) tới 22,7 lần. Đó là lý do malloc xin các khối lớn từ kernel rồi tự chia nhỏ. Bộ bump allocator cũng không có cách tái sử dụng bộ nhớ đã giải phóng.

Chunk và header

malloc thực ra cấp phát nhiều hơn lượng bạn yêu cầu. Mỗi lần cấp phát, hay chunk, có một header kích thước nằm ngay trước con trỏ trả về — đó là cách free biết kích thước mà không cần bạn truyền vào. Kích thước chunk luôn là bội số của 16, nên các bit thấp luôn bằng 0, và glibc giữ ba cờ trong đó:

  • A: arena khác
  • M: chunk có ánh xạ riêng
  • P: chunk phía trước đang được dùng

Với header, ta mới có thể viết my_free đàng hoàng: lùi về header, rồi đưa chunk vào danh sách free list. Hàm chèn sẽ duyệt danh sách để tìm các chunk trống kề bên theo địa chỉ, rồi gộp (coalesce) chúng nếu chúng liền nhau.

Kết quả chạy thử cho thấy rõ: sau khi giải phóng a và b kề nhau, chúng gộp thành một chunk 352 byte; lần xin d sau đó được cắt ra từ chính chunk gộp này.

Demand paging: bộ nhớ chỉ tốn RAM khi được ghi

my_free tái sử dụng bộ nhớ nhưng chưa bao giờ trả lại cho kernel. Việc một chunk trống có tốn RAM hay không phụ thuộc vào thời điểm kernel gán bộ nhớ vật lý phía sau một địa chỉ.

Mỗi tiến trình thấy bộ nhớ ảo (virtual memory), được kernel ánh xạ lên RAM vật lý theo các trang (page) 4 KiB. Sự tách biệt này cho phép kernel cấp dải địa chỉ mà chưa có RAM vật lý nào phía sau — một trang ảo chỉ cần được "đỡ" bởi bộ nhớ vật lý ở lần ghi đầu tiên.

Thử nghiệm tăng break thêm 1 GiB rồi ghi một byte vào mỗi trang cho thấy: ngay sau sbrk, kích thước ảo đã hơn 1 GiB nhưng RSS (resident set size — lượng trang đang nằm trong RAM) chỉ 1308 KiB. Mỗi 256 MiB được ghi tốn thêm 65.536 page fault, đúng một lần cho mỗi trang 4 KiB.

Trả bộ nhớ lại cho kernel và vấn đề phân mảnh

Ta trả bộ nhớ bằng cách hạ program break. Hàm trim hạ break khi chunk trống ở đỉnh heap đạt 128 KiB, giữ lại 64 KiB để các yêu cầu tiếp theo không phải nâng break lên ngay.

Tuy nhiên, chỉ phần đỉnh của heap mới có thể trả lại. Một chunk đang sống sẽ giữ break ở trên nó, nên mọi chunk trống bên dưới vẫn nằm trong RAM. Qua thời gian dài, các lần cấp phát và giải phóng để lại những lỗ trống giữa các chunk đang sống mà các yêu cầu sau hiếm khi lấp đầy — hiện tượng này gọi là phân mảnh (fragmentation). Trong chương trình chạy lâu, phân mảnh có thể làm RSS phình lên cho tới khi bị giới hạn bộ nhớ "giết" tiến trình.

Khối lớn dùng mmap

Syscall mmap thường ánh xạ file vào không gian địa chỉ. Với cờ MAP_ANONYMOUS, không có file phía sau và kernel tạo một vùng ánh xạ chứa đầy số 0, căn theo trang. Syscall munmap trả khối có ánh xạ riêng về kernel ngay khi được giải phóng, bất kể thứ gì nằm cạnh nó.

Trong bộ cấp phát tự viết, my_malloc chọn giữa sbrk và mmap theo kích thước yêu cầu: mọi yêu cầu từ 128 KiB trở lên (tính cả header) có ánh xạ riêng; yêu cầu nhỏ hơn đi theo đường free list của sbrk. Bit M trong header đánh dấu chunk có ánh xạ riêng để my_free biết cách xử lý.

Vậy tại sao không mmap mọi thứ? Một số bộ cấp phát làm vậy. Bản thân glibc cũng tạo thêm các heap gọi là arena bằng mmap và phân bổ thread lên đó để tránh tranh chấp khóa trên heap chính. Nhưng các ánh xạ riêng biệt không đảm bảo liền mạch, nên chunk không thể gộp qua các khoảng hở này. Ngược lại, cấp phát bằng sbrk chỉ đẩy lên vùng liền mạch duy nhất đã có, nên một chunk trống được tái sử dụng không gây page fault nào.

So sánh hiệu năng

Bài viết đánh giá các bộ cấp phát tự viết so với glibc trên một tải lặp đi lặp lại việc cấp phát và giải phóng các khối kích thước ngẫu nhiên. Khi kết thúc, các khối đang sống giữ 10,3 MiB.

Bộ cấp phátThời gian trung bình mỗi bước (µs)RSS khi còn khối sống (MiB)RSS khi giải phóng hết (MiB)
my-bump-11,7214251425
my-freelist-25,1217,817,8
my-trim-311,214,50,188
my-mmap-411,811,20,188
glibc0,078919,019,0

my-bump-1 không bao giờ giải phóng hay tái sử dụng, nên RSS bằng toàn bộ 1425 MiB đã cấp phát trong cả lượt chạy. my-freelist-2 tái sử dụng bộ nhớ nên RSS gần với 10,3 MiB dữ liệu sống hơn nhiều, nhưng mỗi bước phải duyệt danh sách hai lần. my-trim-3 thêm lần duyệt thứ ba, nên chậm hơn glibc 142 lần, bù lại chỉ giữ 0,188 MiB khi giải phóng hết.

Vì sao glibc nhanh đến vậy? Vì nó giữ các chunk trống trong bin — các danh sách tách theo kích thước — nên một yêu cầu chỉ cần duyệt danh sách đúng cỡ. Nó còn giữ tới bảy chunk đã giải phóng cho mỗi kích thước nhỏ trong tcache (bộ nhớ đệm theo thread) và trả thẳng lại cho yêu cầu tiếp theo cùng cỡ.

Chính tcache giải thích cột cuối: glibc giữ 19,0 MiB sau khi giải phóng hết, vì heap tính chunk trong cache là đang dùng, nên một chunk gần đỉnh khiến glibc không hạ break. Có thể tắt tcache bằng biến môi trường GLIBC_TUNABLES=glibc.malloc.tcache_count=0, hoặc cố định ngưỡng mmap ở 128 KiB với GLIBC_TUNABLES=glibc.malloc.mmap_threshold=131072.

Cấu hìnhThời gian mỗi bước (µs)RSS khi còn khối sống (MiB)RSS khi giải phóng hết (MiB)
glibc0,078919,019,0
glibc, tcache_count=00,12117,40,250
glibc, mmap_threshold=1310720,84312,15,85

Khi tắt tcache, giải phóng hết chỉ còn 0,250 MiB, nhưng mỗi bước chậm đi 1,5 lần.

malloc tiêu bộ nhớ để tránh xa kernel, chia nhỏ các khối lớn trong không gian người dùng để phần lớn lời gọi không phải chạm tới syscall.

Góc nhìn

Bài viết này là tài liệu học tập rất giá trị cho lập trình viên Việt Nam làm việc với C, C++, Rust hay bất kỳ ngôn ngữ nào có lớp quản lý bộ nhớ thủ công. Hiểu rằng malloc nhanh hơn sbrk hàng chục lần, rằng bộ nhớ chỉ tốn RAM khi thực sự được ghi, và rằng phân mảnh có thể âm thầm làm RSS tăng trong các tiến trình chạy lâu, giúp ích trực tiếp khi debug các dịch vụ bị "out of memory" trên server.

Với các nhóm phát triển back-end, những kiến thức về tcache, bin và ngưỡng mmap giải thích vì sao cùng một chương trình có thể tốn lượng RAM rất khác nhau tùy mô hình truy cập, và vì sao các biến GLIBC_TUNABLES đôi khi là công cụ tinh chỉnh đáng thử trước khi kết luận rằng phải viết lại code hay nâng cấp máy chủ. Đây cũng là nền tảng tốt để hiểu vì sao các bộ cấp phát hiện đại như jemalloc hay tcmalloc tồn tại, dù bài viết không đi sâu vào chúng.

Nguồn: benmandrew.com · Quy trình biên tập

Chia sẻ:FacebookX