Bộ nhớ/mmap & copy-on-write — chia sẻ trang thông minh
19/26
Bài 19 / 26~16 phútBộ nhớ ảoMiễn phí lượt xem

mmap & copy-on-write — chia sẻ trang thông minh

mmap ánh xạ file vào địa chỉ ảo; copy-on-write cho phép fork tức thì bằng cách chia sẻ trang read-only đến khi có bên ghi. Cơ chế bên dưới và pitfall thực tế.

TL;DR: mmap ánh xạ một file (hoặc vùng ẩn danh) vào không gian địa chỉ ảo của tiến trình — bạn đọc/ghi file như thao tác con trỏ, OS tự nạp trang theo demand paging. Copy-on-write (COW) là kỹ thuật chia sẻ: khi fork(), cha và con dùng chung trang vật lý, đánh dấu read-only. Khi một bên ghi, page fault kích OS copy riêng trang đó. Nhờ vậy fork tiến trình 4 GB trả về gần tức thì — chỉ trả giá thật cho trang thực sự bị ghi sau đó. Hai cơ chế này là nền tảng của shared library, zero-copy I/O, và fork-based server.

fork() một tiến trình đang dùng 4 GB bộ nhớ. Bạn có thể nghĩ: OS phải copy 4 GB trước khi con chạy — chắc mất vài giây? Thực tế lời gọi trả về dưới 1 millisecond, và nếu con gọi exec() ngay (mẫu fork-exec cổ điển), hầu như không có trang nào được copy thật sự. Còn libc — thư viện C dùng chung của trăm tiến trình — chỉ tồn tại một bản vật lý trong RAM dù mỗi tiến trình thấy nó tại địa chỉ ảo của riêng mình.

Cả hai hiện tượng đó dựa trên cùng một nguyên tắc: chia sẻ trang vật lý qua lớp gián tiếp của bộ nhớ ảo, kết hợp với page fault làm cơ chế kích hoạt copy hoặc nạp theo nhu cầu.

1. Analogy — phòng họp chia sẻ và tài liệu photocopy khi cần

Hình dung một văn phòng với một phòng họp (bộ nhớ vật lý). Thay vì photo toàn bộ bộ tài liệu cho mỗi người trước buổi họp, mọi người đọc từ bản gốc chung. Khi ai đó muốn ghi chú riêng lên tài liệu, họ mới photo đúng trang đó và ghi vào bản riêng — người còn lại vẫn dùng bản gốc.

Văn phòngBộ nhớ máy tính
Tài liệu gốc trên bànTrang vật lý chia sẻ (shared physical page)
Nhiều người đọc cùng bảnNhiều tiến trình map cùng khung vật lý
"Chỉ đọc bản gốc"Trang đánh dấu read-only (write-protected)
Photo trang khi muốn ghiCopy-on-write: OS copy khi tiến trình ghi
Mang file từ kho vàommap: OS nạp trang từ file theo demand paging
💡 Cách nhớ

COW = "chia sẻ tới khi có ai ghi, rồi mới copy trang đó". mmap = "file trên đĩa cũng là bộ nhớ — đọc/ghi qua con trỏ, OS tự lo I/O". Cả hai đều tận dụng lớp gián tiếp của bộ nhớ ảo để tránh copy dữ liệu không cần thiết.

2. mmap — ánh xạ file vào không gian địa chỉ

mmap (memory-mapped file) là syscall cho phép ánh xạ nội dung file (hoặc vùng ẩn danh) vào một vùng địa chỉ ảo của tiến trình. Sau khi map, truy cập địa chỉ ảo đó tương đương đọc/ghi file — không cần read()/write() thủ công.

int fd = open("data.bin", O_RDONLY);
// Map file vao khong gian dia chi ao, chi doc
void *map = mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, fd, 0);
// Gio co the truy cap file nhu mang:
uint32_t val = ((uint32_t*)map)[index];
// Khi xong:
munmap(map, file_size);
close(fd);

Cơ chế bên dưới

Sau mmap, OS tạo một VMA (Virtual Memory Area) trong không gian địa chỉ tiến trình — dải địa chỉ ảo hợp lệ trỏ tới file. Nhưng chưa có trang nào được nạp vào RAM — PTE vẫn absent.

