Gọi một hàm trong chương trình của bạn giống như hỏi người ngồi cùng bàn một câu — quay sang, hỏi, nghe đáp, xong. Gọi một lời gọi hệ thống thì giống như phải ra quầy lễ tân, xuất trình giấy tờ, chờ nhân viên xử lý rồi quay lại chỗ ngồi: cùng một câu hỏi, nhưng cái thủ tục băng qua ranh giới mới là phần tốn thời gian, không phải câu trả lời. Bốn mươi sáu phần trước của sê-ri liên tục nhắc "đừng gọi syscall trong vòng lặp nóng", mà tôi chưa lần nào đo xem đắt là bao nhiêu. Bài này đo, rồi bóc xem 100 nanô giây kia gồm những gì.

(Các con số đo trên VM Apple M4, là thời gian trên mỗi lần gọi, không phải perf counter phần cứng.)

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ố 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. Rồi đo thêm một lượt, 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ế.

Hai điều đáng khắc vào đầu

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 rồi 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 vì chúng trả lời hai câu hỏi khác nhau, nhưng trộn lẫn 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ệ đó — âm thầm, không dòng cấu hình nào của bạn nhắc tới nó.

Vì sao vượt biên lại đắt đến thế

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ộ — và bộ dự đoán nhánh lẫn 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(), và hai con số nằm sát nhau như vậy chính là bằng chứng cache không còn ở đó.

Quy 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. Nhưng 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, và đây là lúc readv/writev, đệm ở tầng người dùng, hay io_uring bắt đầu trả công. Đọc từng byte bằng read() không đệm trả 115 ns mỗi byte; đệm 4 KB đưa con số đó xuống 0,03 ns mỗi byte — đó là toàn bộ lý do stdio tồn tại. Và nếu gọi clock_gettime trong vòng lặp đo đạc thì hãy chắc nó đi qua vDSO: đang đo thứ gì cỡ chục nanô giây mà đồng hồ tốn 122 ns thì bạn đang đo cái đồng hồ.

Có một cách kiểm nhanh xem ứng dụng của bạn có trong vùng nguy hiểm 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 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 hết đất mới nghĩ tới đổi cơ chế I/O. Cũng nên nhớ 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 tranh chấp, tiến trình đang chạy; khi máy 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à cái giá thật khi ấy không còn là 100 ns 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 đã đo được rằng syscall là nút cổ chai và 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.

Mẫu số chung

Cái sự thật "băng qua một ranh giới đắt hơn nhiều lần so với công việc ở hai đầu ranh giới, nên phải gộp các lần băng lại" không riêng gì syscall — nó là một trong những nguyên lý hiệu năng phổ quát nhất.

Trong thế giới Java, đây đúng là chi phí của một cú JNI (vượt biên JVM ↔ mã native, đắt hơn hẳn một lời gọi method thường) và, quen thuộc hơn, là round-trip JDBC: mỗi câu query là một lần đi-về qua mạng tới DB, nên addBatch()/executeBatch() tồn tại để gộp nghìn lần chèn thành một lần băng — đúng cùng logic writev gộp nhiều buffer thành một syscall.

Trong Go, mỗi lần cgo gọi ra C mang chi phí vượt biên đáng kể so với một lời gọi hàm Go thuần, nên lời khuyên chuẩn là "đừng gọi cgo trong vòng lặp nóng, gộp công việc lại rồi băng một lần" — chép nguyên câu "đừng syscall trong vòng lặp nóng". Bản thân runtime Go cũng gộp: netpoller gom nhiều thao tác mạng để giảm số lần vào nhân.

Còn ở cơ sở dữ liệu, đây là bài học kinh điển nhất và tốn tiền nhất: vấn đề N+1 query — một vòng lặp gọi DB mỗi vòng — chậm không phải vì mỗi query nặng mà vì mỗi query là một lần băng qua ranh giới mạng khoảng cỡ trăm micro giây; gộp thành một IN (...) hay một JOIN là biến N lần băng thành một, đúng nguyên tắc gộp syscall ở đây phóng to lên nghìn lần.

Mẫu số chung: chi phí thật thường không nằm ở công việc mà ở việc băng qua ranh giới để tới nơi làm việc — user↔kernel, JVM↔native, app↔DB — và mỗi ranh giới đắt hơn một lời gọi trong nhà nhiều bậc độ lớn, nên đòn bẩy lớn nhất gần như luôn là gộp nhiều lần băng thành một, không phải làm mỗi lần băng nhanh hơn. Vì thế câu hỏi đúng khi một hệ chậm không phải "mỗi thao tác có nhanh không" mà là "tôi đang băng qua ranh giới bao nhiêu lần, và gộp được xuống mấy lần" — vì đó mới là con số nhân với cái giá trăm-nanô-giây (hay trăm-micro-giây) kia.

Muốn tự đo chênh lệch ấy, và cả cái giá seccomp, chạy trong 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ả.