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. 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.
Một lần tôi đo hớ: getpid không còn được cache
Tôi vào đo với hai niềm tin từ sách cũ. Thứ nhất: "getpid() được glibc cache — nó hỏi kernel một lần rồi nhớ, nên các lần sau gần như free". Thứ hai, ngược lại: "hàm nào trông như một lời gọi hệ thống thì đều phải vào nhân".
Phép đo sửa cả hai. Nhìn dòng hai: getpid() gọi qua glibc tốn 100,1 ns — gần y hệt syscall thô 103,1 ns. Nếu glibc còn cache PID, con số này 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. Lý do: cache đó từng sai sau fork/clone trong một số trường hợp (tiến trình 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 "gọi getpid rẻ vì được cache" từng đúng, giờ không còn đúng — và chỉ đo mới biết, vì cái niềm tin đó 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 bị lật: clock_gettime trông như syscall nhưng chỉ 12,7 ns, vì nó đi qua vDSO (virtual dynamic shared object). vDSO là 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. Bài học đo lường: "trông như syscall" không cho biết nó có vào nhân hay không — phải đo, hoặc biết cái nào đi vDSO. Cùng một vẻ ngoài (một lời gọi hàm lấy thông tin hệ thống), hai cái giá chênh nhau 8 lần.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầ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.
Hệ quả 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 — bạn gọi chúng 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.
Hệ quả thứ ba là tinh thần đo lường, mở màn cho cả sê-ri hệ điều hành: đừng tin folklore về chi phí hệ thống — đo trên máy của bạn. Con số mang theo: một system call thật tốn ~103 ns (getpid, ~450× một phép tính thường 0,23 ns) vì phải vượt ranh giới user↔kernel; getpid() qua glibc KHÔNG còn được cache (glibc 2.25+ bỏ cache vì sai sau fork) nên vẫn ~100 ns; còn clock_gettime chỉ 12,7 ns (rẻ 8×) vì đi qua vDSO — nhân ánh xạ sẵn dữ liệu để hàm chạy trong user mode, không vào nhân. "Trông như syscall" không quyết định giá; có vượt ranh giới hay không mới quyết định.
Thử ba mươi giây
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. Ba mươi giây 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.