bài về bộ nhớ ảo và RSS ta thấy malloc chỉ đặt trước không gian địa chỉ ảo, RAM thật chỉ được cấp khi ta ghi vào từng trang. Điều đó dẫn tới một câu hỏi táo bạo: nếu cấp phát không tốn RAM cho tới lúc chạm, thì tôi có thể malloc một lượng lớn hơn cả RAM vật lý không? Nhân có để tôi làm vậy không, và chuyện gì xảy ra khi tôi bắt đầu chạm thật? Bài này đo trực tiếp hiện tượng overcommit — và vấp đúng cái bẫy giữa "cấp phát thành công" và "có RAM".

Overcommit bộ nhớ

Overcommit là gì

Overcommit là việc nhân Linux cho phép các tiến trình cấp phát tổng cộng nhiều bộ nhớ ảo hơn RAM cộng swap thật sự có. Lý do rất thực dụng: phần lớn bộ nhớ mà chương trình xin không bao giờ được chạm tới — một mảng dự phòng khai to cho chắc, thư viện ánh xạ cả đống trang mà chỉ dùng vài trang, heap xin sẵn để lớn dần. Nếu nhân bắt mỗi malloc phải có đủ RAM ngay lúc xin, nó sẽ từ chối rất nhiều cấp phát hoàn toàn vô hại và lãng phí RAM cho những trang chẳng ai đụng.

Nên nhân "hứa liều": nó ghi sổ rằng vùng đó tồn tại (tăng VmSize), trả về con trỏ hợp lệ, nhưng chưa giao một byte RAM nào. RAM thật chỉ được cấp khi ta ghi vào trang, đúng qua cơ chế lỗi trang. Hệ quả là malloc trả về thành công trở thành một lời hứa, không phải một sự bảo đảm — và như mọi lời hứa liều, nó có thể vỡ khi tới lúc phải giao hàng.

Linux có ba chế độ, đọc ở /proc/sys/vm/overcommit_memory: 0 (mặc định, đoán theo heuristic — từ chối những cú xin rõ ràng quá lớn), 1 (luôn đồng ý, không bao giờ từ chối), và 2 (chặt chẽ, không cho tổng vượt quá CommitLimit). Máy tôi đo — chiếc VM Linux mà Docker chạy container bên trong — đặt sẵn mode=1. Tôi không đổi nó (giá trị này không thuộc riêng container, sửa là ảnh hưởng cả VM); tôi chỉ đọc và đo với những gì đang có.

Đo: xin 512 GB trên máy 8 GB, không sao

Máy đo có MemTotal 7,75 GB, swap 1 GB, và CommitLimit 4,87 GB. Tôi viết một chương trình C malloc các lượng tăng dần rồi đọc ngay VmSize, VmRSS từ /proc/self/statusCommitted_AS từ /proc/meminfokhông chạm vào vùng vừa xin:

malloc Kết quả VmSize VmRSS
16 GB THÀNH CÔNG +16386 MB 1 MB
64 GB THÀNH CÔNG +65538 MB 1 MB
128 GB THÀNH CÔNG +131074 MB 1 MB
512 GB THÀNH CÔNG +524290 MB 1 MB

Con số nói thẳng: malloc 512 GB trên một máy có 7,75 GB RAM vẫn trả về thành công tức thì. VmSize phồng lên đúng bằng lượng xin, nhưng VmRSS đứng nguyên ở 1 MB — không một byte RAM vật lý nào bị tiêu. Đây là overcommit ở dạng thuần khiết nhất: nhân ghi sổ một lời hứa 512 GB mà không hề có 512 GB để giao.

Committed_AS — con số nhân dùng để theo dõi tổng bộ nhớ đã hứa — trèo lên tới 526 GB, vượt xa CommitLimit 4,87 GB. Dưới mode=1, cái trần đó hoàn toàn không được ép: nhân cứ hứa, sổ sách cứ phồng, chẳng ai chặn. Đó là dấu hiệu đầu tiên cho thấy một con số trông như "giới hạn" thật ra chẳng giới hạn điều gì.

Một lần tôi đo hớ: "cấp phát thành công" không phải "có RAM"

Tới đây tôi định chốt một kết luận gọn: "overcommit cho xin thoải mái, cấp phát không bao giờ hỏng". Để kiểm, tôi dựng một tình huống có giới hạn thật: chạy container với --memory=512m (một giới hạn cgroup, áp riêng cho container này, an toàn), rồi malloc 2 GB — gấp bốn lần cái cap.

malloc 2 GB trả về THÀNH CÔNG, VmRSS khoảng 1 MB. Tôi nhìn con trỏ hợp lệ, nhìn Committed_AS tăng đủ, và suýt kết luận: "cấp phát 2 GB xong xuôi, tôi có 2 GB trong tay." Đó chính là chỗ tôi đo hớ — tôi đang đọc một lời hứa và tưởng đó là hàng đã giao.

Sự thật chỉ lộ ra khi tôi bắt đầu ghi dần vào từng trang, in VmRSS mỗi 50 MB:

malloc 2 GB -> THÀNH CÔNG (VmRSS ~1 MB)
ghi 100 MB -> VmRSS ~102 MB
ghi 300 MB -> VmRSS ~309 MB
ghi 500 MB -> VmRSS ~502 MB
Killed          <-- tiến trình bị giết, mã thoát 137

VmRSS bám sát lượng đã ghi gần như một-đối-một (ghi 500 MB thì RSS ~502 MB), và khi RSS chạm mức ~512 MB — đúng bằng cái cap cgroup — OOM killer ra tay giết tiến trình, mã thoát 137 (128 + tín hiệu 9, SIGKILL). Nó chết khi mới ghi được 500 MB, còn cách rất xa 2 GB mà nó đã "cấp phát thành công".

Bài học đo lường: cấp phát thành công dưới overcommit chỉ là một lời hứa, không phải RAM đã giao — và số đo báo "thành công" là con số nói dối về khả năng thật. Tôi đã tin malloc trả về và Committed_AS, hai bộ đếm, và chúng lừa tôi: chúng đo lời hứa, không đo hiện thực. Chỉ khi tôi hành động thật — ghi trang, quan sát đủ lâu tới lúc tiến trình bị giết — cái đồng hồ của thực tế mới nói thật: giới hạn thật nằm ở 512 MB, không phải ở con số 2 GB đã xin hay 4,87 GB của CommitLimit. Một lần nữa, tin đồng hồ hơn bộ đếm.

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

Hệ quả đầu tiên là đừng tin malloc trả về non-NULL nghĩa là bạn đã có bộ nhớ. Trên Linux với overcommit bật (mặc định ở gần như mọi bản phân phối), thói quen "kiểm tra malloc trả về NULL để bắt hết bộ nhớ" gần như vô dụng: malloc hiếm khi trả NULL vì nó chỉ hứa. Cái chết thật đến muộn hơn, dưới dạng OOM killer bắn một SIGKILL ở giữa chừng khi bạn đang ghi — một tín hiệu không bắt được, không dọn dẹp được, không có stack trace. Chương trình của bạn không "hết bộ nhớ một cách lịch sự"; nó bị bắn gục. Vì thế các hệ thống nghiêm túc thường theo dõi RSS thật và đặt giới hạn cgroup rõ ràng, thay vì trông cậy vào giá trị trả về của malloc.

Hệ quả thứ hai là OOM killer là một biến ẩn cần biết mặt. Khi một tiến trình bị giết đột ngột không rõ lý do (mã thoát 137, hay biến mất lặng lẽ), thủ phạm rất thường là OOM killer, không phải một lỗi trong code. Và nó chọn nạn nhân theo một điểm số (oom_score, dựa nhiều vào RSS), nên kẻ bị giết chưa chắc là kẻ vừa xin quá nhiều — nó có thể bắn tiến trình to nhất trên máy, một dịch vụ vô can đang ngồi yên. Đây là lý do một cú rò rỉ bộ nhớ ở dịch vụ A có thể làm dịch vụ B "tự nhiên" chết. Đọc dmesg (hay log OOM của cgroup) là cách xác nhận, thay vì đoán mò trong code ứng dụng.

Hệ quả thứ ba là CommitLimit/Committed_AS không phải lúc nào cũng là giới hạn thật — phải biết mình đang ở chế độ nào. Dưới mode=1 (khá phổ biến trong container và VM), cái trần đó bị bỏ qua hoàn toàn, và giới hạn thật lại đến từ chỗ khác: cap cgroup, hoặc đơn giản là lượng RAM còn trống khi bạn chạm trang. Muốn cấp phát thất bại sớm và lịch sự (trả NULL ngay lúc xin thay vì bị giết lúc ghi), bạn phải đặt mode=2 — nhưng đổi lại nhiều chương trình quen xin dư sẽ gãy. Con số mang theo: overcommit khiến malloc 512 GB trên máy 8 GB vẫn báo thành công, Committed_AS vọt lên 526 GB không ai chặn — nhưng RAM thật chỉ trả khi GHI trang, và vượt giới hạn thật (cap 512 MB) thì OOM killer giết ngay với exit 137, còn xa lượng "đã cấp phát". Cấp phát là lời hứa; chỉ có cái chạm trang mới là sự thật.

Thử ba mươi giây

Trên một máy Linux (hay trong một container), chạy cat /proc/sys/vm/overcommit_memory để biết chế độ, rồi grep -E 'MemTotal|CommitLimit|Committed_AS' /proc/meminfo để thấy trần và mức đã hứa. Sau đó viết vài dòng C xin một lượng lớn hơn RAM mà không chạm — char *p = malloc(100L*1024*1024*1024); rồi sleep — và quan sát trong top: cột VIRT phồng lên cả trăm GB trong khi RES gần như bằng 0, cấp phát vẫn thành công. (Đừng thử bằng bytearray của Python hay calloc có chạm: chúng ghi 0 nên cấp RAM thật ngay.) Muốn thấy mặt OOM killer, chạy một tiến trình ghi dần vào vùng lớn dưới một cap nhỏ — docker run --memory=256m ... — và xem nó bị giết đúng ở cái cap, mã thoát 137. Đó chính là khoảng cách giữa "đã hứa" và "có thật" mà bài này đo.