Hãy tưởng tượng hai cách mượn sách trong một thư viện. Cách thứ nhất: mỗi lần cần một trang, bạn ra quầy điền phiếu, thủ thư đi tìm, photo đúng trang đó rồi đưa bản sao cho bạn — thủ tục ấy lặp lại cho từng trang. Cách thứ hai: họ phát cho bạn một tấm thẻ vào thẳng kho, và bạn tự rút sách đọc ngay tại giá, không qua quầy, không bản sao nào. Cách hai nhanh hơn hẳn — nhưng có một cái bẫy: nếu ai đó rút mất quyển sách khỏi giá đúng lúc bạn với tay tới, bạn ngã ngửa giữa kho thay vì được thủ thư lịch sự báo "sách này không còn". mmap và pread đúng là hai cách mượn sách đó, và bài này đo cả cái nhanh lẫn cái bẫy.

mmap cho phép truy cập một tệp như thể nó là một mảng trong bộ nhớ. Ta đo xem việc đó đáng giá bao nhiêu, và đo cả những chỗ tôi không kết luận được.

Nhiều lần đọc nhỏ: 53 lần

Một triệu lần đọc 64 byte ở vị trí ngẫu nhiên trong tệp 512 MB đã nằm sẵn trong bộ đệm trang:

Cách Lần 1 Lần 2 Lần 3
pread() 1.019 ns 1.050 ns 914 ns
mmap + đọc bộ nhớ 19 ns 20 ns 16 ns

53 lần.

Điều quan trọng: không có lần đọc đĩa nào ở cả hai bên. Dữ liệu đã ở trong RAM. Toàn bộ khác biệt đến từ việc pread là một lời gọi hệ thống — cái quầy điền phiếu.

Khoảng 900 ns đó là giá của việc chuyển từ chế độ người dùng sang chế độ nhân và quay lại: lưu ngữ cảnh, kiểm tra tham số, tra bảng mô tả tệp, chép dữ liệu, quay ra. Với các biện pháp giảm nhẹ Spectre và Meltdown còn bật, cái giá này cao hơn so với trước năm 2018.

Với mmap, sau lần chạm đầu tiên, đọc một byte trong tệp là đúng một lệnh máy — không có nhân nào tham gia. Đây là lý do LMDB, LevelDB, Lucene, và cả ld.so khi nạp thư viện đều dùng mmap.

Đọc tuần tự: 2,6 lần

Đọc hết 512 MB của cùng tệp nóng đó:

Cách Ba lần đo (MB/s)
read, khối 4 KB 7.004 / 7.513 / 7.565
read, khối 64 KB 8.084 / 8.454 / 8.778
read, khối 1 MB 8.816 / 9.214 / 9.049
mmap 20.788 / 20.999 / 23.991

Tăng kích thước khối từ 4 KB lên 1 MB — tức là giảm số lời gọi hệ thống đi 256 lần — chỉ cải thiện 1,2 lần. Đổi sang mmap cải thiện 2,6 lần. Phần còn lại của khoảng cách là việc chép dữ liệu: read chép từ bộ đệm trang sang bộ đệm của bạn (thủ thư photo trang); mmap không chép gì, nó chỉ ánh xạ chính những trang đó vào không gian địa chỉ của tiến trình (bạn đọc thẳng sách trên giá).

MAP_POPULATE cho 19.318 / 26.685 / 25.215 MB/s — nằm trong dải của mmap thường. Với tệp đã nóng thì không có gì để nạp trước, nên kết quả này đúng như mong đợi.

Hai chỗ tôi không kết luận được

Tệp lạnh. Cùng phép đo read, xoá bộ đệm trước mỗi lần:

995 MB/s   1.969 MB/s   3.081 MB/s

Ba lần chênh nhau ba lần. Như phần 12 đã chỉ ra, drop_caches trong máy ảo không chạm tới bộ đệm của máy chủ bên dưới, nên "lạnh" không thật sự lạnh và mỗi lần lạnh một kiểu. Với dải rộng như vậy, mọi so sánh mmap – read trên tệp lạnh đều vô nghĩa. Tôi không đưa ra con số nào.