Khi tiến trình đọc byte đầu tiên trong vùng map:

  1. MMU thấy PTE absent → page fault.
  2. OS handler nhận fault, tra VMA, biết đây là fault trong vùng mmap'd file.
  3. OS đọc trang tương ứng từ file vào một khung vật lý (major fault).
  4. PTE cập nhật present = 1, trỏ vào khung.
  5. CPU retry — tiến trình đọc được dữ liệu.

Các trang kế tiếp nạp theo demand tương tự — chỉ khi thật sự truy cập.

mmap(file) → VMA tạo, PTE absent (chưa nạp)
↓ truy cập địa chỉ trong vùng map
Page fault → OS nạp trang từ file → PTE present = 1
↓ CPU retry → truy cập thành công
Trang trong page cache — chia sẻ giữa mọi tiến trình map cùng file

Ứng dụng thực tế của mmap

Shared library (.so/.dylib): khi nhiều tiến trình load libc, OS map cùng trang vật lý của code libc vào không gian địa chỉ ảo của mỗi tiến trình. 200 tiến trình dùng libc → chỉ 1 bản vật lý trong RAM. Nối với bài 01 — chia sẻ libc.

Executable loading: khi exec() chạy chương trình, OS không copy binary vào heap — nó mmap các segment (text, rodata, data) từ ELF file. Code chương trình được nạp theo demand paging khi CPU thực thi từng hàm lần đầu.

File lớn hơn RAM: bạn có thể mmap file 100 GB trên máy 16 GB RAM vì chỉ vùng đang truy cập mới cần trong RAM.

Anonymous mmap: malloc của nhiều allocator thực ra gọi mmap(MAP_ANONYMOUS) cho allocation lớn thay vì sbrk — linh hoạt hơn để trả bộ nhớ về OS sau khi free.

MAP_SHARED vs MAP_PRIVATE

FlagHành vi khi ghiDùng khi nào
MAP_SHAREDGhi phản ánh lên file và chia sẻ với tiến trình khác map cùng fileShared memory giữa process, ghi ngược về file
MAP_PRIVATEGhi dùng COW — tạo bản sao riêng, không ảnh hưởng fileĐọc file không muốn sửa; fork semantics
MAP_ANONYMOUSKhông liên kết file — vùng ẩn danh zero-filledHeap lớn, IPC với COW sau fork

3. Copy-on-write — fork rẻ nhờ chia sẻ trang

fork() trước COW

Trước khi COW phổ biến, fork() copy toàn bộ bộ nhớ tiến trình cha sang con — tốn thời gian và RAM tỉ lệ thuận với kích thước tiến trình. Server Apache cũ dùng fork mỗi request: tiến trình 100 MB → fork 100 MB → chậm.

Cơ chế COW

Với COW, khi fork():

  1. OS không copy bất kỳ trang dữ liệu nào.
  2. OS tạo một page table mới cho tiến trình con, nhưng mọi PTE trỏ vào cùng khung vật lý với cha.
  3. Cả hai page table đều đánh dấu các trang đó là read-only (write-protected).
  4. fork() trả về gần tức thì — chỉ mất thời gian copy page table, không phải dữ liệu.
flowchart TD
  subgraph Before["Truoc khi ghi"]
    FA["Tien trinh Cha<br/>PTE: khung #42 read-only"]
    CA["Tien trinh Con<br/>PTE: khung #42 read-only"]
    FA -->|"chia se"| P42["Khung vat ly #42<br/>[data: hello]"]
    CA -->|"chia se"| P42
  end
  subgraph After["Sau khi Con ghi vao trang"]
    FB["Tien trinh Cha<br/>PTE: khung #42 read-only"]
    CB["Tien trinh Con<br/>PTE: khung #99 read-write"]
    FB --> P42b["Khung #42<br/>[data: hello]"]
    CB --> P99["Khung #99 (ban sao)<br/>[data: world]"]
  end
  Before -->|"Con ghi -> page fault -> OS copy"| After

