bài OOM killer ta thấy malloc thành công dù không đủ RAM — hệ quả của cấp phát vượt mức (overcommit). Bài này đào sâu vào chính cơ chế đó: nhân quyết định đồng ý hay từ chối một lời xin bộ nhớ thế nào, và câu trả lời không cố định như tôi tưởng. Tôi đo trong container với đặc quyền để đổi được chính sách, và phát hiện cùng một lệnh mmap cho ra kết quả ngược nhau tùy một con số ở nơi khác.

Cấp phát vượt mức

Ba chế độ overcommit

Cấp phát vượt mức nghĩa là nhân đồng ý cấp nhiều bộ nhớ ảo hơn RAM cộng swap thật, cược rằng phần lớn sẽ không bị chạm cùng lúc (đúng như VSZ so với RSS và OOM đã đo). Nhưng nhân cấp bao nhiêu mới đồng ý được điều khiển bởi một sysctl toàn hệ thống, vm.overcommit_memory, với ba chế độ:

  • 0 — heuristic (mặc định): cho phép cấp vượt "hợp lý", nhưng dùng một phép đoán để từ chối một cấp phát đơn lẻ lớn vô lý so với RAM+swap.
  • 1 — always (luôn đồng ý): nói với mọi lời xin, bất kể cỡ nào — kể cả 100GB trên máy 8GB.
  • 2 — never/strict (kế toán chặt): không cấp vượt; nhân theo dõi tổng bộ nhớ đã cam kết và từ chối bất cứ cấp phát nào làm vượt CommitLimit (mặc định = swap + một tỉ lệ phần trăm của RAM). Thất bại xảy ra sớm và đoán trước được, ngay tại lúc malloc.

Nhân ghi trong /proc/meminfo: Committed_AS là tổng bộ nhớ đã cam kết, CommitLimit là trần mà chế độ 2 ép. Tôi đo mmap với các cỡ khác nhau ở cả ba chế độ, trên máy ~8GB RAM (CommitLimit ~5GB).

Đo: cùng lệnh mmap, ba kết quả

Máy ~8GB RAM, CommitLimit ~5GB:

Chế độ 1 (always):
  mmap(100GB) -> THÀNH CÔNG!   con trỏ khác NULL
  Committed_AS nhảy lên ~101 GB   (trên máy chỉ 8GB!)

Chế độ 0 (heuristic, mặc định):
  mmap(100GB) -> NULL (từ chối)
  mmap(3GB)   -> THÀNH CÔNG

Chế độ 2 (never/strict):
  mmap(100GB) -> NULL
  mmap(6GB)   -> NULL   (vượt CommitLimit ~5GB)
  mmap(3GB)   -> THÀNH CÔNG

Ba khối này là ba thế giới. Ở chế độ 1, mmap(100GB) thành công trên một máy chỉ có 8GB — và Committed_AS nhảy lên 101GB, một con số bất khả về mặt vật lý. Con số bất khả đó chính là bằng chứng: cấp phát chỉ là một lời hứa không gian địa chỉ ảo, nhân sẵn sàng hứa nhiều hơn nó có tới mức lố bịch.

chế độ 0 (mặc định trên hầu hết máy Linux), cùng lệnh mmap(100GB) lại trả NULL — phép đoán heuristic từ chối một cấp phát đơn lẻ lớn vô lý. Nhưng mmap(3GB) thì được: cấp vượt vừa phải vẫn cho.

chế độ 2, mọi thứ bị kế toán chặt theo CommitLimit. mmap(100GB) tất nhiên trượt, nhưng bất ngờ là mmap(6GB) cũng thất bại — trên một máy có 8GB RAM! Vì CommitLimit chỉ ~5GB (swap 1GB + 50% của 8GB RAM), và 6GB vượt qua nó. Chỉ mmap(3GB) (cộng phần đã cam kết trước đó vẫn dưới 5GB) mới thành công.

Một lần tôi đo hớ: malloc thành công hay không không phải chuyện của bạn

Tôi vào bài với một mô hình đơn giản: malloc/mmap hoặc thành công (nghĩa là tôi có bộ nhớ) hoặc trả NULL (tôi không có) — một câu hỏi có đáp án cố định, quyết định bởi lượng RAM của máy. Ba khối đo trên phá vỡ mô hình đó hoàn toàn: cùng một lệnh, trên cùng một máy 8GB, cho ba kết quả khác nhau — chỉ vì một con số ở /proc/sys/vm/overcommit_memory mà chương trình của tôi không thấy và không điều khiển được.

Điều này lật ngược cách chẩn đoán. Nếu malloc của tôi trả NULL sớm bất ngờ, phản xạ là nghĩ "chắc máy hết RAM" hoặc "code rò rỉ đâu đó". Nhưng phép đo cho thấy nó có thể chỉ là host đang đặt chế độ 2 với CommitLimit thấp — máy còn thừa RAM vật lý mà malloc vẫn bị từ chối vì kế toán bảo thủ (nó đếm cái có thể dùng, không phải cái đang dùng). Và ngược lại, malloc thành công không có nghĩa bạn an toàn — ở chế độ 1, nó thành công rồi bạn bị OOM-kill lúc chạm.

