Bài trước về streaming JSON, ta thấy os.ReadFile nạp toàn bộ tệp vào bộ nhớ. Với tệp 200 MB, đó là 200 MB nằm trong heap Go — và bộ thu gom rác (GC) phải quản lý khối đó, quét qua nó mỗi chu kỳ. Có một cách đọc tệp lớn hoàn toàn khác, ở tầng hệ điều hành: memory-mapped file (mmap).

Thay vì sao chép nội dung tệp vào heap, mmap ánh xạ tệp thẳng vào không gian địa chỉ của tiến trình. Nó trả về một slice byte trỏ vào vùng nhớ ảo; khi bạn chạm tới một vị trí, hệ điều hành nạp trang (mỗi trang 4KB) chứa nó theo nhu cầu — không sao chép trước, không chiếm heap Go. Bài này dựng mmap thật trong Go, đo khác biệt bộ nhớ và tốc độ so với đọc thường, và nêu rõ những rủi ro của nó.

mmap khác đọc thường thế nào

// os.ReadFile: sao chép TOÀN BỘ nội dung tệp vào heap Go.
// mmap: ánh xạ tệp thẳng vào KHÔNG GIAN ĐỊA CHỈ tiến trình.
// Trả về một slice byte trỏ vào vùng nhớ ảo; HĐH nạp TRANG
// (4KB) theo NHU CẦU khi bạn chạm tới - không copy trước, không heap.

Khác biệt cốt lõi: ReadFile là một hành động sao chép (từ tệp vào RAM của heap), còn mmap là một hành động ánh xạ (cho tệp và bộ nhớ ảo dùng chung page cache của hệ điều hành). Bạn không copy gì cả — bạn nhìn thẳng vào cache của HĐH.

Ánh xạ tệp bằng unix.Mmap

f, _ := os.Open(path)
data, _ := unix.Mmap(int(f.Fd()), 0, int(size),
	unix.PROT_READ, unix.MAP_SHARED) // chỉ đọc, chia sẻ
defer unix.Munmap(data) // gỡ ánh xạ khi xong

unix.Mmap (trong golang.org/x/sys/unix) nhận file descriptor, offset, độ dài, cờ bảo vệ (PROT_READ = chỉ đọc) và cờ chia sẻ. Nó trả về một []byte mà mỗi phần tử ứng với một byte trong tệp. Munmap gỡ ánh xạ — phải nhớ gọi, vì đây là tài nguyên hệ điều hành, không phải bộ nhớ Go mà GC tự dọn.

Đọc: truy cập slice như bình thường

for i := 0; i < N; i++ {
	v := uint32(data[i*4]) | uint32(data[i*4+1])<<8 |
	     uint32(data[i*4+2])<<16 | uint32(data[i*4+3])<<24
	tong += uint64(v)
}

Từ góc nhìn code Go, data chỉ là một slice byte bình thường. Nhưng bên dưới, lần đầu bạn chạm data[k], nếu trang chứa nó chưa trong RAM, CPU sinh một page fault và HĐH nạp đúng trang đó vào page cache. Bạn không bao giờ nạp phần tệp mình không đọc tới — đây là tính lười (lazy) mạnh mẽ của mmap.

