Khi RAM cạn, hệ điều hành không đầu hàng ngay — nó đẩy bớt các trang ít dùng ra swap (một vùng trên đĩa hay SSD) để lấy chỗ cho việc gấp hơn. Cái giá phải trả đến sau: lần tới chương trình chạm vào một trang đã bị đẩy đi, nó vấp một lỗi trang nặng (major page fault) — nhân phải nạp trang từ swap về RAM trước khi chương trình đọc được. Bài này đo trực tiếp cái giá đó bằng cách tự đẩy bộ nhớ của chính mình ra swap rồi đọc lại — và vấp đúng một lần mà mọi bộ đếm đều nói "chẳng có gì xảy ra" trong khi đồng hồ nói ngược lại.
Swap và lỗi trang nặng
Ở bài về bộ nhớ ảo và RSS ta thấy RAM thật (RSS) chỉ được cấp khi ta ghi vào trang. Nhưng RAM có hạn. Khi tổng nhu cầu vượt RAM, nhân chọn các trang ẩn danh (anonymous — heap, stack, vùng mmap riêng) ít được đụng tới và ghi chúng ra swap, rồi thu hồi khung RAM đó. Trang vẫn "tồn tại" trong không gian địa chỉ ảo của tiến trình, nhưng nội dung của nó giờ nằm trên đĩa.
Lần sau tiến trình đọc trang đó, phần cứng thấy nó không có trong RAM và bắn một lỗi trang. Khác với lỗi trang thường (nhẹ) — chỉ cần nhân nối một khung RAM có sẵn — đây là lỗi trang nặng: nhân phải phát một lệnh đọc đĩa để lấy nội dung trang từ swap về, rồi mới cho tiến trình chạy tiếp. Đọc đĩa chậm hơn đọc RAM hàng trăm tới hàng nghìn lần, nên một lỗi trang nặng là một trong những khoản phạt độ trễ lớn nhất mà một chương trình bình thường có thể vấp. Câu hỏi tôi muốn đo: cụ thể nó chậm hơn bao nhiêu?
Đo: nạp lại từ swap chậm hơn RAM 58 lần
Ép RAM cạn tự nhiên trong container hóa ra không đáng tin — lần đầu tôi thử tạo áp lực bộ nhớ bằng giới hạn cgroup, nhưng VmSwap gần như đứng yên và bộ đếm sự kiện swap của cgroup báo fail: swap không thực sự chạy như tôi tưởng, và mọi con số "độ trễ" tính ra chỉ là nhiễu từ vài lỗi trang tình cờ. Nên tôi chuyển sang cách chủ động và kiểm chứng được: madvise(MADV_PAGEOUT) — bảo nhân đẩy chính vùng nhớ của tôi ra swap, ngay lập tức, không cần ép cả máy vào cảnh thiếu RAM.
Tôi mmap 300 MB, ghi hết, rồi gọi MADV_PAGEOUT lên đúng vùng đó. Trước khi tin bất cứ con số nào, tôi xác minh swap đã thật sự xảy ra:
ĐẨY RA: RSS 301 -> 1 MB | VmSwap 0 -> 298 MB | vmstat pswpout +76.500 trang
RSS tụt từ 301 xuống 1 MB, VmSwap vọt lên 298 MB, và bộ đếm toàn cục pswpout (pages swapped out) tăng đúng 76.500 trang. Không nghi ngờ gì: 300 MB đã rời RAM và nằm trên swap. Giờ mới đo việc đọc lại:
| Thao tác | Đồng hồ |
|---|---|
| Đọc lại (swap-in) | 704 ns / trang |
| Đọc RAM (lần 2, đã về) | 12 ns / trang |
Đọc lại một trang vừa bị đẩy ra swap tốn 704 ns; đọc lại lần hai (khi trang đã nằm sẵn trong RAM) chỉ 12 ns. Nạp một trang từ swap chậm hơn đọc RAM khoảng 58 lần. Và con số 704 ns rất ổn định qua ba lần đo (698, 709, 704) — đây là cái giá thật, có thể tin. Sau khi đọc xong, VmSwap tụt lại về 0: các trang đã được nạp hết về RAM, đúng như một chu trình swap-out rồi swap-in trọn vẹn.
Một lần tôi đo hớ: bộ đếm nói không, đồng hồ nói có
Đây là chỗ suýt làm tôi vứt cả phép đo. Bên cạnh đồng hồ, tôi cũng đọc hai bộ đếm mà sách vở luôn dùng để phát hiện swap: ru_majflt (số lỗi trang nặng, từ getrusage) và pswpin (pages swapped in, từ /proc/vmstat). Cả hai đều báo 0:
ĐỌC LẠI (swap-in): 704 ns/trang | ru_majflt = 0 | vmstat pswpin = 0 (!?)
Phản xạ đầu tiên của tôi: "không có lỗi trang nặng nào, không trang nào được swap-in — vậy là chẳng có gì được nạp từ swap cả, chắc phép đẩy hỏng rồi". Theo bộ đếm, sự kiện tôi định đo không tồn tại.
Nhưng hai chứng cứ khác cãi lại quyết liệt. Thứ nhất, VmSwap rõ ràng tụt từ 298 MB về 0 sau khi đọc — các trang thật sự rời swap và về RAM. Thứ hai, đồng hồ đo 704 ns/trang, chậm gấp 58 lần đọc RAM thường — có một cái giá thật, lớn, đang được trả. Hai số mâu thuẫn: bộ đếm nói "không có gì", đồng hồ và VmSwap nói "có, và đắt". Khi số đo mâu thuẫn như vậy, tôi đang đo nhầm — nhưng nhầm ở đâu?
Lời giải nằm ở môi trường tự dựng, một biến ẩn tôi phải nhớ: swap trên chiếc VM này là một tệp, mà các khối của tệp đó còn nóng trong bộ đệm của host (page cache, nhưng ở tầng máy ảo). Khi nhân "nạp lại từ swap", nó không phải phát một lệnh đọc đĩa thật và chờ — dữ liệu vẫn còn trong bộ đệm, nên nó lấy về gần như tức thì. Vì ru_majflt và pswpin chỉ đếm những lỗi trang phải chờ thiết bị I/O thật, còn ở đây không có cú chờ đĩa nào, nhân xếp chúng vào loại lỗi trang nhẹ và bộ đếm nặng vẫn đứng ở 0. Nhưng công việc nạp lại — tra bảng, tìm trang trong swap cache, dựng lại ánh xạ — vẫn tốn 704 ns mỗi trang.
Bài học đo lường, đúng câu tôi tự nhắc suốt sê-ri: tin đồng hồ hơn bộ đếm. Bộ đếm đo một định nghĩa hẹp (cú chờ đĩa), đồng hồ đo cái tôi thật sự quan tâm (mất bao nhiêu thời gian). Khi chúng cãi nhau, đồng hồ — kèm một chứng cứ độc lập như VmSwap — mới là trọng tài. Và có một hệ quả quan trọng: 58 lần này là sàn, đo trong điều kiện tốt nhất (khối swap còn nóng trong đệm). Trên một máy thật đang thiếu RAM, khối swap nguội, cùng thao tác này biến thành lỗi trang nặng thật, tốn hàng mili giây — chậm hơn RAM hàng nghìn lần. Đó là lúc "swap" trở thành cơn ác mộng mang tên thrashing.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là thrashing giết hiệu năng theo cách dốc đứng, không tuyến tính. Chừng nào vùng làm việc (working set) còn vừa RAM, chương trình chạy nhanh; vượt qua ngưỡng đó một chút, các trang nóng bắt đầu bị đẩy ra swap và nạp lại liên tục, mỗi lần một lỗi trang nặng. Hiệu năng không giảm dần đều — nó rơi tự do, vì mỗi truy cập từng tốn chục nano giây giờ tốn hàng nghìn lần hơn. Một dịch vụ "vẫn chạy" nhưng chậm kinh khủng và CPU thì rảnh (vì toàn ngồi chờ đĩa) là dấu hiệu kinh điển của thrashing. Cách chữa không phải thêm CPU mà là giảm vùng làm việc hoặc thêm RAM.
Hệ quả thứ hai là swap cho máy chủ và swap cho máy tính cá nhân là hai câu chuyện khác nhau. Trên laptop, swap giúp bạn mở nhiều tab hơn RAM cho phép, đổi lại đôi lúc khựng — chấp nhận được. Trên máy chủ phục vụ độ trễ thấp, một lỗi trang nặng bất ngờ mili giây giữa một request là tai họa; nhiều hệ thống vì thế tắt swap hoàn toàn (hoặc đặt vm.swappiness rất thấp) để nhân thà giết tiến trình bằng OOM còn hơn để nó âm thầm chậm đi hàng nghìn lần — thà chết nhanh và rõ hơn là ngắc ngoải khó chẩn đoán. Biết hệ thống của mình chọn triết lý nào là quan trọng.
Hệ quả thứ ba là giám sát swap bằng đúng chỉ số, và đừng tin một bộ đếm đơn lẻ. Con số đáng theo dõi không chỉ là "có swap hay không" (SwapTotal/SwapFree) mà là tốc độ swap-in/swap-out (pswpin/pswpout mỗi giây trong vmstat, hay si/so): swap đầy mà đứng yên thì vô hại, còn swap đang chảy liên tục mới là thrashing. Nhưng như phép đo này cho thấy, ngay cả các bộ đếm đó cũng có thể im lặng trong khi cái giá vẫn có thật — nên khi nghi ngờ, hãy đo độ trễ thực (đồng hồ) chứ đừng chỉ nhìn bộ đếm. Con số mang theo: nạp lại một trang từ swap chậm hơn đọc RAM khoảng 58 lần ngay cả khi khối swap còn nóng trong đệm (và hàng nghìn lần khi phải đọc đĩa thật) — dù bộ đếm ru_majflt và pswpin có thể báo 0, VmSwap và đồng hồ mới nói ra sự thật là trang đã bị đẩy đi rồi nạp lại. Swap là một lưới an toàn quý giá, nhưng chạm vào nó trong đường nóng là trả một cái giá độ trễ khổng lồ.
Thử ba mươi giây
Xem máy bạn đang swap bao nhiêu và có đang chảy không: 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 khác 0 dưới tải, máy đang thrashing và đó là nút thắt thật sự, không phải CPU. Xem một tiến trình cụ thể đang gửi bao nhiêu ra swap: grep VmSwap /proc/<pid>/status. Muốn cảm nhận cái giá tận tay, viết một chương trình mmap vài trăm MB, ghi hết, gọi madvise(ptr, len, MADV_PAGEOUT), rồi bấm giờ vòng đọc lại so với vòng đọc thứ hai — và luôn kiểm VmSwap để chắc chắn trang thật sự đã ra swap, đừng chỉ tin bộ đếm lỗi trang, đúng cái bẫy bài này vấp phải.