Ghi. So msync với write + fsync, 256 MB:

write   704   741   1.365 MB/s
mmap    696  1.145  1.133 MB/s

Hai dải trùm lên nhau. Không kết luận được.

Tôi ghi lại hai chỗ này thay vì bỏ đi, vì "không đo được" cũng là một kết quả — và nó ngăn người đọc trích một con số vốn không có ý nghĩa.

mmap không phải lựa chọn mặc định

Có ba cái giá thật, không hiện ra trong bảng nào ở trên.

Lỗi trang thay cho lời gọi hệ thống. Lần chạm đầu tiên vào mỗi trang tốn khoảng 990 ns (phần 10). Với tệp chỉ đọc một lần, mmap chỉ đổi chi phí read thành chi phí lỗi trang chứ không xoá nó.

Không có cách xử lý lỗi đọc. read trả về -1 và errno — thủ thư báo lịch sự. mmap thì lỗi I/O đến dưới dạng SIGBUS — một tín hiệu, giữa một lệnh truy cập bộ nhớ trông vô hại; bạn ngã ngửa giữa kho. Muốn xử lý phải bắt tín hiệu, và làm đúng chuyện đó rất khó. Đây là lý do các bên phát triển SQLite và PostgreSQL đưa ra để không dùng mmap cho tệp dữ liệu chính.

Tệp lớn hơn không gian địa chỉ, hoặc bị co lại. Trên hệ 64 bit thì kích thước không còn là vấn đề, nhưng nếu tệp bị cắt ngắn trong khi đang ánh xạ, chạm vào phần đã mất cũng cho SIGBUS — quyển sách bị rút khỏi giá.

Thêm một điểm về đo đạc: RSS của tiến trình dùng mmap bao gồm cả trang bộ đệm của tệp, nên nó trông như đang ăn rất nhiều RAM. Phần 11 đã chỉ ra tại sao con số đó không đọc được — và ở đây phải dùng PSS.

Khi nào dùng cái nào

Tình huống Nên dùng
Nhiều lần truy cập nhỏ, ngẫu nhiên, tệp nóng mmap
Cấu trúc dữ liệu tra cứu trên đĩa (chỉ mục, cây) mmap
Quét một lần từ đầu tới cuối read khối 64 KB – 1 MB
Cần xử lý lỗi I/O tử tế read
Ghi có đảm bảo bền vững write + fsync
Tệp có thể bị bên khác cắt ngắn read

Ba gợi ý cho nhân khi đã chọn mmap:

madvise(a, n, MADV_SEQUENTIAL);   /* doc truoc manh, bo trang da qua */
madvise(a, n, MADV_RANDOM);       /* tat doc truoc */
madvise(a, n, MADV_WILLNEED);     /* keo truoc vao bo dem */

Với truy cập ngẫu nhiên, MADV_RANDOM tránh việc nhân kéo về 128 KB cho mỗi lần chạm — đúng cái giá mà phần 20 đã đo ở read_ahead_kb.

Mẫu số chung: bỏ được lớp trung gian thì nhanh, nhưng cũng mất luôn chỗ báo lỗi

Cái lõi ở đây không riêng gì tệp: mmap nhanh vì nó bỏ qua một lớp trung gian (lời gọi hệ thống và bản sao) để chạm thẳng vào dữ liệu — và cái giá đúng là mất đi điểm mà lớp trung gian ấy vẫn dùng để kiểm tra và báo lỗi tử tế. Đánh đổi này lặp lại ở mọi tầng.

Trong thế giới Java, đây đúng là ByteBuffer trực tiếp / MappedByteBuffer so với đọc qua stream có kiểm tra: truy cập bộ nhớ ngoài heap (và sun.misc.Unsafe) né được việc chép và kiểm tra biên của JVM nên nhanh hơn, nhưng một chỉ số sai không ném IndexOutOfBoundsException lịch sự mà cho lỗi bộ nhớ thô — mất đúng cái lưới an toàn, y như SIGBUS thay cho errno.

