Bài 8 chúng ta thấy cách hệ quản trị CSDL quản lý dữ liệu trên đĩa. Bài này đi sâu hơn vào một câu hỏi nền tảng hơn nữa: khi bạn gọi malloc() trong C, điều gì thực sự xảy ra bên dưới? Không có phép màu nào cả — chỉ là một cấu trúc dữ liệu và vài giải thuật khá đơn giản.

1. Vì sao cần một bộ cấp phát bộ nhớ động?

Chương trình xin cấp phát vùng nhớ (heap) với kích thước khác nhau tại thời điểm chạy, rồi giải phóng chúng theo thứ tự bất kỳ — không nhất thiết theo đúng thứ tự đã xin. Bộ cấp phát (allocator) phải quản lý vùng nhớ trống còn lại sao cho các lần xin cấp phát sau vẫn tìm được chỗ trống phù hợp, mà không tốn quá nhiều thời gian dò tìm.

2. Free List: Danh sách các khối nhớ trống

Kỹ thuật kinh điển nhất: bộ cấp phát duy trì một danh sách liên kết các khối nhớ trống (free list). Điều thú vị là con trỏ next của danh sách này được lưu ngay bên trong chính vùng nhớ trống đó — không tốn thêm bộ nhớ phụ nào, vì vùng nhớ đang trống thì không ai dùng tới nội dung của nó cả:

free-block.js
// Mô phỏng khối nhớ trống — trong C thật, size/next được ghi
// TRỰC TIẾP vào những byte đầu của vùng nhớ trống đó
class FreeBlock {
  constructor(start, size) {
    this.start = start; // địa chỉ bắt đầu
    this.size = size;   // kích thước khối (byte)
  }
}
// Free list là mảng/danh sách các FreeBlock, luôn được sắp xếp
// theo địa chỉ start tăng dần để việc gộp khối (coalescing) ở
// phần 5 chỉ cần so sánh 2 khối liền kề trong danh sách.

3. Chiến lược cấp phát: First-Fit, Best-Fit, Worst-Fit

Khi cần cấp phát $n$ byte, bộ cấp phát phải chọn 1 khối trống đủ lớn trong free list. Có 3 chiến lược kinh điển:

allocation-strategies.js
// First-Fit: lấy khối trống ĐẦU TIÊN đủ lớn — nhanh nhất, dừng ngay khi tìm thấy
function firstFit(freeList, size) {
  for (const block of freeList) {
    if (block.size >= size) return block;
  }
  return null; // không còn khối nào đủ lớn
}

// Best-Fit: quét HẾT danh sách, chọn khối NHỎ NHẤT vẫn đủ chứa
// (giảm thiểu phần thừa lãng phí mỗi lần cấp phát)
function bestFit(freeList, size) {
  let best = null;
  for (const block of freeList) {
    if (block.size >= size && (!best || block.size < best.size)) best = block;
  }
  return best;
}

// Worst-Fit: quét HẾT danh sách, chọn khối LỚN NHẤT
// (để phần còn thừa lại vẫn đủ lớn, hữu dụng cho lần sau)
function worstFit(freeList, size) {
  let worst = null;
  for (const block of freeList) {
    if (block.size >= size && (!worst || block.size > worst.size)) worst = block;
  }
  return worst;
}
🔬 Đào sâu: Vì sao Best-Fit không hẳn là "tốt nhất"?
Nghe tên tưởng Best-Fit luôn thắng — nó lãng phí ít nhất mỗi lần cấp phát. Nhưng về lâu dài, Best-Fit có xu hướng để lại rất nhiều mảnh vụn cực nhỏ (tiny leftover fragments) rải khắp bộ nhớ — những mảnh này thường quá nhỏ để dùng cho bất kỳ yêu cầu cấp phát nào sau đó, làm phân mảnh ngoài trở nên TỆ HƠN theo thời gian dù mỗi lần cấp phát riêng lẻ đều "tối ưu".

4. Phân mảnh trong (Internal) và Phân mảnh ngoài (External)

Phân mảnh trong (internal fragmentation) xảy ra khi khối cấp phát lớn hơn yêu cầu thực tế (do làm tròn theo alignment, hoặc do chọn worst-fit để lại khối to hơn cần thiết) — phần dư đó bị "khóa" bên trong khối đã cấp phát, không ai dùng được dù nó vẫn tính là "đã cấp phát". Phân mảnh ngoài (external fragmentation) xảy ra khi tổng bộ nhớ trống đủ lớn, nhưng bị chia nhỏ rải rác thành nhiều mảnh không liền kề — không mảnh nào đủ lớn cho yêu cầu cấp phát mới dù cộng dồn lại thì thừa sức.

