Bạn viết p = malloc(n) rồi if (p == NULL) { /* hết bộ nhớ */ } — cẩn thận kiểm lỗi cấp phát như sách dạy. Nhưng trên Linux, cái kiểm đó gần như vô dụng, và tệ hơn: malloc trả về một con trỏ không NULL cho một lượng bộ nhớ mà máy không có. Đó là vì Linux overcommit — hứa nhiều hơn nó có thể giữ. Và khi lời hứa vỡ, kẻ trả giá không phải là malloc (nó đã "thành công" từ lâu), mà là một tiến trình bị OOM killer bắn chết. Tôi đo cả ba mảnh — overcommit, chạm mới tốn RAM, và OOM killer — trong container gcc:13, với phần OOM chạy an toàn trong một container giới hạn bộ nhớ riêng.

Overcommit và OOM killer

Overcommit: malloc chỉ hứa, chưa giao

Nhớ lại bộ nhớ ảo lười ở phần 6: khi bạn xin bộ nhớ, nhân không cấp trang vật lý ngay — nó chỉ ghi vào bảng trang rằng "vùng này hợp lệ", và trang thật chỉ được cấp lúc bạn chạm tới (một page fault). malloc/mmap vì thế trả về ngay lập tức với một vùng địa chỉ ảo, chưa gắn với RAM nào cả. Hệ quả trực tiếp: bạn có thể xin nhiều hơn RAM vật lý, thậm chí nhiều hơn cả RAM + swap, và nó vẫn "thành công" — vì cái được cấp chỉ là không gian ảo, một lời hứa.

Đây gọi là overcommit: nhân cho phép tổng bộ nhớ đã hứa vượt xa bộ nhớ thật, đánh cược rằng phần lớn chương trình không bao giờ chạm hết cái chúng xin (nghĩ tới fork + copy-on-write, hay một mảng cấp to nhưng dùng ít). Tôi kiểm chứng bằng cách xin 48 GB trên một máy chỉ có ~8 GB RAM.

Đo: 48 GB "thành công", nhưng RAM chỉ tốn khi chạm

malloc 48 GB (máy RAM ~8 GB):
  malloc trả về  : NON-NULL (thành công!) — địa chỉ hợp lệ
  RSS ngay sau   : ~1 MB   (chưa chạm -> chưa tốn RAM thật)
  mmap 48 GB     : cũng thành công

Chạm 200 MB đầu (ghi từng trang):
  RSS            : ~202 MB (tăng đúng theo số trang đã chạm)

malloc(48 GB) trả về non-NULL — thành công hoàn toàn — dù máy chỉ có 8 GB. Và ngay sau đó, RSS (resident set size — lượng RAM vật lý tiến trình thực sự chiếm, đọc từ /proc/self/statm) chỉ ~1 MB. Nghĩa là 48 GB kia hoàn toàn trên giấy: chưa một trang vật lý nào được cấp. mmap cũng vậy. Chỉ khi tôi chạm vào 200 MB đầu (ghi một byte mỗi trang), RSS mới tăng lên ~202 MB — đúng bằng lượng đã chạm, không phải lượng đã xin. Bộ nhớ thật chỉ được giao khi bạn thực sự dùng tới.

Vậy điều gì xảy ra khi bạn chạm nhiều hơn RAM có? Đây là lúc OOM killer vào cuộc. Tôi chạy phần này an toàn trong một container riêng giới hạn 256 MB (--memory=256m), để cgroup chặn mọi thiệt hại trong container đó:

Trong container giới hạn 256 MB:
  malloc 8 GB ảo -> NON-NULL (thành công)
  chạm dần:  64 MB -> RSS 65 MB
            128 MB -> RSS 129 MB
            192 MB -> RSS 193 MB
  chạm tới ~256 MB -> tiến trình bị GIẾT: exit 137 (SIGKILL), "Killed"

Khi lượng đã chạm chạm trần 256 MB, nhân không còn RAM để giao lời hứa — và nó giết tiến trình bằng SIGKILL, cho exit code 137 (128 + 9, tức bị tín hiệu 9). Không có malloc nào trả NULL, không có lỗi nào được trả về code của bạn: chương trình đang chạy ngon thì biến mất. Đó là OOM killer.

Một lần tôi đo hớ: "malloc non-NULL nghĩa là có bộ nhớ"

Tôi vào đo với đúng niềm tin sách giáo khoa: "kiểm malloc khác NULL là đủ để biết cấp phát thành công và mình có vùng nhớ đó". Đo phá tan điều này ở cả hai đầu. Đầu thứ nhất: malloc(48 GB) trên máy 8 GB vẫn non-NULL — nên "non-NULL" không nghĩa là bạn có 48 GB; nó chỉ nghĩa là nhân đã ghi nhận một vùng ảo. Đầu thứ hai, nguy hiểm hơn: khi bạn thật sự chạm quá RAM, thất bại không đến dưới dạng một malloc trả NULL để bạn xử lý gọn gàng — nó đến dưới dạng một SIGKILL từ trời giáng xuống, muộn hơn nhiều so với lúc cấp phát, và có thể vào một tiến trình khác: OOM killer chọn "nạn nhân" theo oom_score (một heuristic dựa trên mức chiếm RAM, độ ưu tiên…), nên kẻ bị giết chưa chắc là kẻ vừa xin nhiều.

Bài học đo lường: trên Linux overcommit mặc định, malloc != NULL không bảo đảm bạn có bộ nhớ; sự thật chỉ lộ ra lúc chạm, và thất bại đến muộn như một SIGKILL, không phải một giá trị trả về. Kiểm malloc == NULL vì thế gần như vô dụng ở chế độ mặc định — nó hầu như không bao giờ đúng, kể cả khi máy sắp cạn RAM. (Có vm.overcommit_memory để đổi sang chế độ không overcommit, khi đó malloc mới trả NULL thật khi vượt hạn mức — nhưng đó là ngoại lệ phải chủ động bật, không phải mặc định.) Nếu tôi tin "non-NULL là an toàn", tôi đã viết code tưởng mình đã kiểm lỗi bộ nhớ mà thực ra chưa kiểm gì cả.

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

Hệ quả đầu tiên: đừng dựa vào malloc == NULL để phát hiện hết RAM trên Linux. Ở chế độ mặc định nó gần như không bao giờ báo; RAM cạn biểu hiện thành OOM-kill (tiến trình biến mất, exit 137) hoặc máy chậm rề vì swap, chứ không thành một NULL bạn bắt được. Muốn kiểm soát bộ nhớ thật, hãy giới hạn ở tầng hệ (cgroup/--memory như trong container), theo dõi RSS thật (/proc), và thiết kế để không chạm quá cái mình cần.

Hệ quả thứ hai: RSS (đã chạm), không phải kích thước malloc (đã hứa), mới là con số bộ nhớ có ý nghĩa. Một tiến trình malloc 48 GB nhưng dùng 200 MB chỉ tốn 200 MB — theo dõi "bộ nhớ ảo" (VSZ) sẽ khiến bạn hoảng loạn vô cớ, còn theo dõi RSS mới thấy áp lực thật. Đây là lý do các công cụ giám sát nghiêm túc nhìn RSS và oom_score, không nhìn VSZ.

Hệ quả thứ ba là tinh thần đo lường: phân biệt "đã hứa" với "đã giao", và biết thất bại đến dưới dạng gì. Con số mang theo: Linux overcommit: malloc/mmap cấp không gian ẢO + lười nên trả non-NULL dù xin nhiều hơn RAM (đo: malloc 48GB trên máy 8GB thành công, RSS ~1MB); RAM thật chỉ tốn lúc CHẠM (chạm 200MB -> RSS 202MB); chạm quá RAM thì OOM killer GIẾT một tiến trình bằng SIGKILL (đo: chạm quá 256MB -> exit 137), chọn nạn nhân theo oom_score nên có thể không phải kẻ xin nhiều. Vậy malloc != NULL KHÔNG bảo đảm bộ nhớ, và kiểm NULL gần như vô dụng ở mặc định — lỗi đến muộn như SIGKILL. "Cấp phát thành công" trên Linux chỉ là một lời hứa, và lời hứa đó có thể vỡ rất xa chỗ bạn kiểm lỗi.

Thử ba mươi giây

Viết một chương trình malloc một lượng lớn hơn RAM máy bạn nhiều lần (ví dụ 10× RAM), rồi in con trỏ và đọc RSS từ /proc/self/statm ngay sau đó — bạn sẽ thấy con trỏ non-NULL và RSS gần như bằng 0. Rồi thêm một vòng lặp chạm dần từng trang và in RSS: nó tăng đúng theo lượng bạn chạm, không theo lượng bạn xin. Nếu muốn thấy OOM killer (hãy làm trong một container --memory=256m để an toàn), cho vòng lặp chạm mãi: đến một điểm, chương trình biến mất với exit 137. Ba mươi giây đó dạy bạn một sự thật mà cái if (p == NULL) quen thuộc che giấu: trên Linux, cấp phát chỉ là hứa hẹn, bộ nhớ thật được giao lúc chạm, và khi hết thật thì không ai trả NULL cho bạn — có kẻ bị bắn.