fork() là cách Unix tạo một tiến trình mới: nó nhân đôi tiến trình đang chạy thành hai bản gần như y hệt. Nghe qua thì đáng sợ — chẳng lẽ mỗi lần fork một tiến trình dùng 1 GB RAM là phải sao chép trọn 1 GB? Câu trả lời "không, nhờ copy-on-write" ai cũng biết, và nó dễ dẫn tới một kết luận vội: "vậy fork gần như miễn phí". Bài này đo fork() thật theo kích thước bộ nhớ tiến trình cha để xem nó thực sự tốn gì — và bắt được cả một lần chính công cụ đo của tôi nói dối.

fork tốn gì

fork và copy-on-write

Khi fork(), nhân không sao chép các trang bộ nhớ của cha sang con. Thay vào đó, nó dùng copy-on-write (COW): cha và con dùng chung các trang vật lý, tất cả được đánh dấu chỉ-đọc. Chừng nào cả hai chỉ đọc, chúng chia sẻ cùng một bộ nhớ vật lý. Chỉ khi một bên ghi vào một trang, nhân mới bắt lỗi, sao chép riêng đúng trang đó ra, và cho bên ghi bản riêng. Nhờ vậy, fork một tiến trình 1 GB không tốn 1 GB — phần lớn trang không bao giờ bị ghi nên không bao giờ bị sao chép.

Nhưng có một thứ nhân phải sao chép ngay lập tức, không lười được: bảng trang (page table). Đây là cấu trúc ánh xạ từ địa chỉ ảo sang địa chỉ vật lý — mỗi trang 4 KB cần một mục trong bảng. Con phải có bảng trang riêng (dù trỏ tới cùng các trang vật lý như cha), nên nhân phải dựng lại toàn bộ cây bảng trang cho con. Và cái đó lớn theo bộ nhớ: càng nhiều trang, càng nhiều mục phải sao chép. Đây chính là cái giá tôi muốn đo.

Đo: fork lớn dần theo bộ nhớ cha

Tôi viết một chương trình C: cấp phát N MB, chạm vào mọi trang (để chúng thật sự chiếm bộ nhớ vật lý), rồi đo fork() — dùng clock_gettime (gần như miễn phí nhờ vDSO, đúng như bài trước đo) làm đồng hồ, lấy trung vị 31 lần.

Bộ nhớ tiến trình cha Thời gian fork()
1 MB 32,8 µs
10 MB 37,6 µs
100 MB 166,2 µs
500 MB 898,0 µs
1000 MB 1613,2 µs

Con số rất rõ: fork() lớn dần theo bộ nhớ tiến trình cha. Từ ~33 µs cho một tiến trình 1 MB, nó tăng lên ~1613 µs (1,6 ms) cho một tiến trình 1 GB — gần như tuyến tính. fork không phải là một thao tác giá cố định; càng nhiều bộ nhớ, fork càng lâu, vì bảng trang phải sao chép càng lớn.

Nhưng nếu fork lớn theo bộ nhớ, làm sao ta biết nó thật sự không sao chép dữ liệu (như COW hứa)? Tôi so trực tiếp: fork một tiến trình 1 GB tốn ~1,6 ms; còn memcpy sao chép trọn 1 GB đó tốn bao lâu? ~490 ms. Nếu fork thật sự sao chép 1 GB dữ liệu, nó phải mất cỡ 490 ms; thực tế nó chỉ mất 1,6 ms — nhanh hơn ~310 lần. Vậy COW đúng: fork không đụng tới dữ liệu. Cái 1,6 ms đó là tiền sao chép bảng trang, không phải dữ liệu.

Một lần tôi đo hớ: COW không làm fork miễn phí, và trình biên dịch ăn mất phép đo

Tôi vào bài với niềm tin gọn: "COW nghĩa là fork chẳng sao chép gì, nên fork gần như miễn phí, O(1) bất kể tiến trình to cỡ nào". Bảng số trên bác bỏ ngay: fork 1 GB tốn gấp ~50 lần fork 1 MB. COW làm phần dữ liệu trở nên lười (không sao chép cho tới khi ghi), nhưng phần bảng trang thì nhân sao chép ngay và đầy đủ, và nó O(bộ nhớ). "fork miễn phí" là một nửa sự thật nguy hiểm: đúng ở chỗ không copy dữ liệu, sai ở chỗ vẫn tốn theo kích thước.

