Hình dung văn phòng của bạn có một phòng trong khóa kín, muốn nhờ việc gì phải qua một cửa an ninh: quẹt thẻ, ghi sổ, cửa mở, vào, xong việc, ra, cửa đóng. Cái nghi thức qua cửa đó tốn thời gian cố định — dù bạn vào để bê một thùng hồ sơ hay chỉ hỏi đúng một câu. Còn ngoài sảnh, văn phòng dán sẵn một bảng thông tin tự phục vụ (giờ giấc, vài số liệu chung) mà bạn liếc đọc ngay, khỏi qua cửa. system call chính là cái cửa an ninh giữa chương trình của bạn (sảnh, user mode) và nhân hệ điều hành (phòng trong, kernel) — và một vài lời gọi tưởng là "vào phòng" thật ra chỉ liếc cái bảng ngoài sảnh.
Mở sê-ri mới về hệ điều hành — cùng tinh thần "đo thật" như sê-ri đồng thời vừa khép lại: mỗi bài một phép đo trong container, một con số thay cho một lời khẳng định. Bài đầu tiên hỏi câu nền tảng nhất: khi chương trình của bạn nhờ hệ điều hành làm một việc — đọc file, lấy giờ, hỏi PID — nó thực hiện một system call, và lời gọi đó tốn bao nhiêu? Câu trả lời định hình mọi thứ về sau: vì sao I/O theo lô nhanh hơn, vì sao clock_gettime gọi được hàng triệu lần mỗi giây mà read thì không. Tôi đo trong container gcc:13 (ARM), và vấp đúng một mẩu folklore đã lỗi thời.
Vì sao vượt ranh giới lại tốn
Một system call không phải một lời gọi hàm bình thường. Chương trình của bạn chạy ở user mode — chế độ đặc quyền thấp, không được đụng trực tiếp phần cứng hay bộ nhớ nhân. Muốn nhờ nhân (kernel) làm việc — mở file, cấp bộ nhớ, lấy PID — CPU phải vượt ranh giới user → kernel: lưu trạng thái thanh ghi, chuyển sang chế độ đặc quyền cao (kernel mode), nhảy vào mã nhân, làm việc, rồi đảo ngược toàn bộ để quay ra. Đúng cái nghi thức quẹt thẻ qua cửa an ninh. Chuỗi thao tác đó — trên phần cứng hiện đại còn kèm chi phí phòng thủ Spectre/Meltdown — là cái giá cố định của mỗi lần gọi, bất kể việc bên trong to hay nhỏ.
Để đo cái giá đó cho gọn, tôi chọn getpid — syscall gần như không làm gì (chỉ trả về PID). Thời gian nó tốn gần như thuần là chi phí vượt ranh giới, không lẫn công việc thật. Rồi so với ba mốc khác.
Đo: 103ns cho một syscall, 12,7ns qua vDSO
Bốn phép đo, mỗi cái lặp 2 triệu lần và lấy min (đọc volatile để -O2 không tối ưu mất vòng lặp):
syscall(SYS_getpid) THẬT : 103,1 ns/lần
getpid() qua glibc : 100,1 ns/lần (KHÔNG cache!)
clock_gettime (vDSO) : 12,7 ns/lần
phép tính user thuần (mốc) : 0,23 ns/lần
-> syscall/user = 442× | vDSO/user = 55× | syscall/vDSO = 8×
Một system call thật tốn ~103 ns — nghe nhỏ, nhưng so với một phép tính thường (0,23 ns) thì đắt gấp ~450 lần. Đó là lý do một vòng lặp gọi read từng byte thảm hại so với đọc cả khối một lần: bạn không trả tiền cho việc đọc, bạn trả tiền cho hàng triệu lần vượt ranh giới. Chi phí syscall là nền tảng cho gần như mọi tối ưu I/O của hệ điều hành — gộp lô, buffer, readahead — tất cả đều nhằm giảm số lần vượt ranh, đúng tinh thần amortize mà sê-ri trước đã đo đi đo lại.
Nhưng con số thú vị nhất là dòng thứ ba: clock_gettime chỉ 12,7 ns — rẻ hơn một syscall thật 8 lần. Nó trông như một syscall (lấy giờ từ hệ thống), nhưng thực ra không vào nhân. Đó là nhờ vDSO — cái bảng thông tin tự phục vụ ngoài sảnh.
(Môi trường đo: container gcc:13 trên host ARM AArch64, mỗi phép lặp 2 triệu lần lấy min. Con số đổi theo CPU/kernel; điều bền vững là thứ bậc: syscall thật >> vDSO >> một phép tính, và getpid không còn rẻ như folklore.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với hai niềm tin từ sách cũ, ngược chiều nhau. Thứ nhất: "getpid() được glibc cache — hỏi kernel một lần rồi nhớ, nên các lần sau gần như free". Nhìn dòng hai: getpid() qua glibc tốn 100,1 ns — gần y hệt syscall thô 103,1 ns. Nếu còn cache PID, con số phải gần 0,23 ns (một lần đọc biến). Nó không — vì glibc hiện đại (từ 2.25) đã bỏ cache PID: cache đó từng sai sau fork/clone (con thấy PID cũ của cha), một nguồn bug tinh vi, nên glibc quyết định luôn hỏi nhân cho đúng. Folklore "getpid rẻ vì được cache" từng đúng, giờ không còn — và chỉ đo mới biết, vì nó vẫn nằm trong nhiều sách và câu trả lời trên mạng.
Chiều ngược lại cũng sai: "hàm nào trông như một lời gọi hệ thống thì đều phải vào nhân". clock_gettime trông như syscall nhưng chỉ 12,7 ns, vì nó đi qua vDSO (virtual dynamic shared object) — một trang mã và dữ liệu mà nhân ánh xạ sẵn vào không gian địa chỉ của mọi tiến trình. Với vài lời gọi "chỉ đọc" mà nhân có thể phơi bày an toàn (lấy giờ, lấy PID ảo), hàm chạy hoàn toàn trong user mode, đọc dữ liệu nhân cập nhật sẵn, không vượt ranh giới — đúng liếc bảng ngoài sảnh thay vì qua cửa. Hai niềm tin gặp nhau ở một sự thật: vẻ ngoài của một lời gọi không cho biết giá của nó; chỉ việc có vượt ranh giới user↔kernel hay không mới quyết định — cùng một dáng "lấy thông tin hệ thống", hai cái giá chênh 8 lần. Phải đo, hoặc biết cái nào đi vDSO.
Về đến code hằng ngày
Điều đầu tiên: giảm số system call trong đường nóng. Nếu code của bạn gọi read/write/gettimeofday hàng triệu lần, chi phí không nằm ở việc bên trong mà ở ~100 ns vượt ranh mỗi lần. Gộp lô (đọc/ghi khối lớn), buffer (như stdio làm), hay cache kết quả — mọi cách đều nhằm trả cái ~100 ns đó ít lần hơn. Đây là lý do vật lý đằng sau mọi buffer I/O bạn từng dùng.
Điều thứ hai: biết hàm nào đi vDSO. clock_gettime, gettimeofday, time, và (tùy kiến trúc) vài cái khác chạy qua vDSO nên rẻ ~8 lần một syscall — gọi thoải mái hơn nhiều. Ngược lại, đừng cho rằng một hàm "nhẹ" như getpid là free: nó vẫn vào nhân mỗi lần. Khi nghi ngờ, đo — hoặc strace để xem lời gọi nào thật sự chạm nhân.
Chỗ này chi phí syscall là "thuế nền" mà cả thế giới hiệu năng xoay quanh. Đây là lý do System.nanoTime() của Java và các lời đọc giờ của Go rẻ (chúng đi vDSO dưới nền) trong khi mỗi read/write nhỏ thì đắt — nên BufferedReader, bufio, mọi tầng buffer sinh ra để gộp syscall. Đây là động lực của io_uring (Linux): nộp hàng nghìn thao tác I/O qua một điểm vào thay vì một syscall mỗi cái. Và runtime Go tự lập lịch goroutine trong user-space chính để tránh trả syscall cho mỗi lần chuyển. Ngược lại, thêm một tầng như seccomp (lọc syscall) làm mỗi lời gọi đắt thêm — một cái giá đo được ở dịch vụ gọi syscall dày. Mẫu số chung: mỗi lần chạm nhân là ~100 ns thuế, và kiến trúc hiệu năng cao là nghệ thuật chạm nhân ít lần nhất.
Muốn tự thấy thì chạy strace -c ./chương-trình-của-bạn (hoặc strace -c một lệnh quen như ls) và nhìn cột đếm: chương trình gọi bao nhiêu system call, và cái nào nhiều nhất? Mỗi lời gọi trong bảng đó là ~100 ns vượt ranh giới (trừ các cái đi vDSO không hiện ra vì không chạm nhân). Nếu một syscall xuất hiện hàng chục nghìn lần, đó là ứng viên để gộp lô hoặc buffer. Vài phút với strace -c cho bạn thấy chương trình thật sự nhờ hệ điều hành làm gì bao nhiêu lần — và thường lộ ra một vòng lặp gọi read/write từng mẩu nhỏ mà chỉ cần đọc/ghi khối lớn là nhanh lên nhiều lần.