Suốt phần bộ nhớ, ta thấy hệ điều hành cấp phát lười và cache mọi thứ. I/O file cũng vậy, và cơ chế đó là một trong những tối ưu quan trọng nhất của cả hệ điều hành: page cache. Câu hỏi thực tế: khi chương trình đọc một file, nó có luôn chạm đĩa không? Trực giác nói có — file nằm trên đĩa, đọc file thì phải đọc đĩa, mà đĩa thì chậm. Sai. Tôi đo trong container gcc:13 (ARM), so đọc buffered (thường) ấm/nguội với O_DIRECT, và con số cho thấy vì sao "đọc file" thường không chạm đĩa chút nào.
Page cache: tầng RAM giấu đĩa
Khi bạn read() một file theo cách thường (buffered), dữ liệu không đi thẳng từ đĩa vào chương trình. Nó đi qua page cache: nhân giữ các trang file đã đọc trong RAM. Lần đọc đầu tiên (cache nguội) phải nạp từ đĩa vào cache — chậm, giới hạn bởi tốc độ đĩa. Nhưng mọi lần đọc sau (cache ấm) được phục vụ từ RAM — chỉ là một phép copy bộ nhớ từ cache sang buffer của bạn, không hề chạm đĩa. Nhân tự quản cache này: giữ trang nóng, đẩy trang nguội ra khi cần RAM, hoàn toàn trong suốt với chương trình.
Ngược lại, O_DIRECT bảo nhân bỏ qua page cache: đọc thẳng từ thiết bị vào buffer của bạn, không qua tầng RAM trung gian. Nghe như "gần phần cứng hơn nên nhanh hơn", nhưng đo cho thấy điều ngược lại.
Đo: cache ấm 39 GB/s, O_DIRECT chậm nhất
Tôi tạo một file 64 MB, rồi đọc nó theo ba cách — buffered ấm (ngay sau khi ghi, còn trong cache), buffered nguội (sau khi đẩy khỏi cache bằng posix_fadvise(DONTNEED)), và O_DIRECT:
BUFFERED ấm (page cache) : lần 1 34,7 GB/s | lần 2 39,1 GB/s
BUFFERED nguội (fadvise) : 9,2 GB/s
O_DIRECT (bỏ qua cache) : 5,5 GB/s
-> ấm / nguội = 4,3×
Đọc buffered ấm đạt 39 GB/s — tốc độ của bộ nhớ, không phải đĩa. Vì dữ liệu nằm sẵn trong page cache (RAM) sau lần đọc đầu, read() chỉ copy từ RAM sang RAM. Không một byte nào chạm đĩa ở lần thứ hai. Đọc nguội (sau khi đẩy khỏi cache) chỉ 9,2 GB/s — phải nạp lại từ tầng lưu trữ nền. Page cache làm đọc lại nhanh hơn 4,3 lần.
O_DIRECT là chậm nhất — 5,5 GB/s — vì nó cố ý bỏ qua page cache. Không có tầng RAM giấu độ trễ, mỗi lần đọc đi thẳng xuống thiết bị. Nó chậm hơn buffered, không nhanh hơn.
Cần thành thật một điểm quan trọng: đây là container trên máy chủ có lưu trữ nền rất nhanh (SSD/cache của host). Nên "nguội" 9,2 GB/s ở đây vẫn nhanh bất thường — trên một đĩa quay thật, đọc nguội có thể chỉ ~0,1 GB/s, và lợi thế của cache ấm sẽ là hàng trăm lần, không phải 4,3 lần. Con số ở đây minh họa hướng và cơ chế; khoảng cách thật trên phần cứng chậm còn lớn hơn nhiều.
Một lần tôi đo hớ: O_DIRECT không phải "nhanh hơn"
Tôi vào đo với hai niềm tin ngược nhau mà cùng sai. Thứ nhất, của người mới: "đọc file thì luôn chạm đĩa nên chậm". Sai — lần đọc thứ hai chạy ở 39 GB/s, tốc độ RAM, vì page cache. Phần lớn I/O đọc trong một hệ thống thật (đọc lại config, thư viện, dữ liệu nóng) không bao giờ chạm đĩa sau lần đầu.
Thứ hai, tinh vi hơn, mà tôi suýt mắc: "O_DIRECT bỏ qua tầng trung gian nên gần phần cứng, chắc nhanh hơn". Đo bác bỏ ngay — O_DIRECT là chậm nhất (5,5 GB/s), vì nó vứt đi chính cái tầng RAM đang giấu độ trễ đĩa. O_DIRECT không phải một nút "tăng tốc"; nó là một công cụ chuyên dụng cho trường hợp bạn không muốn nhân cache — điển hình là cơ sở dữ liệu tự quản buffer pool riêng và không muốn dữ liệu bị cache hai lần (một ở buffer pool của nó, một ở page cache của nhân), lãng phí RAM. Với gần như mọi chương trình khác, page cache của nhân là bạn, không phải kẻ cản đường.
Bài học đo lường: hiểu một tầng cache làm gì trước khi bỏ qua nó — "gần phần cứng hơn" không đồng nghĩa "nhanh hơn". Nếu tôi tin O_DIRECT nhanh vì "ít tầng", tôi đã làm chậm chương trình 7 lần so với buffered ấm. Và nếu tôi tin "đọc file luôn chậm vì đĩa", tôi đã tối ưu nhầm chỗ — thêm cache thủ công cho thứ mà nhân đã cache sẵn. Page cache là một trong những lý do lớn nhất khiến hệ thống thật nhanh hơn nhiều so với "mỗi thao tác đọc = một lần chạm đĩa".
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: đừng cho rằng "đọc file" nghĩa là "chạm đĩa". Dữ liệu đọc thường xuyên nằm trong page cache và được phục vụ ở tốc độ RAM. Khi đo hiệu năng I/O, phân biệt rõ cold (lần đầu, chạm đĩa) với warm (đã cache) — trộn hai cái cho ra con số vô nghĩa. Nếu benchmark của bạn đọc cùng file nhiều lần, bạn đang đo tốc độ page cache, không phải tốc độ đĩa.
Hệ quả thứ hai: chỉ dùng O_DIRECT khi bạn có lý do cụ thể — thường là tự quản cache. Cơ sở dữ liệu, một số hệ thống lưu trữ, muốn kiểm soát chính xác cái gì trong RAM nên bỏ qua page cache để tránh cache kép. Với ứng dụng thường, O_DIRECT chỉ làm chậm và phức tạp (buộc căn chỉnh buffer theo khối, kích thước bội của sector). Để nhân cache — nó làm việc đó rất giỏi.
Hệ quả thứ ba là tinh thần đo lường: một tầng cache trong suốt có thể là thứ nhanh nhất trong hệ thống — biết nó tồn tại trước khi tối ưu quanh nó. Con số mang theo: đọc file buffered đi qua page cache — lần đọc lại (ấm) phục vụ từ RAM ở 39 GB/s, KHÔNG chạm đĩa, nhanh 4,3× đọc nguội (9,2 GB/s, phải nạp lại từ nền); O_DIRECT cố ý bỏ qua page cache nên CHẬM nhất (5,5 GB/s) — "gần phần cứng" không phải "nhanh hơn", nó chỉ đáng khi bạn tự quản cache (CSDL) để tránh cache kép. (Lưu ý trung thực: đĩa nền container nhanh nên khoảng cách nguội/ấm ở đây nhỏ hơn nhiều so với đĩa thật.)
Thử ba mươi giây
Trên một máy Linux, chạy free -h và nhìn cột buff/cache — con số đó là dung lượng RAM nhân đang dùng làm page cache cho các file gần đây (thường là phần lớn RAM "đang dùng" trên một server bận). Rồi thử: time cat file-lớn > /dev/null hai lần liên tiếp — lần đầu (cold) chậm hơn hẳn lần hai (warm, từ cache). Nếu muốn thấy hiệu ứng rõ hơn, xóa cache giữa hai lần bằng echo 1 | sudo tee /proc/sys/vm/drop_caches. Ba mươi giây đó cho bạn thấy tận mắt điều mà "đọc file = đọc đĩa" giấu đi: hệ điều hành đã biến phần lớn RAM rảnh thành một cache đĩa khổng lồ, và phần lớn thao tác đọc của bạn thật ra chạm RAM, không phải đĩa.