Ở bài về OOM killer ta thấy khi hết RAM, nhân giết một tiến trình. Nhưng nhiều hệ thống có một vùng đệm giữa "đủ RAM" và "bị giết": swap — dùng đĩa để giả làm bộ nhớ. Nghe như một bữa trưa miễn phí: RAM không đủ thì mượn đĩa, xong. Tôi đo trong container có swap, và con số cho thấy bữa trưa đó đắt đến kinh ngạc.
Swap mở rộng RAM bằng đĩa
Swap là một vùng trên đĩa (một phân vùng hay file) mà nhân dùng như phần nối dài của RAM. Cơ chế: khi RAM chật, nhân chọn những trang ít được dùng gần đây và đẩy chúng ra swap (swap out), giải phóng RAM thật cho những trang đang cần. Trang đã bị đẩy ra vẫn "thuộc về" tiến trình, nhưng nội dung nằm trên đĩa. Khi tiến trình chạm lại vào một trang như vậy, CPU sinh một lỗi trang lớn (major fault): nhân phải đọc trang từ đĩa về (swap in) rồi mới cho lệnh chạy tiếp.
Điểm mấu chốt là tốc độ: đọc từ RAM mất vài chục nano giây, đọc từ đĩa (kể cả SSD) mất hàng chục micro giây — chậm hơn hàng trăm tới nghìn lần. Nên swap chỉ "vô hình" chừng nào các trang bạn thật sự dùng — working-set (tập làm việc) — nằm gọn trong RAM. Vượt qua đó, mỗi lần truy cập có thể phải chờ đĩa. Tôi đo bằng một container giới hạn 100MB RAM cộng 100MB swap, chạy hai working-set: một cái vừa RAM, một cái vượt.
Đo: cùng một chương trình, hai thế giới
cgroup: 100MB RAM + 100MB swap
Working-set 80MB (VỪA trong 100MB RAM):
swap dùng = 0 MB
quét toàn bộ: 0 major fault | ~900.000 MB/s
Working-set 150MB (VƯỢT RAM, tràn 54MB ra swap):
RAM dùng = 99MB, swap dùng = 54MB
quét toàn bộ: ~9.000 major fault mỗi lần | ~600 MB/s
Với working-set 80MB, mọi thứ nằm trong RAM: swap không bị chạm tới (0 MB), quét toàn bộ không sinh một major fault nào, và tốc độ là ~900.000 MB/s — tốc độ RAM/cache thuần túy. Cùng chương trình đó với working-set 150MB rơi vào một thế giới khác: RAM đầy ở 99MB, 54MB thừa bị đẩy ra swap, và mỗi lần quét toàn bộ tốn ~9.000 major fault (kéo từng trang từ đĩa về), tốc độ rơi xuống ~600 MB/s. Chậm hơn khoảng 1.500 lần.
Đây là thrashing: khi working-set lớn hơn RAM, không thể giữ tất cả trong RAM cùng lúc, nên mỗi vòng quét lại phải đẩy trang này ra để kéo trang kia vào, xáo qua xáo lại giữa RAM và đĩa. Chương trình không sai gì, thuật toán không đổi — chỉ là dữ liệu nó động tới không vừa bộ nhớ thật, và hiệu năng rơi khỏi vực.
Một lần tôi đo hớ: đo dưới ngưỡng thì swap "miễn phí"
Đây là chỗ tôi suýt kết luận sai. Lần chạy đầu tiên của tôi dùng working-set 80MB, và kết quả tuyệt đẹp: 0 major fault, 900.000 MB/s, swap dùng 0. Nếu chỉ nhìn con số đó, tôi sẽ viết "container có 100MB RAM cộng 100MB swap, mà chương trình chạy đầy tốc độ, chẳng tốn gì — cứ cấp swap là yên tâm". Kết luận đó sai hoàn toàn, và nó sai vì tôi đo ở một điểm nằm dưới ngưỡng RAM, nơi swap chưa bao giờ bị chạm.
Sự thật chỉ lộ ra khi tôi đẩy working-set vượt RAM. Cái vực 1.500 lần hoàn toàn vô hình ở working-set 80MB, và hiện ra dữ dội ở 150MB. Biến ẩn quyết định tất cả là working-set có vượt RAM hay không — một ngưỡng, không phải một đường dốc thoải. Đo một điểm dưới ngưỡng rồi ngoại suy "swap không tốn gì" là cách chắc chắn để bất ngờ khi production chạm ngưỡng.
Bài học đo lường: một tài nguyên có thể trông miễn phí cho tới đúng cái điểm nó không còn miễn phí. Và về bản chất swap: nó không phải "thêm RAM". "Có 200MB tổng cộng" không giống "200MB nhanh như nhau" — 100MB trong RAM chạy ở tốc độ RAM, 100MB trong swap chạy ở tốc độ đĩa, chậm gấp nghìn lần. Swap là một vùng tràn chậm, không phải bộ nhớ nhanh mở rộng.
Không phải trang nào cũng cần swap
Có một chi tiết đáng biết: swap chỉ cần cho một loại trang. Bộ nhớ của một tiến trình chia làm hai kiểu. Trang có nền file (file-backed) — mã chương trình, thư viện, file được mmap, và page cache — đã có một bản trên đĩa; khi RAM chật, nhân chỉ cần bỏ chúng khỏi RAM (nếu chưa sửa) và đọc lại từ file gốc khi cần, không cần swap. Chỉ có trang vô danh (anonymous) — bộ nhớ heap/stack do malloc, các biến, dữ liệu tính toán — là không có nền file để quay về, nên muốn đẩy chúng ra khỏi RAM thì bắt buộc phải ghi vào swap. Đó là lý do working-set trong phép đo (một mảng malloc — bộ nhớ vô danh) đi thẳng vào swap khi vượt RAM. Hệ quả thực tế: một máy không có swap vẫn có thể "lấy lại" RAM từ page cache và mã chương trình (chỉ cần bỏ đi, đọc lại từ đĩa) — nên tắt swap không có nghĩa là mọi áp lực bộ nhớ đều dẫn thẳng tới OOM; chỉ áp lực từ bộ nhớ vô danh mới không còn chỗ tràn và buộc nhân phải giết tiến trình.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: giữ working-set vừa RAM là ranh giới hiệu năng quan trọng nhất. Một dịch vụ đang chạy mượt bỗng chậm thảm hại thường là vì working-set vừa vượt qua RAM (dữ liệu lớn thêm, cache phình ra) và bắt đầu thrashing. Triệu chứng kinh điển: CPU tưởng như nhàn (vì tiến trình toàn chờ đĩa, không tính toán) nhưng ứng dụng chậm như rùa, và vmstat thấy cột si/so (swap in/out) tăng vọt. Cách chữa không phải thêm CPU, mà là giảm working-set (dùng ít bộ nhớ hơn) hoặc thêm RAM thật.
Hệ quả thứ hai: swap là con dao hai lưỡi, và mặc định của container thường tắt nó. Nối với bài OOM: không có swap, vượt RAM là bị OOM-kill ngay (chết nhanh); có swap, vượt RAM là thrashing (chết chậm, agony). Cả hai đều tệ, chỉ khác kiểu. Kubernetes theo mặc định tắt swap cho node chính vì lý do này: một pod thrashing làm chậm cả node một cách khó lường, nên người ta thà để nó bị OOM-kill dứt khoát (dễ phát hiện và tự khởi động lại) hơn là lê lết kéo cả cụm xuống. vm.swappiness chỉnh nhân hăng đẩy trang ra swap tới mức nào (0 = tránh tối đa, 100 = sẵn sàng), nhưng nó chỉ đổi khi nào bắt đầu swap, không đổi cái giá của swap.
Hệ quả thứ ba là bài học đo lường bao trùm: đo phải quét qua ngưỡng, không chỉ một điểm an toàn. Con số mang theo: swap dùng đĩa làm bộ nhớ dự phòng — working-set vừa RAM thì swap vô hình (0 major fault, tốc độ RAM), nhưng vượt RAM thì tràn ra swap và mỗi truy cập thành major fault kéo từ đĩa, chậm ~1.500 lần (600 so với 900.000 MB/s); swap cứu khỏi OOM-kill nhưng không phải "thêm RAM nhanh". Ranh giới hiệu năng nằm ở chỗ working-set gặp RAM, và đo lường phải chạm tới đó.
Thử ba mươi giây
Trên máy Linux, free -h cho bạn thấy dòng Swap — dung lượng và phần đã dùng. Rồi cat /proc/sys/vm/swappiness xem mức độ hăng swap (thường 60). Muốn thấy thrashing đang diễn ra, chạy vmstat 1 và nhìn hai cột si (swap in) và so (swap out): nếu chúng liên tục lớn hơn 0 khi máy bận, hệ thống đang swap — và nếu chúng cao trong khi CPU cột wa (wait) cũng cao, bạn đang thrashing. Với một tiến trình cụ thể, grep VmSwap /proc/<pid>/status cho biết nó có bao nhiêu bộ nhớ đang nằm trên swap — con số đó lớn nghĩa là mỗi lần nó chạm vùng đó phải trả giá đĩa, đúng cái chậm 1.500 lần mà bài này đo.