Bốn mươi sáu phần trước của sê-ri này liên tục chạm vào cùng một câu nói: "đừng gọi syscall trong vòng lặp nóng". Chưa lần nào tôi đo xem đắt nghĩa là bao nhiêu. Bài này đo: cùng một máy, cùng một trình biên dịch, so một lời gọi hàm thường với một lời gọi hệ thống, rồi bóc xem 100 nanô giây kia gồm những gì.
Bảng số liệu
Máy: Apple M4, container debian:12-slim kiến trúc aarch64, nhân 7.0.12-linuxkit, biên dịch bằng gcc -O2. Mỗi phép chạy 3 triệu tới 200 triệu lần; mỗi con số dưới đây là trung vị của ba lần chạy độc lập.
| Việc | ns mỗi lần | So với gọi hàm |
|---|---|---|
| Vòng lặp rỗng (nền) | 0,23 | — |
Gọi hàm thường (noinline) |
0,70 | 1× |
clock_gettime qua vDSO |
11,39 | 16× |
getpid() của glibc |
100,30 | 143× |
syscall(SYS_getpid) thô |
100,65 | 144× |
write(/dev/null, 1 byte) |
115,41 | 165× |
clock_gettime ép qua syscall |
121,77 | 174× |
Ba lần chạy lệch nhau dưới 5%. Con số lớn nhất là getpid() ở lần thứ ba — 104,91 ns so với 100,08 và 100,30 — nên tôi lấy trung vị chứ không lấy trung bình.
Đo thêm một lượt nữa, lần này bật tắt bộ lọc seccomp mà Docker gắn sẵn:
| Cấu hình | getpid() |
write(/dev/null) |
|---|---|---|
| Mặc định của Docker | 100,12 ns | 114,60 ns |
--security-opt seccomp=unconfined |
81,15 ns | 95,68 ns |
| Chênh | 18,97 ns | 18,92 ns |
Ba lần chạy không seccomp cho 81,15 / 81,15 / 81,17 ns. Tôi hiếm khi thấy dãy số nào sạch hơn thế.
Điều đáng nhớ
Nói lại theo cách khác, vì đây là chỗ dễ nhớ sai:
Một lời gọi hệ thống rỗng đắt bằng khoảng 143 lời gọi hàm. Rỗng nghĩa là nó không làm gì có ích — getpid() chỉ đọc một số nguyên đã nằm sẵn trong cấu trúc tiến trình. Toàn bộ 100 ns là chi phí đi sang nhân và quay về, không phải chi phí của công việc.
Nếu trừ nền vòng lặp 0,23 ns ở cả hai vế thì tỷ lệ thật còn cao hơn: (100,30 − 0,23) / (0,70 − 0,23) ≈ 213 lần. Không trừ nền là tự khai khống chi phí gọi hàm lên 49%, và tỷ lệ tụt từ 213 xuống 143. Cả hai con số đều đúng — chúng trả lời hai câu hỏi khác nhau — nhưng trộn lẫn chúng thì sai.
Và 19 ns trong số đó là thứ bạn không hề khai báo. Docker bật một bộ lọc seccomp mặc định cho mọi container, chặn khoảng 40 lời gọi hệ thống. Cái giá là một đoạn BPF chạy trên mọi syscall, kể cả những syscall được cho qua. Gần 19% chi phí syscall trong container là tiền trả cho lớp bảo vệ đó.
Vì sao
Trên aarch64, một lời gọi hệ thống là lệnh svc #0. Nó không phải một cú nhảy — nó là một cái bẫy có chủ đích, đẩy CPU từ vòng đặc quyền người dùng (EL0) sang vòng nhân (EL1).
Gọi hàm thường thì CPU ở nguyên chỗ cũ: một lệnh bl, một lệnh ret, bộ dự đoán nhánh biết trước đích đến nên đường ống gần như không vỡ. 0,47 ns sau khi trừ nền, tức khoảng hai chu kỳ.
Lời gọi hệ thống thì phải làm đủ những việc sau, mỗi lần: đổi ngữ cảnh đặc quyền, lưu thanh ghi người dùng vào khung ngắt, chuyển sang ngăn xếp nhân, tra bảng syscall, chạy bộ lọc seccomp, làm việc, rồi khôi phục ngược lại toàn bộ. Bộ dự đoán nhánh và bộ đệm lệnh đều mất ngữ cảnh ở ranh giới đó.
Cái khiến vDSO đáng giá nằm đúng ở chỗ này. Nhân ánh xạ một trang mã vào không gian địa chỉ của tiến trình; clock_gettime trong trang đó đọc thẳng thanh ghi bộ đếm của CPU và tự tính, không có svc nào cả. Kết quả: 11,39 ns thay vì 121,77 ns cho đúng một phép tính — nhanh gấp 10,7 lần chỉ nhờ không rời khỏi EL0.
Còn một chi tiết ngược trực giác cần nói riêng: getpid() của glibc bằng đúng syscall(SYS_getpid) thô — 100,30 so với 100,65 ns. Nhiều tài liệu cũ (và trí nhớ của tôi) nói glibc cache PID nên getpid() gần như miễn phí. Điều đó đã đúng, và không còn đúng từ glibc 2.25: bộ cache bị gỡ vì nó sai sau vài đường clone(). Hai con số nằm sát nhau như vậy chính là bằng chứng cache không còn ở đó.
Nghĩa là gì trong thực tế
Quy đổi sang thứ dùng được:
- 10.000 syscall mỗi giây tốn khoảng 1 ms CPU mỗi giây — 0,1% một nhân. Không đáng lo.
- 1 triệu syscall mỗi giây tốn 100 ms mỗi giây — 10% một nhân, chỉ để chuyển vòng. Đây là lúc
readv/writev, đệm ở tầng người dùng, hayio_uringbắt đầu trả công. - Đọc từng byte bằng
read()không đệm: mỗi byte trả 115 ns. Đệm 4 KB đưa con số đó xuống 0,03 ns mỗi byte. Đó là toàn bộ lý dostdiotồn tại. - Gọi
clock_gettimetrong vòng lặp đo đạc thì hãy chắc là nó đi qua vDSO. Nếu bạn đang đo thứ gì cỡ chục nanô giây mà đồng hồ tốn 122 ns, bạn đang đo cái đồng hồ.
Có một cách kiểm tra nhanh xem ứng dụng của bạn có nằm trong vùng nguy hiểm hay không: đếm syscall trong một giây bằng strace -c -f -p <pid> rồi nhân với 100 ns. Nếu tích đó vượt quá vài phần trăm ngân sách CPU, việc đáng làm không phải là tối ưu từng lời gọi mà là gộp chúng lại. Thứ tự ưu tiên gần như luôn là: bỏ bớt syscall trước, gộp nhiều lần thành một lời gọi véc-tơ sau, và chỉ khi cả hai đều hết đất mới nghĩ tới chuyện đổi cơ chế I/O.
Cũng nên nhớ rằng 100 ns là chi phí một chiều đi và về trong điều kiện tốt nhất: bộ đệm còn nóng, không có tranh chấp, tiến trình đang chạy. Khi máy đang tải nặng, mỗi lần vào nhân là một cơ hội để bộ lập lịch lấy lại CPU của bạn, và lúc đó cái giá thật không còn là 100 ns nữa mà là cả một lát thời gian bị mất.
Về seccomp: 19 ns mỗi syscall là cái giá tôi vẫn trả. Tắt bộ lọc để lấy lại 19% chỉ hợp lý khi bạn đã đo được rằng syscall là nút cổ chai và bạn chấp nhận đánh đổi bảo mật. Với phần lớn dịch vụ, giảm số lượng syscall rẻ hơn nhiều so với làm mỗi syscall nhanh hơn.
Chỗ tôi không kết luận được
Con số tuyệt đối này không phải con số của máy chủ Linux thật. Container ở đây chạy trong máy ảo linuxkit của Docker Desktop trên macOS. Tôi không tách được phần nào của 100 ns là chi phí syscall thuần và phần nào là chi phí ảo hoá. Phần 48 của sê-ri đo đúng chuyện đó — cùng một tải trên ba nơi khác nhau.
Cái tôi tin là tỷ lệ, vì cả hai vế đều đo trên cùng một máy trong cùng một lần chạy: 143 lần so với gọi hàm, 10,7 lần cho vDSO, 19 ns cho seccomp.
Tôi cũng không đo vfork, mmap hay bất kỳ syscall nào thật sự làm việc. write(/dev/null, 1 byte) chỉ đắt hơn getpid() 15 ns — nhưng /dev/null là trường hợp rẻ nhất có thể của một lời ghi. Đừng suy ra từ đây rằng mọi syscall đều tốn 100 ns.
Một sai lầm khi đo, và cách nó lộ ra. Lần chạy so seccomp đầu tiên của tôi trả về unknown flag: --security-opt seccomp. Tôi đã gói cờ vào một biến rồi truyền vào hàm shell — và zsh, khác bash, không tách từ khi khai triển tham số không đặt trong nháy. Docker nhận đúng một đối số "--security-opt seccomp=unconfined" thay vì hai. Điều may là nó nổ ra thành lỗi rõ ràng; nếu cờ đó lỡ bị nuốt im lặng, tôi đã so hai lần chạy giống hệt nhau và kết luận seccomp miễn phí.
Thử ba mươi giây
cat > /tmp/s.c <<'EOF'
#define _GNU_SOURCE
#include <stdio.h>
#include <stdint.h>
#include <time.h>
#include <unistd.h>
static volatile long sink;
__attribute__((noinline)) static long plain(long x){return x+1;}
static uint64_t now(void){struct timespec t;clock_gettime(CLOCK_MONOTONIC,&t);
return (uint64_t)t.tv_sec*1000000000ull+t.tv_nsec;}
#define RUN(n,N,b) do{uint64_t a=now();for(long i=0;i<(N);i++){b;}uint64_t z=now();\
printf("%-22s %8.2f ns/op\n",n,(double)(z-a)/(double)(N));}while(0)
int main(void){struct timespec t;
RUN("vong lap rong",100000000L,sink=i);
RUN("goi ham thuong",100000000L,sink=plain(i));
RUN("clock_gettime vDSO",10000000L,clock_gettime(CLOCK_MONOTONIC,&t));
RUN("getpid()",2000000L,sink=getpid());
return 0;}
EOF
# co seccomp mac dinh
docker run --rm -v /tmp/s.c:/s.c:ro debian:12-slim sh -c \
'apt-get -qq update && apt-get -qq install -y gcc && cc -O2 -o /s /s.c && stdbuf -o0 /s'
# khong seccomp - so hai con so getpid()
docker run --rm --security-opt seccomp=unconfined -v /tmp/s.c:/s.c:ro debian:12-slim sh -c \
'apt-get -qq update && apt-get -qq install -y gcc && cc -O2 -o /s /s.c && stdbuf -o0 /s'
Chạy hai lệnh và nhìn đúng một cặp số: getpid() có và không có seccomp. Nếu chúng bằng nhau, cờ của bạn đã bị nuốt — kiểm tra lại shell trước khi tin kết quả.