Khi tiến trình con (hoặc cha) ghi vào trang shared:

  1. CPU phát write page fault (trang đánh dấu read-only).
  2. OS COW handler:
    • Cấp một khung vật lý mới.
    • Copy nội dung khung gốc vào khung mới.
    • Cập nhật PTE của bên ghi trỏ sang khung mới, đặt lại read-write.
    • Nếu chỉ còn 1 tiến trình reference khung gốc, đặt lại read-write cho khung gốc luôn.
  3. CPU retry lệnh ghi — thành công trên bản sao riêng.

Chi phí thực sự của fork: chỉ trả giá cho số trang thực sự bị ghi sau fork. Nếu con gọi exec() ngay (pattern fork-exec), hầu như không có trang nào bị copy.

Minh hoạ: fork với array lớn

static int data[1000000];  // ~4 MB -- static (BSS), tranh tran stack
memset(data, 0, sizeof(data));

pid_t pid = fork();
if (pid == 0) {
    // Con: chi doc data, khong ghi -> khong copy trang nao
    printf("sum = %d\n", sum(data, 1000000));
    exit(0);
}
// Cha: tiep tuc chinh sua data -> moi trang duoc sua moi duoc copy
data[0] = 42;  // -> COW: copy trang chua data[0]

Sau fork(), cả 4 MB chia sẻ. Con chỉ đọc → 0 byte copy. Cha ghi 1 phần tử → chỉ trang chứa phần tử đó (~4 KB) bị copy.

4. Pitfall tổng hợp

Nhầm 1: MAP_SHARED khi cần isolation. Với MAP_SHARED, ghi của tiến trình này phản ánh lên file và tới tiến trình khác đang map cùng file. Nếu muốn đọc file mà không sửa hoặc không ảnh hưởng tiến trình khác, dùng MAP_PRIVATE.

Nhầm 2: mmap luôn nhanh hơn read/write. mmap tiết kiệm một lần copy từ kernel buffer sang user buffer. Nhưng với access pattern ngẫu nhiên, mỗi page miss là một major fault; với file nhỏ, overhead setup VMA có thể vượt lợi ích. Benchmark trước khi quyết định.

Nhầm 3: fork + ghi nhiều trang = COW overhead thật. Nếu sau fork tiến trình con sửa phần lớn bộ nhớ (ví dụ worker Redis sau BGSAVE), COW copy sẽ tốn CPU và bộ nhớ tương đương copy thủ công. Redis có thể dùng gần gấp đôi RAM trong lúc fork đang chạy vì copy-on-write liên tục.

Nhầm 4: munmap không cần thiết. Bỏ munmap trước khi exit thì OS dọn tự động, nhưng trong tiến trình sống lâu, quên munmap rò địa chỉ ảo (VMA leak) — đặc biệt nguy hiểm trên 32-bit.

5. Đào sâu (tuỳ chọn)

📚 Đào sâu (tuỳ chọn)

Demand-zero pages: anonymous mmap (và trang heap mới) bắt đầu là "demand-zero" — PTE absent, khi fault OS cấp một khung rảnh đã được zero-fill (kernel giữ sẵn pool trang zero để tránh information leak). Nhờ đó OS có thể "hứa" nhiều bộ nhớ ảo hơn RAM thật (overcommit).

vfork: biến thể của fork không copy page table — con chia sẻ không gian địa chỉ cha và phải gọi exec() hoặc _exit() ngay, không được đụng vào stack/heap cha. Nhanh hơn fork/COW nhưng rất dễ sai — ít dùng ngoài internals của shell/posix_spawn.

Overcommit và /proc/sys/vm/overcommit_memory: Linux cho phép tổng địa chỉ ảo cam kết vượt RAM + swap (overcommit). Mode 0 (default): heuristic; mode 1: luôn overcommit; mode 2: không overcommit (từ chối malloc khi sắp hết). OOM killer là hậu quả của overcommit + không đủ bộ nhớ thật.

Page cache dùng chung với mmap: trang file nạp qua mmap được lưu trong page cache kernel, cùng cấu trúc với buffer đọc/ghi thông thường. Nếu một tiến trình đọc file bằng read() và tiến trình khác mmap cùng file, OS chia sẻ cùng trang vật lý — không có bản sao thứ hai.