Và đây là cú đo hớ thứ hai, sống động hơn. Khi tôi viết phép so sánh với memcpy để chứng minh "fork không copy dữ liệu", lần chạy đầu in ra: memcpy 1GB = 0,0 µs. Một con số bất khả — không cách nào sao chép một gigabyte trong không giây. Nếu tôi tin nó, tôi đã có một "bằng chứng" vô nghĩa. Truy ra thủ phạm: trình biên dịch. Tôi bật -O2, và nó thấy bản sao dst sau khi memcpy chẳng được ai đọc, nên theo luật loại mã chết (dead-code elimination), nó xóa luôn cả lệnh memcpy. Phép đo đo một đoạn mã đã bị xóa nên ra 0. Tôi thêm một rào cản (asm volatile(... : "memory")) buộc trình biên dịch coi dst là có dùng, và memcpy hiện ra đúng ~490 ms.

Bài học đo lường, đúng tinh thần cả hai sê-ri: công cụ bạn dùng để đo cũng là một biến — kể cả trình biên dịch. Ở sê-ri mạng, thủ phạm là một máy chủ tự dựng kẹt Nagle, một sysctl thừa kế từ Docker; ở đây là bộ tối ưu của gcc lặng lẽ xóa mất thứ tôi định đo. Một con số bất khả (0 giây cho 1 GB) là dấu hiệu không phải hiện tượng lạ, mà là công cụ đang nói dối. Luôn hỏi "con số này có khả dĩ về mặt vật lý không?" trước khi ghi nó xuống.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là đừng fork từ một tiến trình đã phình to. Nếu ứng dụng của bạn ngốn nhiều GB RAM rồi mới fork (ví dụ để chạy một lệnh con), mỗi lần fork trả một cái giá tỉ lệ với bộ nhớ đó — hàng mili giây, và tệ hơn, tạo áp lực lên bảng trang. Đây là lý do các máy chủ (như Redis khi lưu snapshot bằng fork, hay các web server mô hình prefork) quan tâm tới kích thước tiến trình lúc fork. Mẫu tốt là fork sớm, khi tiến trình còn nhỏ, rồi mới nạp dữ liệu vào các tiến trình con — chứ không nạp cả núi dữ liệu rồi mới fork.

Hệ quả thứ hai là dùng đúng công cụ khi chỉ muốn chạy một chương trình khác. Nếu bạn fork chỉ để ngay sau đó exec một chương trình mới (thay thế toàn bộ không gian địa chỉ), thì việc sao chép bảng trang là công toi — con vứt bỏ nó ngay khi exec. Vì thế có vforkposix_spawn: chúng tránh sao chép bảng trang cho đúng tình huống fork-rồi-exec. Trên bộ nhớ lớn, khác biệt là đáng kể — đó cũng là một phần đề tài các bài sau của sê-ri.

Hệ quả thứ ba là hiểu COW là con dao hai lưỡi về thời điểm trả giá. COW dời chi phí sao chép dữ liệu từ lúc fork sang lúc ghi lần đầu — nên một tiến trình con tưởng nhẹ nhàng có thể đột ngột chậm và ngốn RAM khi nó bắt đầu ghi rải rác khắp bộ nhớ chung (mỗi trang ghi là một lỗi trang cộng một lần sao chép). Con số mang theo: fork không sao chép dữ liệu nhờ COW (fork 1GB 1,6ms so với memcpy 490ms, nhanh hơn 310 lần), nhưng nó vẫn tốn O(bộ nhớ) vì phải sao chép bảng trang ngay — 33µs ở 1MB lên 1613µs ở 1GB. fork rẻ hơn một bản sao đầy đủ rất nhiều, nhưng "rẻ hơn" không phải "miễn phí"; và cái giá thật của nó lớn dần đúng theo kích thước tiến trình bạn fork.

Thử ba mươi giây

Trên một máy Linux, chạy cat /proc/self/status | grep -E 'VmSize|VmRSS' để thấy một tiến trình nhỏ (cat) dùng bao nhiêu bộ nhớ. Rồi hình dung: mỗi mục bảng trang phủ 4 KB, nên một tiến trình 1 GB có khoảng 262 nghìn mục chỉ ở tầng cuối — đó là chừng ấy thứ nhân phải nhân đôi mỗi lần fork. Muốn thấy tận mắt COW hoạt động, chạy một chương trình cấp phát nhiều bộ nhớ rồi fork một tiến trình con ngồi im: theo dõi bằng free -m hay /proc/meminfo, bộ nhớ tổng gần như không tăng — vì con và cha vẫn dùng chung từng trang, đúng cái mà bài này đo. Trả giá bằng bảng trang, chứ không bằng dữ liệu.