Ảnh chụp đoạn mã Go nền tối minh hoạ memory-mapped file mmap trong Go đọc tệp lớn không qua heap, mmap là gì và khác đọc thường thế nào os.ReadFile sao chép toàn bộ nội dung tệp vào heap Go mmap ánh xạ tệp thẳng vào không gian địa chỉ tiến trình trả về một slice byte trỏ vào vùng nhớ ảo HĐH nạp trang 4KB theo nhu cầu khi bạn chạm tới không copy trước không heap, ánh xạ tệp bằng unix Mmap f bằng os Open path data bằng unix Mmap int f Fd 0 int size unix PROT_READ unix MAP_SHARED chỉ đọc chia sẻ defer unix Munmap data gỡ ánh xạ khi xong data giờ là slice byte trỏ vào tệp đọc như slice bình thường, đọc truy cập slice như bình thường for i nhỏ hơn N v bằng uint32 data i nhân 4 hoặc uint32 data i nhân 4 cộng 1 dịch 8 hoặc uint32 data i nhân 4 cộng 2 dịch 16 hoặc uint32 data i nhân 4 cộng 3 dịch 24 tong cộng uint64 v chạm data k lần đầu page fault HĐH nạp trang chứa nó không bao giờ nạp phần tệp bạn không đọc tới, vì sao nhanh và nhẹ heap zero-copy không sao chép cả tệp vào heap đọc thẳng từ page cache lười HĐH nạp trang theo nhu cầu đọc một phần tệp GB vẫn rẻ chia sẻ nhiều tiến trình mmap cùng tệp dùng chung page cache ngoài heap dữ liệu không nằm trong heap Go không tạo áp lực GC

Hình 1: mmap ánh xạ tệp vào không gian địa chỉ (không sao chép vào heap); đọc như slice byte bình thường nhưng HĐH nạp trang 4KB theo nhu cầu qua page fault — zero-copy, lười, ngoài heap Go.

Đo thật: 200 MB, +191 MB hay +0 MB heap

Tạo tệp nhị phân 200 MB (50 triệu uint32), đọc và tổng tất cả hai cách, đo HeapAlloc tăng thêm và thời gian:

Cách         heap tăng thêm  thời gian đọc  kết quả
os.ReadFile  +191 MB         125 ms         1249999975000000
mmap         +0 MB           31 ms          1249999975000000

Hai cách CÙNG kết quả: đúng
mmap: heap ít hơn 191 MB, nhanh hơn ~4 lần

Khác biệt rất lớn trên cả hai trục:

  • Heap: +191 MB so với +0 MB. os.ReadFile sao chép cả 200 MB tệp vào heap Go, nên heap phình +191 MB — và toàn bộ khối đó giờ là gánh nặng cho GC. mmap để dữ liệu nằm trong page cache của HĐH ngoài heap Go, nên heap Go gần như không đổi. Với tệp 200 MB hay 20 GB, heap Go vẫn gần 0 — HĐH lo phần nạp trang.
  • Tốc độ: 125 ms so với 31 ms (~4 lần). mmap nhanh hơn vì nó tránh bước sao chép cả tệp từ page cache sang heap; nó đọc thẳng từ page cache. (Cả hai cho cùng kết quả 1.249.999.975.000.000, nên đây là so sánh công bằng.)

Ảnh chụp bảng kết quả đo thật nền tối memory-mapped file mmap trong Go chạy bằng go run Go 1.23 arm64 file nhị phân 200 MB 50 triệu uint32 HeapAlloc, đọc và tổng 50 triệu uint32 từ file 200 MB os.ReadFile heap tăng thêm cộng 191 MB thời gian đọc 125 ms kết quả 1249999975000000 mmap heap tăng thêm cộng 0 MB thời gian đọc 31 ms kết quả 1249999975000000 hai cách cùng kết quả đúng mmap heap ít hơn 191 MB nhanh hơn khoảng 4 lần, vì sao khác biệt lớn thế os.ReadFile copy cả 200MB tệp heap Go cộng 191 MB heap cộng tốn copy mmap ánh xạ tệp đọc thẳng page cache cộng 0 MB heap không copy mmap tránh cả bước sao chép lẫn áp lực GC lên heap Go file 200MB hay 20GB heap Go vẫn gần 0 HĐH lo phần nạp trang, cốt lõi mmap ánh xạ tệp vào không gian địa chỉ đọc như slice byte lười HĐH nạp trang 4KB theo nhu cầu không copy cả tệp đo thật 200MB heap cộng 191 MB 125ms ReadFile tới cộng 0 MB 31ms mmap ngoài heap dữ liệu ngoài heap Go không áp lực GC hợp tệp cực lớn rủi ro chạm vùng lỗi tệp bị cắt SIGBUS làm sập không phải error đánh đổi truy cập ngẫu nhiên rải rác gây nhiều page fault ghi phức tạp