Bài học đo lường: "malloc có thành công không" không phải thuộc tính của code bạn, cũng không phải của RAM máy, mà của một chính sách hệ thống. Cùng một chương trình chạy khác nhau trên máy dev (thường chế độ 0) và máy chủ đặt chế độ 2 — một khác biệt vô hình trong code mà chỉ lộ khi đọc đúng nguồn: vm.overcommit_memoryCommitted_AS/CommitLimit.

Vì sao overcommit tồn tại: fork và những mảng thưa

Có thể hỏi: sao nhân lại hứa liều tới mức cho 100GB trên máy 8GB? Vì phần lớn phần mềm thật sự cần overcommit để chạy hiệu quả. Hai ví dụ kinh điển. Thứ nhất là fork: khi một tiến trình 4GB gọi fork để chạy một lệnh nhỏ, copy-on-write khiến tiến trình con "cam kết" thêm 4GB nữa mà gần như không chạm tới trang nào trước khi exec. Nếu kế toán chặt (chế độ 2), fork sẽ thất bại nếu không đủ chỗ cho một bản sao 4GB — dù bản sao đó không bao giờ được vật chất hóa. Thứ hai là các cấu trúc thưa: một mảng băm hay ma trận cấp 10GB nhưng chỉ dùng 500MB rải rác. Overcommit cho những mẫu này chạy mà không lãng phí RAM cho phần chưa dùng. Đó là lý do chế độ 0 (heuristic) là mặc định: nó cân bằng giữa cho phép các mẫu hợp lệ này và chặn những đòi hỏi lố bịch. Chế độ 2 an toàn hơn về mặt "malloc không nói dối" nhưng phải trả giá bằng việc fork và các mảng thưa có thể bị từ chối oan, nên nó cần CommitLimit rộng và cấu hình cẩn thận.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: hiểu chế độ overcommit của môi trường chạy, đừng giả định. Trên chế độ mặc định (0), kiểm malloc == NULL gần như vô dụng cho các cấp phát vừa phải (chúng luôn thành công, rồi thất bại lúc chạm bằng OOM-kill). Trên chế độ strict (2), cùng kiểm đó lại có ý nghĩamalloc thật sự trả NULL khi vượt hạn mức, cho bạn cơ hội xử lý êm. Nhiều hệ thống đòi độ tin cậy cao (một số cơ sở dữ liệu, dịch vụ tài chính) cố tình đặt chế độ 2 để malloc thất bại đàng hoàng thay vì bị OOM-kill bất ngờ — đổi lại phải cấp CommitLimit đủ rộng.

Hệ quả thứ hai: Committed_AS so với CommitLimit là thước đo áp lực bộ nhớ thật khi bật kế toán. Ở chế độ 2, khi Committed_AS tiến gần CommitLimit, các malloc tiếp theo sẽ bắt đầu thất bại — đó là tín hiệu cần thêm RAM/swap hoặc giảm cam kết, trước khi dịch vụ gặp lỗi cấp phát. MAP_NORESERVE cho phép một mmap cụ thể né kế toán (hữu ích cho vùng ánh xạ lớn mà bạn biết sẽ chạm thưa), nhưng ở chế độ 2 nó bị bỏ qua.

Hệ quả thứ ba là bài học bao trùm nhánh này: hành vi cấp phát bộ nhớ là một hợp đồng do hệ thống định nghĩa, không phải do code bạn. Con số mang theo: vm.overcommit_memory có ba chế độ — always cho cả mmap 100GB trên máy 8GB (Committed_AS lên 101GB), heuristic (mặc định) từ chối cỡ vô lý nhưng cho cỡ hợp lý, strict chặn theo CommitLimit và từ chối cả 6GB trên máy 8GB; cùng một lệnh mmap cho kết quả ngược nhau tùy chế độ mà chương trình không thấy. Trước khi tin malloc thành công hay đổ lỗi cho nó thất bại, hãy đọc chính sách overcommit của nơi code đang chạy.

Thử ba mươi giây

Xem chế độ overcommit của máy bạn: cat /proc/sys/vm/overcommit_memory (0/1/2) và cat /proc/sys/vm/overcommit_ratio (thường 50). Rồi grep -E 'Committed_AS|CommitLimit' /proc/meminfoCommitLimit là trần khi chế độ 2, và Committed_AS là tổng đang cam kết. Thử cấp một khối khổng lồ trong Python: python3 -c "import mmap; m=mmap.mmap(-1, 100*1024**3); print('OK', len(m))" — trên chế độ mặc định nó thường in "OK" với 100GB dù máy bạn nhỏ hơn nhiều (overcommit), còn nếu máy đặt chế độ 2 nó sẽ ném MemoryError. Kết quả bạn thấy chính là chế độ overcommit của máy đang nói, không phải RAM của nó.