Phần 45 đo chuyện gì xảy ra khi tiến trình con chết. Bài này lùi lại một bước: tạo ra nó tốn bao nhiêu.
Bảng
Tạo và thu hồi một tiến trình con, 2.000 lần mỗi cách, đổi lượng bộ nhớ mà tiến trình cha đang giữ:
| Cách tạo | Cha giữ 0 MB | Cha giữ 256 MB | Cha giữ 1.024 MB |
|---|---|---|---|
fork + _exit |
88,3 µs | 1.354,9 µs | 5.160,7 µs |
fork + exec /bin/true |
175,5 µs | 1.421,2 µs | 5.448,0 µs |
vfork + exec |
154,7 µs | 152,4 µs | 154,3 µs |
posix_spawn |
180,5 µs | 178,8 µs | 180,9 µs |
system("/bin/true") |
414,4 µs | 361,8 µs | 373,7 µs |
Hai dòng đầu tăng 58 lần theo bộ nhớ của cha. Ba dòng dưới đứng yên tuyệt đối.
fork không chép dữ liệu, nhưng nó chép bảng trang
Ai cũng biết fork dùng sao chép khi ghi, nên nó "không chép bộ nhớ". Đúng — nó không chép dữ liệu.
Nó vẫn phải chép bảng trang.
1 GB chia cho 4 KB là 262.144 trang. Mỗi trang cần một mục trong bảng trang của tiến trình con, và mục đó ở cả hai bên phải được đánh dấu chỉ-đọc để cơ chế sao chép khi ghi hoạt động.
Đó là 5 mili giây làm việc thuần tuý dựng bảng.
Và với fork + exec — mẫu phổ biến nhất — toàn bộ 5 mili giây đó bị vứt đi ngay lập tức, vì exec thay thế toàn bộ không gian địa chỉ.
Nói lại lần thứ hai vì nó ngược với hiểu biết thông thường: fork rẻ khi tiến trình cha nhỏ, và đắt tuyến tính theo kích thước cha. Câu "sao chép khi ghi làm fork rẻ" chỉ đúng một nửa.
vfork và posix_spawn không có gì để chép
Cả hai không tạo bảng trang mới. Tiến trình con mượn không gian địa chỉ của cha, và cha bị treo cho tới khi con gọi exec hoặc _exit.
Không có gì để chép, nên không có gì phụ thuộc vào kích thước cha. Ba con số đo được: 154,7 / 152,4 / 154,3 µs — chênh nhau 1,5%.
posix_spawn của glibc được cài đặt bằng clone(CLONE_VM|CLONE_VFORK), tức là cùng cơ chế, nhưng bọc trong một API an toàn hơn nhiều.
Đừng gọi vfork trực tiếp. Giữa vfork và exec, tiến trình con dùng chung bộ nhớ với cha — gán một biến toàn cục, gọi malloc, hay chỉ đơn giản là return từ hàm đang chạy, đều làm hỏng cha theo cách rất khó gỡ. posix_spawn tốn thêm 26 µs và loại bỏ toàn bộ lớp lỗi đó.
Nghĩa là gì trong thực tế
Một dịch vụ giữ nhiều bộ nhớ mà gọi lệnh ngoài bằng fork + exec trả giá theo kích thước heap của chính nó.
Với 8 GB heap — bình thường với JVM hoặc một cơ sở dữ liệu — mỗi lần gọi lệnh ngoài tốn hàng chục mili giây, trong khi posix_spawn làm cùng việc đó trong 180 µs.
Điều khó chịu: chi phí này không liên quan gì tới lệnh được gọi. Chạy /bin/true hay chạy ffmpeg đều trả cùng khoản phí vào, và khoản đó do bộ nhớ của bên gọi quyết định.
Đây cũng là một nguồn của "dịch vụ chậm dần theo thời gian": heap lớn lên, và mọi lần gọi lệnh ngoài chậm theo, dù bản thân lệnh không đổi.
Cách gọi trong từng môi trường:
| Ngôn ngữ | Dùng | Tránh |
|---|---|---|
| C | posix_spawn() |
fork() + exec() khi cha lớn |
| Java | ProcessBuilder (đã dùng vfork/posix_spawn từ JDK 9) |
Runtime.exec trên JDK cũ |
| Python | subprocess.run(...) |
os.fork() |
| Go | os/exec (đã dùng clone + CLONE_VM) |
— |
| Node | child_process.spawn |
child_process.exec |
Cột cuối của dòng Node và dòng C có cùng lý do với dòng cuối của bảng đo.
system() luôn đắt nhất
414,4 µs khi cha trống — gấp 2,3 lần posix_spawn.
Nó khởi động thêm một shell để phân tích chuỗi lệnh. Cái shell đó là một tiến trình đầy đủ, phải nạp, phải khởi tạo, rồi mới exec lệnh thật.
Điểm đáng chú ý: system() không tăng theo bộ nhớ của cha (414 / 362 / 374 µs), vì glibc cài đặt nó bằng posix_spawn. Nên nó chậm vì lý do khác hẳn hai dòng đầu bảng.
Và cái giá thật của system() không nằm ở 414 µs mà ở chỗ có một shell phân tích chuỗi:
char cmd[256];
snprintf(cmd, sizeof cmd, "convert %s out.png", ten_tep_do_nguoi_dung_gui);
system(cmd); /* ten tep chua "; rm -rf /" la xong */
posix_spawn nhận mảng đối số, không nhận chuỗi, nên lớp lỗi này không tồn tại.
Chỗ tôi không đo được
Tôi không đo clone3() với các cờ tuỳ chỉnh, cũng không đo io_uring với IORING_OP_... cho việc tạo tiến trình. Cả hai đều tồn tại và đều có thể rẻ hơn, nhưng chúng chưa phổ biến trong thư viện chuẩn của ngôn ngữ nào, nên không có ý nghĩa thực dụng cho phần lớn người đọc.
Tôi cũng không đo trên máy có nhiều điểm gắn kết hệ thống tệp. Phần 36 đã cho thấy không gian tên mount tốn thêm 51 µs với cây gắn nhỏ; với máy chủ có hàng trăm điểm gắn thì con số đó lớn hơn, và nó cộng vào mọi dòng trong bảng.
Thử ba mươi giây
Đo xem dịch vụ của bạn trả bao nhiêu cho mỗi lần gọi lệnh ngoài:
cat > /tmp/f.c <<'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <spawn.h>
#include <sys/wait.h>
#include <sys/mman.h>
#include <time.h>
extern char **environ;
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){
size_t mb = c>1 ? atol(v[1]) : 0, n = mb*1024*1024;
if(n){ char*h=mmap(0,n,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS,-1,0);
for(size_t i=0;i<n;i+=4096) h[i]=1; }
char *av[]={"/bin/true",0}; int N=500; double t;
t=now(); for(int i=0;i<N;i++){pid_t p=fork(); if(p==0){execv(av[0],av);_exit(127);} waitpid(p,0,0);}
printf("cha giu %4zu MB | fork+exec %8.1f us\n",mb,(now()-t)/N*1e6);
t=now(); for(int i=0;i<N;i++){pid_t p; posix_spawn(&p,av[0],0,0,av,environ); waitpid(p,0,0);}
printf("cha giu %4zu MB | posix_spawn %7.1f us\n",mb,(now()-t)/N*1e6);
return 0;
}
EOF
cc -O2 -o /tmp/f /tmp/f.c
for mb in 0 512; do /tmp/f $mb; done
Nếu dòng fork+exec ở 512 MB chậm hơn dòng posix_spawn nhiều lần, và dịch vụ của bạn giữ nhiều bộ nhớ hơn thế, bạn vừa tìm được một cải thiện chỉ tốn một dòng đổi API.
Phần sau: bẫy khi đo — những sai lầm tôi đã mắc trong bốn mươi sáu phần trước.