ℹ️ Lưu ý: Đo phân mảnh ngoài bằng công thức nào?
Một cách đo phổ biến: $\text{external\_frag} = 1 - \dfrac{\text{khối trống lớn nhất}}{\text{tổng bộ nhớ trống}}$. Nếu tổng bộ nhớ trống là 100 byte nhưng khối lớn nhất chỉ 10 byte, tỷ lệ phân mảnh ngoài là 0.9 — tức 90% bộ nhớ trống "về mặt lý thuyết" thực chất không dùng được cho một yêu cầu cấp phát lớn.

5. Gộp khối liền kề (Coalescing)

Khi giải phóng 1 khối, nếu khối liền trước hoặc liền sau nó trong bộ nhớ cũng đang trống, phải gộp ngay thành 1 khối trống lớn hơn — đây là kỹ thuật chống phân mảnh ngoài quan trọng nhất:

coalesce.js
// freeList luôn được giữ SẮP XẾP theo start — gộp chỉ cần
// so sánh khối vừa thêm với khối ngay trước/sau nó trong mảng
function coalesce(freeList) {
  freeList.sort((a, b) => a.start - b.start);
  for (let i = 0; i < freeList.length - 1; i++) {
    const cur = freeList[i];
    const next = freeList[i + 1];
    if (cur.start + cur.size === next.start) { // liền kề tuyệt đối
      cur.size += next.size;      // gộp next vào cur
      freeList.splice(i + 1, 1);  // xoá next khỏi danh sách
      i--; // xét lại cur với khối kế tiếp mới (có thể gộp tiếp)
    }
  }
}
🕳️ Cạm bẫy thường gặp: Quên gộp khối sau khi free()
Nếu free() chỉ đơn giản thêm khối vừa giải phóng vào free list mà không gộp với khối liền kề, một chuỗi cấp phát/giải phóng xen kẽ (allocate A, B, C rồi free B) có thể để lại vô số khối trống nhỏ xíu nằm rải rác — dù tổng bộ nhớ trống dư dả, không khối nào đủ lớn cho yêu cầu cấp phát tiếp theo. Đây là nguyên nhân phổ biến nhất gây phân mảnh ngoài nghiêm trọng trong các allocator cài đặt cẩu thả.

6. So sánh First-Fit, Best-Fit, Worst-Fit

Tiêu chí First-Fit Best-Fit Worst-Fit
Tốc độ tìm khối Nhanh nhất (dừng ngay khi thấy) Chậm (phải quét hết) Chậm (phải quét hết)
Phân mảnh ngoài theo thời gian Trung bình Có thể TỆ hơn (nhiều mảnh vụn nhỏ) Thường tốt hơn Best-Fit
Lãng phí mỗi lần cấp phát Trung bình Ít nhất (tối ưu cục bộ) Nhiều nhất
💡 Mẹo: Allocator thực tế dùng kỹ thuật gì tinh vi hơn?
Glibc malloc, jemalloc, và tcmalloc dùng kỹ thuật phức tạp hơn nhiều: segregated free lists (nhiều free list riêng theo dải kích thước), buddy allocator (chia đôi/gộp đôi theo lũy thừa 2), và slab allocator (cấp phát theo lô cho các object cùng kích thước). Bộ thu gom rác (garbage collector) của JavaScript/Java cũng đối mặt bài toán phân mảnh tương tự — đó là lý do một số GC có bước "compaction" định kỳ dồn các object đang sống lại gần nhau.

Sân chơi tương tác: Memory Allocator Visualizer

Cấp phát nhiều khối kích thước khác nhau theo chiến lược bạn chọn, rồi giải phóng vài khối bằng cách click trực tiếp vào khối trên thanh bộ nhớ — quan sát phân mảnh ngoài hình thành và cách gộp khối liền kề hoạt động.

💾 Sân chơi tương tác: Memory Allocator

Cấp phát (heap 200 byte)

Click vào khối màu trên thanh bộ nhớ để giải phóng (free).

Tổng bộ nhớ trống200
Khối trống lớn nhất200
Phân mảnh ngoài0%

Nhật ký

memory-allocator-live.js

Trắc nghiệm ôn tập

Câu 1: Sự khác biệt cốt lõi giữa phân mảnh trong (internal) và phân mảnh ngoài (external) là gì?

Trắc nghiệm ôn tập

Câu 2: Vì sao Best-Fit — dù tối ưu cho TỪNG lần cấp phát riêng lẻ — có thể khiến phân mảnh ngoài TỆ HƠN về lâu dài so với First-Fit?

Trắc nghiệm ôn tập

Câu 3: Vì sao free list phải được sắp xếp theo địa chỉ (start) tăng dần khi cài đặt gộp khối (coalescing)?

📖 Tài liệu tham khảo / References

Bài viết liên quan trong series

Bài 10: Hash Table & Va Chạm Bài 8: B-Tree Database Index Quay lại Lộ trình Series DSA Series C: Quản Lý Bộ Nhớ & Valgrind