fork() không sao chép dữ liệu của cha nhờ copy-on-write — ai viết C lâu năm cũng biết. Nhưng nó vẫn phải sao chép bảng trang, và chi phí đó tỉ lệ thuận với lượng bộ nhớ cha đang giữ. Bài này đo con số thật, và cho thấy vì sao trên phần lớn máy Linux hiện đại bạn gần như không thấy được điều đó.

Đây là phần 46/50 của sê-ri Hiệu năng Linux cho lập trình viên, nối tiếp phần trước — Tiến trình mồ côi và zombie — bàn về vòng đời tiến trình sau khi cha chết. Phần này lùi về điểm bắt đầu: cha tạo con bằng cách nào, và ba cách phổ biến nhất — fork() + exec(), vfork() + exec(), posix_spawn() — tốn bao nhiêu thời gian. (Các con số là thời gian đo trong container, không phải perf counter phần cứng.)

Ba cách khởi động một tiến trình

fork(2) tạo bản sao gần như hoàn chỉnh của tiến trình gọi nó: cùng bộ nhớ (nhờ copy-on-write nên không copy dữ liệu ngay), cùng file descriptor, cùng biến môi trường. Vấn đề nằm ở chữ "gần như": nhân Linux vẫn phải cấp cho con một bảng trang riêng, dựng bằng cách duyệt toàn bộ bảng trang của cha rồi sao mỗi mục sang bảng mới, đánh dấu các trang liên quan là copy-on-write. Không trang dữ liệu nào bị copy lúc này, nhưng số mục bảng trang cần sao tỉ lệ thuận với RSS (Resident Set Size) của cha — cha càng giữ nhiều bộ nhớ, fork() càng lâu, kể cả khi con exec() ngay dòng tiếp theo và không đụng byte dữ liệu nào của cha.

vfork(2) né hẳn bước đó: tiến trình con dùng chung không gian địa chỉ và bảng trang với cha, không tạo bản sao nào. Đổi lại cha bị tạm dừng tới khi con gọi exec() hoặc _exit(), và con tuyệt đối không được sửa biến nào ngoài giá trị trả về của vfork() — sửa sai là undefined behavior vì con đang chạy trên đúng stack của cha.

posix_spawn(3) là API chuẩn POSIX, để không ai phải tự viết đúng cặp fork() + exec() kèm xử lý lỗi. Trên glibc, khi thuộc tính spawn không đòi hỏi gì đặc biệt, cài đặt bên trong dùng clone() với cờ CLONE_VM kết hợp CLONE_VFORK — cùng cơ chế chia sẻ địa chỉ ảo như vfork(), chỉ khác là được gói trong API an toàn hơn, không có cạm bẫy "đừng đụng vào biến cục bộ".

Chuẩn bị đo

Đo trực tiếp trên macOS không có ý nghĩa với bài về Linux — fork()/vfork() trên Darwin khác hẳn về ngữ nghĩa và cơ chế bộ nhớ. Toàn bộ phép đo chạy trong container Docker (debian:bookworm-slim, gcc 12), dựng và dọn lại sau khi đo:

docker build -t bench-fork:latest .
docker run --rm --memory=3g bench-fork:latest ./bench <rss_mb> <iters> <method> [nothp]
# ... sau khi đo xong
docker image rm bench-fork:latest

Chương trình đo viết bằng C: chạm (touch) một vùng bộ nhớ rss_mb MB bằng mmap() để đẩy RSS của cha lên mức mong muốn, sau đó lặp 300 lần một trong ba cách tạo tiến trình con chạy /bin/true rồi waitpid(), đo bằng clock_gettime(CLOCK_MONOTONIC) và lấy trung bình mỗi lần gọi. Mỗi cấu hình (phương pháp × RSS × bật/tắt Transparent Huge Page) chạy lặp 3 lần trong container mới — không tái sử dụng container cũ để tránh cache trang từ lần trước làm sai lệch kết quả.

Đo lần đầu: RSS nhỏ, gần như không khác biệt

Với tiến trình cha RSS ≈ 0 (chưa chạm bộ nhớ thêm), cả ba cách đều nhanh và gần bằng nhau:

Cách Thời gian trung bình/lần
fork() + exec() 186 µs
vfork() + exec() 154 µs
posix_spawn() 174 µs

vfork() nhanh hơn một chút vì né được việc dựng bảng trang mới — dù ở RSS gần 0 thì bảng trang cũng chỉ có vài trang, chênh lệch không đáng kể. Nếu chỉ đo tới đây, kết luận hợp lý là "cả ba xêm xêm nhau, chọn cái nào tiện thì dùng". Kết luận đó sai, và chỉ lộ ra khi tăng RSS của cha lên.

Đo lần hai: tăng RSS của cha

Giữ nguyên phương pháp, chỉ tăng RSS cha lên 128 MB, 512 MB, 1024 MB (bằng cách mmap() và ghi vào mỗi trang trước khi đo), THP (Transparent Huge Page) ở chế độ mặc định always của container:

RSS cha fork()+exec() vfork()+exec() posix_spawn()
0 MB 186 µs 154 µs 174 µs
128 MB 202 µs 148 µs 172 µs
512 MB 290 µs 144 µs 170 µs
1024 MB 412 µs 147 µs 174 µs

vfork() và posix_spawn() gần như phẳng tuyệt đối bất kể cha giữ bao nhiêu RAM — đúng như lý thuyết, vì cả hai không sao chép bảng trang. fork() thì tăng dần: từ 186 µs lên 412 µs, hơn gấp đôi khi cha giữ 1 GB. Đây đã là một khác biệt đáng kể, nhưng con số này vẫn còn bị che bớt — vì THP đang bật.

Lần đo sai đầu tiên: madvise() trả EINVAL

Trước khi đo bằng cờ tắt THP, phiên bản đầu dùng malloc() để cấp vùng bộ nhớ cần chạm, rồi gọi madvise(buf, bytes, MADV_NOHUGEPAGE) để ép trang thường 4 KB. Kết quả: madvise() báo EINVAL ngay cả với vùng 1 MB, dù man 2 madvise không nói gì về giới hạn kích thước.

Không nuốt lỗi này — tra thêm mới thấy: malloc() cho vùng lớn hơn ngưỡng mmap (128 KB) tuy dùng mmap() bên dưới, nhưng con trỏ glibc trả về không nằm ở đầu trang — nó đứng sau vài byte header glibc dùng để quản lý chunk. madvise() đòi địa chỉ canh trang tuyệt đối, lệch vài byte là đủ để trả EINVAL. Cách sửa: gọi thẳng mmap(NULL, bytes, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) thay vì malloc(), con trỏ khi đó luôn canh trang — xác minh lại bằng chương trình con nhỏ, thành công ngay lần đầu. Lỗi này dễ lặp lại: ai madvise() một vùng do malloc() cấp đều có thể gặp đúng EINVAL này, và thông báo lỗi không gợi ý gì về nguyên nhân thật.

Đo lần ba: tắt THP bằng MADV_NOHUGEPAGE

Sau khi sửa lỗi trên, đo lại đúng ba lần cho mỗi cấu hình, với madvise(buf, bytes, MADV_NOHUGEPAGE) áp lên vùng RSS trước khi chạm:

RSS cha fork()+exec() (THP tắt) vfork()+exec() (THP tắt) posix_spawn() (THP tắt)
0 MB 175 µs 155 µs 177 µs
128 MB 652 µs 149 µs 160 µs
512 MB 2.008 µs 147 µs 166 µs
1024 MB 3.850 µs 136 µs 157 µs

Kết quả ngược trực giác, đã lặp ổn định qua 3 lần đo độc lập (phương sai dưới 2%): khi tắt THP, fork() ở RSS 1 GB chậm gấp 20 lần so với RSS gần 0 (175 µs → 3.850 µs), và gấp 9,3 lần so với chính nó khi THP bật (412 µs → 3.850 µs). vfork() và posix_spawn() hoàn toàn không đổi dù bật hay tắt THP — đúng như dự đoán, vì cả hai không đụng tới bảng trang.

Độ dốc gần như tuyến tính: (3.850 − 175) / 1024 ≈ 3,6 µs cho mỗi MB RSS khi THP tắt, so với (412 − 186) / 1024 ≈ 0,22 µs cho mỗi MB RSS khi THP bật — chênh nhau khoảng 16 lần.

Vì sao THP che giấu gần hết chi phí

Trang bộ nhớ Linux thường có kích thước 4 KB: 1024 MB là 262.144 mục PTE (Page Table Entry) mà fork() phải sao khi tắt THP. Với Transparent Huge Page bật ở chế độ always (mặc định của image debian:bookworm-slim — kiểm bằng cat /sys/kernel/mm/transparent_hugepage/enabled, thấy [always] madvise never), nhân gộp các trang 4 KB liền kề thành trang 2 MB khi có thể. 1024 MB lúc đó chỉ còn 512 mục PMD (Page Middle Directory) trỏ thẳng tới huge page — ít hơn 512 lần số mục cần sao.

Nếu chi phí sao mỗi mục là hằng số, thời gian phải giảm đúng 512 lần. Thực tế chỉ giảm khoảng 16 lần — sao một mục PMD trỏ huge page tốn nhiều hơn sao một mục PTE thường (khoảng 442 ns/mục so với 14 ns/mục, tính từ độ dốc µs/MB chia cho số mục tương ứng), và các vùng đầu/cuối không đủ 2 MB liên tục vẫn phải xử lý theo kiểu PTE thường. Hai hiệu ứng này giải thích vì sao con số thực đo không khớp đúng tỉ lệ 512 lần theo lý thuyết, nhưng vẫn đúng chiều: THP luôn làm fork() rẻ hơn, không bao giờ đắt hơn.

Ý nghĩa thực tế

Hầu hết bản phân phối Linux phổ biến (Ubuntu, Debian, RHEL gần đây) đặt THP mặc định là always hoặc madvise, không phải never. Chi phí fork() trên máy sản xuất thật thường đã ở mức "được THP giảm nhẹ" — vẫn tăng theo RSS, chỉ chậm hơn nhiều so với khi tắt THP hẳn. Với tiến trình cha nhỏ (RSS vài chục MB), khác biệt giữa fork() và vfork()/posix_spawn() không đáng bận tâm. Với tiến trình cha lớn — trình thông dịch đã nạp nhiều thư viện, máy chủ đã cache dữ liệu, tiến trình chạy lâu RSS lên tới hàng GB — mỗi lần fork() để chạy một lệnh con đều trả giá bằng con số đo ở trên. Đây là lý do CPython từ bản 3.8 dùng posix_spawn mặc định cho subprocess khi điều kiện cho phép, thay vì fork() + exec().

vfork() vẫn nhanh nhất, nhưng không đáng dùng trực tiếp trong ứng dụng: ràng buộc "con không được đụng biến nào ngoài giá trị trả về" quá dễ vi phạm âm thầm — trình biên dịch có thể sinh code khác nhau tùy mức tối ưu, lỗi chỉ lộ ra ngẫu nhiên. posix_spawn() đạt gần hết lợi ích tốc độ của vfork() (157–177 µs so với 136–155 µs, chênh chưa tới 15%) mà không kèm rủi ro đó.

Mẫu số chung

Cái bẫy "một tiến trình nặng bộ nhớ mà cứ fork() để chạy lệnh con thì trả giá theo RSS" không riêng gì C — nó là lý do các runtime cấp cao đều lặng lẽ né fork() khi đẻ tiến trình con.

Trong thế giới Java, một JVM đang giữ heap chục GB mà gọi Runtime.exec()/ProcessBuilder từng gặp đúng nỗi đau này: cách cũ fork() + exec() buộc nhân sao bảng trang của cả heap khổng lồ chỉ để chạy một lệnh vặt, đôi khi còn cần gấp đôi bộ nhớ tạm thời. Vì thế JVM hiện đại chuyển sang posix_spawn (qua jspawnhelper) làm mặc định — đúng nước đi của CPython, cùng một lý do.

Trong Go, os/exec không dùng fork() trần mà dựng trên syscall.ForkExec cài đặt bằng clone + CLONE_VM/CLONE_VFORK, chính là con đường posix_spawn — nên một server Go giữ nhiều RAM vẫn spawn tiến trình con rẻ, không trả giá theo heap; đây là "an toàn/nhanh theo mặc định" mà runtime lo hộ bạn.

Còn ở cơ sở dữ liệu, PostgreSQL là ví dụ ngược chiều đáng nhớ: nó cố tình dùng mô hình một tiến trình mỗi kết nối qua fork() từ postmaster, và chính chi phí fork() + bộ nhớ mỗi tiến trình là lý do người ta phải đặt một connection pool (PgBouncer) phía trước thay vì mở kết nối mới cho mỗi request — cùng bài học "đừng fork() trong đường nóng", giải bằng cách tái dùng tiến trình thay vì đẻ mới.

Mẫu số chung: fork() không hề miễn phí chỉ vì có copy-on-write — nó trả giá theo lượng bộ nhớ cha đang giữ, không theo lượng dữ liệu con thật sự dùng — nên đẻ tiến trình con từ một tiến trình nặng RAM là một đường nóng ẩn, và cách né đúng là dùng cơ chế chia-sẻ-địa-chỉ (posix_spawn/vfork) hoặc tái dùng tiến trình sẵn có. Vì thế câu hỏi đúng khi một dịch vụ lớn hay spawn lệnh con bị giật không phải "lệnh con chạy nhanh không" mà là "tôi đang fork() từ một tiến trình giữ bao nhiêu RAM, và có cần fork() thật không" — vì cái giá nằm ở kích thước của cha, chỗ ít ai ngờ tới.

Muốn tự thấy chiều tăng đó trong ba mươi giây, trên máy Linux thật có python3, dán thẳng đoạn này để tự dựng RSS lớn rồi đo fork():

python3 -c "
import os, time
buf = bytearray(1024 * 1024 * 1024)  # cap phat va zero-hoa ngay, RSS len khoang 1 GB
t0 = time.monotonic()
for _ in range(200):
    pid = os.fork()
    if pid == 0:
        os._exit(0)
    os.waitpid(pid, 0)
print(f'{(time.monotonic() - t0) / 200 * 1e6:.1f} us/lan fork()')
"

Chạy lại với buf = bytearray(1024) rồi so hai con số. Trên container đo ở trên chênh lệch là 20 lần; trên máy thật con số cụ thể sẽ khác tùy nhân và cấu hình THP, nhưng chiều tăng luôn giữ nguyên — đó là điều đáng nhớ nhất: fork() không hề "miễn phí" chỉ vì có copy-on-write.

Bài viết liên quan