Trong Go, cùng đánh đổi khi bạn dùng gói unsafe để bỏ qua bounds-checking, hay đọc thẳng qua syscall.Mmap: nhanh hơn đường an toàn, nhưng runtime không còn chen vào để chặn truy cập ngoài vùng, nên lỗi chuyển từ panic có thông báo thành hỏng bộ nhớ khó lần. Cùng lý do stdlib đường mặc định luôn là đường có kiểm tra.

Còn ở cơ sở dữ liệu, đây là khác biệt giữa đi qua tầng thực thi có kiểm tra ràng buộc/quyền và các đường "nhanh" bỏ qua nó: COPY khối lượng lớn nhanh hơn INSERT từng dòng vì bỏ qua nhiều kiểm tra mỗi-dòng; tắt synchronous_commit nhanh hơn vì bỏ qua chờ fsync — mỗi lần bỏ một lớp trung gian là được tốc độ nhưng mất một đảm bảo (ràng buộc kiểm muộn hơn, hoặc mất bền vững khi sập).

Mẫu số chung: các đường nhanh gần như luôn nhanh bằng cách bỏ qua một lớp trung gian — lời gọi hệ thống, bản sao, kiểm tra biên, hàng rào bền vững — và cái lớp đó chính là nơi lỗi vốn được phát hiện và báo cáo tử tế; bỏ nó đi thì lỗi không biến mất, nó chỉ chuyển thành dạng thô hơn, muộn hơn, khó lần hơn. Nên câu hỏi đúng khi chọn một đường nhanh không phải "nó nhanh hơn bao nhiêu" mà là "lớp tôi vừa bỏ qua có đang bắt lỗi gì cho tôi không, và nếu có thì tôi đã đủ sức tự bắt lỗi đó chưa" — vì tốc độ ấy đi kèm việc tự gánh phần an toàn mà lớp trung gian từng lo.

Muốn tự đo giá của một lời gọi hệ thống trên máy bạn:

cat > /tmp/sc.c <<'EOF'
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <time.h>
#include <sys/mman.h>
#include <sys/stat.h>
static double now(void){struct timespec t;clock_gettime(CLOCK_MONOTONIC,&t);
  return t.tv_sec+t.tv_nsec/1e9;}
int main(int c,char**v){
  int fd=open(v[1],O_RDONLY); struct stat st; fstat(fd,&st);
  char b[64]; volatile long s=0; int N=500000;
  double t=now();
  for(int i=0;i<N;i++){ if(pread(fd,b,64,(i*4096)%st.st_size)<0) return 1; s+=b[0]; }
  printf("pread : %6.0f ns\n",(now()-t)/N*1e9);
  char*a=mmap(0,st.st_size,PROT_READ,MAP_PRIVATE,fd,0);
  for(off_t i=0;i<st.st_size;i+=4096) s+=a[i];
  t=now();
  for(int i=0;i<N;i++) s+=a[(i*4096)%st.st_size];
  printf("mmap  : %6.0f ns  (s=%ld)\n",(now()-t)/N*1e9,s);
  return 0;
}
EOF
cc -O2 -o /tmp/sc /tmp/sc.c
dd if=/dev/zero of=/tmp/f.bin bs=1M count=256 2>/dev/null
cat /tmp/f.bin > /dev/null        # lam nong bo dem trang
/tmp/sc /tmp/f.bin
rm -f /tmp/f.bin

Dòng đầu là giá của một lời gọi hệ thống trên chính máy bạn. Nếu ứng dụng của bạn gọi read hàng triệu lần mỗi giây cho những mẩu dữ liệu nhỏ, nhân con số đó lên là biết bạn đang trả bao nhiêu cho việc đi vào nhân.

Phần sau: theo dõi I/O trong thực tế — iostat, iotop và cách đọc chúng cho đúng.