Hình 2: Đo thật đọc tệp 200 MB (50 triệu uint32) — os.ReadFile tốn +191 MB heap và 125ms, mmap chỉ +0 MB heap và 31ms (~4 lần nhanh hơn), cùng kết quả; mmap tránh cả sao chép lẫn áp lực GC vì dữ liệu nằm ngoài heap Go.

Đánh đổi cần cân nhắc

Chạm vùng lỗi gây SIGBUS làm sập, không phải error. Đây là rủi ro nguy hiểm nhất của mmap. Nếu tệp bị cắt ngắn (truncate) sau khi bạn ánh xạ, hoặc một trang không đọc được từ đĩa, việc chạm vào vùng đó sinh tín hiệu SIGBUS — làm sập chương trình, không trả về một error Go mà bạn có thể xử lý. Đọc thường thì lỗi I/O trả về error đàng hoàng. Với mmap, bạn phải chắc tệp không đổi trong lúc ánh xạ, hoặc chấp nhận rủi ro sập.

Truy cập ngẫu nhiên rải rác gây nhiều page fault. mmap tỏa sáng khi bạn đọc tuần tự hoặc đọc một phần tệp lớn. Nhưng nếu bạn nhảy loạn xạ khắp một tệp khổng lồ, mỗi lần chạm một trang mới là một page fault — và nếu tổng dữ liệu chạm tới vượt RAM vật lý, HĐH phải liên tục nạp/đẩy trang (thrashing), có thể chậm hơn cả đọc thường. mmap không phải phép màu; nó dịch chuyển việc quản lý bộ nhớ sang HĐH, và mẫu truy cập của bạn quyết định nó thắng hay thua.

Ghi qua mmap phức tạp và ít khả chuyển. Bài này chỉ đọc (PROT_READ). Ghi qua mmap (PROT_WRITE, MAP_SHARED) thêm nhiều tinh tế: khi nào dữ liệu thực sự xuống đĩa (msync), đồng bộ với các tiến trình khác, và hành vi khác nhau giữa các HĐH. Và mmap là API Unix — trên Windows phải dùng CreateFileMapping/MapViewOfFile khác hẳn, nên code mmap không tự nhiên khả chuyển (cross-platform); thường phải dùng thư viện bọc như golang.org/x/exp/mmap hoặc build-tag theo OS.

Ba ý mang về

  1. mmap ánh xạ tệp vào không gian địa chỉ thay vì sao chép vào heap: đọc như slice byte bình thường nhưng HĐH nạp trang theo nhu cầu — đo thật, đọc tệp 200 MB tốn +191 MB heap với os.ReadFile nhưng +0 MB với mmap, và nhanh hơn ~4 lần (125ms → 31ms), cùng kết quả.
  2. Lợi ích cốt lõi là zero-copy và ngoài heap Go: dữ liệu nằm trong page cache của HĐH, không tạo áp lực GC — hợp cho tệp cực lớn và đọc một phần (tệp 200MB hay 20GB heap Go vẫn gần 0).
  3. Rủi ro thật sự phải cân nhắc: chạm vùng lỗi/tệp bị cắt gây SIGBUS làm sập chứ không trả error, truy cập ngẫu nhiên rải rác gây nhiều page fault (có thể thrashing khi vượt RAM), và ghi qua mmap phức tạp lẫn ít khả chuyển — mmap không phải mặc định, chỉ dùng khi mẫu truy cập hợp.

Phần sau ta tự cài một cấu trúc dữ liệu xác suất tiết kiệm bộ nhớ đáng kinh ngạc: Bloom filter trong Go — kiểm "phần tử có thể có trong tập" bằng vài bit, đo thật tỉ lệ dương tính giả và bộ nhớ so với set thường.