Hình dung một cô lễ tân hay được hỏi cùng vài câu. Lần đầu ai đó hỏi, cô mở cuốn sổ tra cứu dày cộp ở kho — chậm. Nhưng sau đó cô nhớ câu trả lời, nên mọi lần hỏi sau cô đáp ngay từ trí nhớ, không cần chạy vào kho lần nào nữa. Giờ tưởng tượng có người ra một quy định lạ: "cấm nhớ, mỗi lần hỏi phải mở lại sổ trong kho cho chuẩn nguồn". Nghe như "gần nguồn hơn thì đáng tin/nhanh hơn", nhưng thật ra nó chậm hơn hẳn — vứt đi cái trí nhớ đang giúp cô trả lời tức thì. Hệ điều hành đọc file y hệt cô lễ tân đó, và cái "quy định cấm nhớ" chính là O_DIRECT.
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, đúng lần cô lễ tân phải mở sổ. 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, đúng lúc cô trả lời từ trí nhớ. 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.
(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.)
Hai niềm tin đối nghịch, cùng sai
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. Hai lầm tưởng kẹp hai đầu một sự thật: page cache là tầng nhanh nhất trong đường đọc, và hiểu nó làm gì trước khi bỏ qua mới là điều quan trọng — "gần phần cứng hơn" không đồng nghĩa "nhanh hơn". O_DIRECT không phải nút tăng tốc; nó là 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: tin O_DIRECT nhanh vì "ít tầng" là tự làm chậm mình 7 lần, còn tin "đọc file luôn chậm vì đĩa" là tối ưu nhầm chỗ — thêm cache thủ công cho thứ nhân đã cache sẵn.
Về đến code hằng ngày
Điều đầ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 (đúng cái bẫy "đo không công bằng" xuyên suốt).
Điều 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.
Chỗ này chính là một trong những khác biệt triết lý sâu nhất giữa các database, đáng biết. PostgreSQL cố tình dựa vào page cache của OS: nó giữ shared_buffers vừa phải và để nhân cache phần còn lại — đơn giản, tận dụng cái cache nhân làm rất tốt. MySQL/InnoDB thì ngược lại, mặc định O_DIRECT (innodb_flush_method) để tránh cache kép: nó tin buffer pool riêng của nó quản dữ liệu tốt hơn, nên không muốn nhân cache lần hai tốn RAM — đúng cái lý do chuyên dụng ở trên. Cả hai đều đúng, chỉ khác lựa chọn "ai quản cache". Ở tầng vận hành, đây là lý do free -h trên một server bận cho thấy phần lớn RAM nằm ở buff/cache (page cache), và vì sao "RAM đầy" thường không đáng lo — nhân sẽ nhả cache khi ứng dụng cần. Còn với ứng dụng thường (Java, Go, script), gần như không bao giờ bạn cần O_DIRECT: để nhân cache là lựa chọn đúng.
Muốn tự thấy thì 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). Muốn thấy rõ hơn, xóa cache giữa hai lần bằng echo 1 | sudo tee /proc/sys/vm/drop_caches. Vài phút đó 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.