msync: với MAP_SHARED, ghi có thể nằm trong page cache chưa flush ra đĩa. Gọi msync(addr, len, MS_SYNC) để đảm bảo dữ liệu đã được flush — cần thiết cho durability (database write-ahead log đôi khi dùng mmap + msync).

6. Liên hệ các bài khác

  • Bài 01 — Vì sao cần bộ nhớ ảo: shared library (libc) chia sẻ trang là ví dụ kinh điển của mmap — một bản vật lý dùng cho trăm tiến trình.
  • Bài 04 — Page fault & swap: page fault là cơ chế kích hoạt cả mmap (nạp trang từ file) lẫn COW (copy trang khi bị ghi). Không hiểu fault thì không hiểu tại sao mmap và COW hoạt động.
  • Module 1 — Heap & cấp phát động: malloc lớn thực chất gọi mmap(MAP_ANONYMOUS) bên dưới; hiểu mmap giải thích tại sao heap có thể trả bộ nhớ về OS khi free (thứ sbrk không làm được).

7. Tóm tắt

  • mmap ánh xạ file (hoặc vùng ẩn danh) vào không gian địa chỉ ảo. OS nạp trang theo demand paging khi chạm lần đầu. File lớn hơn RAM vẫn map được vì chỉ phần đang truy cập cần trong RAM.
  • MAP_SHARED chia sẻ ghi với file và tiến trình khác; MAP_PRIVATE dùng COW — ghi tạo bản sao riêng, không ảnh hưởng file.
  • Copy-on-write (COW): khi fork(), cha và con chia sẻ cùng trang vật lý (đánh dấu read-only). Khi một bên ghi → write page fault → OS copy trang đó sang khung mới cho bên ghi. fork trả về tức thì vì chỉ copy page table, không copy dữ liệu.
  • COW overhead thật phát sinh chỉ cho trang thực sự bị ghi sau fork. fork-exec (fork rồi exec ngay) gần như không copy trang nào.
  • Shared library (libc) chia sẻ trang vật lý qua mmap — hàng trăm tiến trình dùng chung một bản RAM nhờ lớp gián tiếp của bộ nhớ ảo.

8. Tự kiểm tra

Tự kiểm tra
Q1
Khi bạn mmap một file 10 GB trên máy 4 GB RAM, OS làm gì ngay sau lệnh mmap? Khi nào trang file thực sự được nạp vào RAM?
Ngay sau mmap, OS chỉ tạo một VMA (Virtual Memory Area) — dải địa chỉ ảo hợp lệ trỏ tới file. Không có trang nào được nạp, PTE tất cả absent. Bộ nhớ RAM chưa bị tiêu.

Trang được nạp khi tiến trình thực sự truy cập địa chỉ đó lần đầu: CPU tra PTE thấy absent → major page fault → OS đọc trang tương ứng từ file vào một khung RAM → cập nhật PTE. Đây là demand paging. File 10 GB vẫn map được trên máy 4 GB RAM vì tại bất kỳ thời điểm nào chỉ cần các trang đang truy cập tích cực nằm trong RAM — phần còn lại ở trên đĩa (file gốc).
Q2
Phân biệt MAP_SHARED và MAP_PRIVATE. Trong trường hợp nào bạn dùng MAP_PRIVATE kể cả khi cần đọc file?
MAP_SHARED: ghi của tiến trình phản ánh lên file trên đĩa và hiển thị với tiến trình khác đang map cùng file. Dùng cho shared memory IPC, ghi log vào file qua mmap.

MAP_PRIVATE: ghi dùng copy-on-write — tạo bản sao riêng trong RAM, không ảnh hưởng file gốc hay tiến trình khác. Trang chỉ đọc vẫn chia sẻ vật lý đến khi bị ghi.

