Các hãng bay bán vé nhiều hơn số ghế trên máy bay. Họ cá rằng luôn có người không đến, nên bán dư một ít thì chuyến nào cũng đầy khách mà chẳng sao. Bạn cầm tấm vé đã xác nhận trong tay — nhìn hệt như một chỗ ngồi chắc chắn. Nhưng nếu hôm đó ai cũng đến check-in, sẽ có người bị đẩy (bump) khỏi chuyến bay — và không nhất thiết là người mua vé cuối cùng; hãng chọn theo luật riêng của họ. Linux quản lý bộ nhớ y hệt một hãng bay overbook, và cái vé "đã xác nhận" của bạn chính là con trỏ non-NULL mà malloc trả về.

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: 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. Đúng tấm vé đã xác nhận cho một chuyến bay có thể đã bán quá chỗ.

Đâ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 — hành khách chỉ chiếm ghế khi thật sự lên máy bay.

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 — hành khách bị bump khỏi chuyến, không một lời báo trước ở quầy vé.

(Môi trường đo: container gcc:13 trên host ARM AArch64, overcommit mặc định; phần OOM chạy trong container --memory=256m để cgroup cô lập. Con số đổi theo RAM máy và cấu hình vm.overcommit_memory; cái cần nhìn là hình dạng: hứa vượt RAM vẫn non-NULL, RSS chỉ tăng theo lượng chạm, chạm quá trần thì SIGKILL.)

Hai niềm tin đối nghịch, cùng sai

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.

Nhưng đừng vội lật sang niềm tin ngược: "vậy kiểm malloc == NULL là vô nghĩa, bỏ luôn đi". Cũng không đúng. Vẫn có chế độ không overcommit (vm.overcommit_memory=2), trên đó malloc trả NULL thật khi vượt hạn mức — và code chạy trên máy khác, OS khác (Windows, hay Linux cấu hình chặt) vẫn dựa vào giá trị trả về đó. Bỏ kiểm NULL là viết code sai ở những môi trường đó, và bỏ lỡ cả những lỗi cấp phát khác (xin một kích thước điên rồ do tràn số, chẳng hạn). Cách đúng không phải "bỏ kiểm", mà là hiểu kiểm đó bảo đảm gì: nó bắt được vài lỗi, nhưng ở chế độ overcommit mặc định của Linux nó gần như không bao giờ báo hết-RAM — nên đừng coi nó là tấm lưới an toàn bộ nhớ.

Về đến code hằng ngày

Điều đầ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.

Điều 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.

Chỗ này là nỗi đau rất quen ở tầng vận hành hiện đại. Cái exit code 137 kia chính là "OOMKilled" khét tiếng trong Kubernetes/Docker: một pod đặt memory limit, container chạm trần, cgroup gọi OOM killer, pod chết với 137 — và log ứng dụng thường sạch trơn vì SIGKILL không cho kịp ghi gì. JVM là nạn nhân kinh điển: -Xmx chỉ giới hạn heap, nhưng RSS thật còn gồm metaspace, thread stack, buffer ngoài heap, code cache — nên một JVM -Xmx200m vẫn có thể bị OOMKill trong container 256 MB (phải để "headroom", hoặc dùng -XX:MaxRAMPercentage). Go và mọi runtime khác cũng vậy: không ngôn ngữ nào "bắt" được SIGKILL để cứu vãn. Còn database thì đi hướng ngược lại — PostgreSQL khuyến nghị tắt overcommit (vm.overcommit_memory=2) trên server chuyên dụng, vì một OOM-kill vào postmaster nguy hiểm hơn nhiều một malloc trả NULL mà nó xử lý được. Cùng một câu chuyện overbook, hiện ra ở mọi tầng bạn deploy.

Muốn tự thấy thì 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. Vài phút đó 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.