Dùng MAP_PRIVATE kể cả khi chỉ đọc khi: (1) bạn cần isolation hoàn toàn — đảm bảo không ai sửa nội dung dưới tay bạn qua MAP_SHARED; (2) sau khi đọc bạn muốn patch/modify dữ liệu trong bộ nhớ mà không ghi ngược lên file; (3) pattern fork-exec: shell fork rồi exec, dùng MAP_PRIVATE để trang text của executable không ghi ngược lên binary.
Q3
Mô tả cơ chế copy-on-write sau fork(). Tại sao fork() một tiến trình 4 GB trả về gần tức thì?
Khi fork(), OS không copy dữ liệu. Thay vào đó:
1. OS copy page table của cha sang con — mọi PTE trong bảng con trỏ cùng khung vật lý với cha.
2. Cả hai bảng đánh dấu mọi trang read-only (write-protected).
3. fork() trả về — tiến trình con sẵn sàng chạy.

Chỉ copy page table (vài MB cho tiến trình 4 GB) nên trả về gần tức thì. Khi một bên ghi vào trang: CPU phát write page fault vì trang read-only → OS copy trang đó (~4 KB) sang khung mới → PTE bên ghi cập nhật trỏ khung mới read-write → retry thành công. Chi phí thật chỉ trả cho trang thực sự bị ghi.
Q4
Vì sao 200 tiến trình cùng dùng libc nhưng chỉ tốn một bản sao RAM của libc? Cơ chế nào cho phép điều này?
Khi tiến trình load shared library, OS dùng mmap để ánh xạ các segment code của libc vào không gian địa chỉ ảo của tiến trình. Điểm mấu chốt: OS ánh xạ cùng khung vật lý chứa code libc vào page table của mọi tiến trình cần nó.

200 tiến trình có 200 page table khác nhau, mỗi cái có VMA trỏ tới libc tại địa chỉ ảo riêng — nhưng các PTE trong những VMA đó đều dịch sang cùng một tập khung vật lý. Vì code thư viện read-only (không ai ghi vào nó), chia sẻ an toàn — không cần COW. Kết quả: 1 bản vật lý, 200 ánh xạ ảo, tiết kiệm gần như toàn bộ RAM lẽ ra phải nhân bản.
Q5
Redis fork() một child để BGSAVE (snapshot). Sau fork, child chỉ đọc để serialize dữ liệu, còn parent tiếp tục nhận write commands. Trong tình huống write-heavy, điều gì xảy ra với mức RAM của Redis?
Đây là pitfall COW kinh điển. Ngay sau fork(), parent và child chia sẻ toàn bộ trang data — tổng RAM Redis ~R GB, chưa tốn thêm gì.

Nhưng parent tiếp tục nhận writes: mỗi key bị ghi → trang chứa key đó trigger COW copy → OS tạo bản sao ~4 KB mới cho parent. Nếu workload write-heavy và snapshot kéo dài (vài phút), phần lớn trang dữ liệu bị copy — RAM thêm = số trang parent ghi sau fork (mỗi trang trigger một COW copy); worst-case (parent ghi gần hết) peak tiệm cận 2× — không phải child giữ bản riêng, mà do parent copy dần từng trang. Đây là lý do Redis khuyến cáo để vm.overcommit_memory=1 trên Linux và cấp RAM dư để COW không bị OOM. Với Redis Cluster replica hoặc RDB dump, phải tính worst-case RAM = 2× data size trong lúc BGSAVE.
Q6
Tại sao pattern fork-exec (fork rồi gọi exec() ngay) gần như không tốn thêm RAM, dù fork() đã copy page table?
Khi fork() dùng COW, tất cả trang chia sẻ đều read-only — chưa bản sao nào được tạo. exec() thay thế toàn bộ không gian địa chỉ của tiến trình con bằng image mới (binary mới): OS giải phóng mọi VMA và page table cũ, tạo mới từ đầu cho executable mới.

exec() xảy ra trước khi child kịp ghi vào bất kỳ trang nào, không có COW copy nào được trigger. Chi phí duy nhất là copy page table lúc fork (vài millisecond), sau đó exec giải phóng nó. RAM peak tăng rất ít. Đây là lý do UNIX shell có thể fork-exec hàng trăm lần mỗi giây mà RAM gần như không tăng — và là lý do thiết kế "fork rồi exec" tồn tại thay vì một syscall tạo process mới hoàn toàn.

Bài tiếp theo: Tổng kết module — Bộ nhớ ảo

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên