Bài đầu sê-ri đo chi phí một lời gọi hệ thống (~trăm ns) và giải thích vì sao nó đắt. Ở phần gần cuối này, tôi muốn nhìn thẳng vào cái ranh giới mà lời gọi đó phải vượt qua — ranh giới giữa user mode (nơi code của bạn chạy, quyền hạn chế) và kernel mode (nơi nhân chạy, toàn quyền) — và chứng minh rằng chính cú vượt ranh giới mới là chi phí, chứ không phải việc nhân làm. Tôi đo ba thứ trong container gcc:13: một lời gọi hàm thường, một syscall thật, và một lời gọi "vào hệ thống" mà không vượt ranh giới (vDSO). Ba con số vẽ nên toàn bộ câu chuyện.
Vượt ranh giới là gì
CPU chạy code ở các mức đặc quyền (trên x86 gọi là ring). Code ứng dụng chạy ở ring 3 — không được đụng thẳng phần cứng, bộ nhớ nhân, hay bảng trang. Nhân chạy ở ring 0 — toàn quyền. Khi bạn gọi một syscall (đọc file, gửi mạng, hỏi giờ nhân), CPU phải chuyển từ ring 3 sang ring 0, làm việc, rồi chuyển ngược lại. Cú chuyển đó không phải một lệnh call bình thường; nó gồm: (1) đổi mức đặc quyền (một lệnh đặc biệt như syscall/sysret), (2) lưu rồi khôi phục trạng thái — thanh ghi, con trỏ lệnh — và chuyển sang stack của nhân, và (3) từ sau Spectre/Meltdown, nhân còn phải xả một phần cache/TLB/trạng thái speculation ở ranh giới để chống rò rỉ dữ liệu. Ba việc đó là cái giá cố định của mỗi lần vượt ranh giới, bất kể việc trong nhân to hay nhỏ.
Tôi kiểm chứng bằng cách so ba thứ: một hàm user-space thuần (noinline để nó thật sự được gọi), một syscall thật (syscall(SYS_getpid) — dùng trực tiếp để tránh glibc cache), và clock_gettime (chạy qua vDSO — sẽ giải thích).
Đo: 0,74 ns, 103 ns, và 13 ns
goi ham user-space (noinline) : 0,74 ns/lần (KHÔNG vượt ranh giới)
syscall(SYS_getpid) THẬT : 103 ns/lần (VƯỢT ranh giới user->kernel) = ~139× một hàm
clock_gettime (vDSO, user-space): 13 ns/lần (gọi hệ thống nhưng KHÔNG vượt ranh giới)
Một lời gọi hàm thường tốn 0,74 ns — gần như free, chỉ là một call/ret. Một syscall thật (getpid, một trong những syscall rẻ nhất vì nhân chỉ đọc một con số) tốn 103 ns — gấp ~139 lần một lời gọi hàm. Nhớ: getpid gần như không làm gì trong nhân; toàn bộ 103ns đó là chi phí vượt ranh giới, không phải chi phí công việc.
Dòng thứ ba là bằng chứng đắt giá. clock_gettime(CLOCK_MONOTONIC) cũng là "hỏi hệ thống một dữ liệu" (thời gian, do nhân quản lý), nhưng nó chạy qua vDSO — một vùng mã nhân ánh xạ sẵn vào không gian user, nên nó đọc thời gian ngay trong user mode, không vượt ranh giới. Kết quả: chỉ 13 ns — rẻ hơn một syscall thật ~8 lần. Nếu chi phí của "hỏi hệ thống" nằm ở việc nhân làm, thì vDSO không thể rẻ như vậy. Nó rẻ chính vì nó tránh được cú vượt ranh giới. Đây là bằng chứng trực tiếp: cú vượt ranh giới, không phải công việc, mới là chi phí.
Amortize: đếm số lần vượt, không phải số byte
Nếu mỗi lần vượt ranh giới tốn ~100ns cố định, thì cách giảm không phải làm nhân nhanh hơn, mà là vượt ít lần hơn. Tôi đo bằng cách ghi 64 MB ra /dev/null (nơi không tốn I/O thật, chỉ đo chi phí syscall) theo hai cách:
Ghi 64 MB ra /dev/null:
1 byte mỗi write : 67.108.864 lần vượt ranh giới -> 7,74 s, 8,3 MB/s (115 ns/lần vượt)
64 KB mỗi write : 1.024 lần vượt ranh giới -> 0,12 ms (nhanh ~66.000×, CÙNG dữ liệu)
Ghi từng byte một buộc 67 triệu lần vượt ranh giới — và mất 7,74 giây, chỉ 8,3 MB/s. Chia ra: mỗi lần vượt ~115 ns (khớp con số getpid, write đắt hơn chút). Ghi cùng 64 MB đó nhưng theo khối 64 KB chỉ cần 1.024 lần vượt — và xong trong 0,12 mili-giây, nhanh hơn 66.000 lần. Dữ liệu y hệt; khác biệt duy nhất là số lần bạn vượt ranh giới. Đây chính là lý do I/O có đệm tồn tại, và vì sao các API hiện đại (readv/writev, io_uring, sendmmsg) đều nhằm gộp nhiều việc vào một cú vượt.
Một lần tôi đo hớ: "syscall cũng như gọi một hàm"
Tôi vào đo với hình dung đơn giản: "gọi một lời gọi hệ thống thì cũng như gọi một hàm — chỉ là hàm nằm trong nhân". Đo phá tan: một syscall thật đắt 139 lần một lời gọi hàm, dù việc trong nhân (getpid) gần như bằng không. Và vDSO cho thấy vì sao: khi tránh được cú vượt ranh giới, "hỏi hệ thống" tụt từ 103ns xuống 13ns. Chi phí không nằm ở "vào nhân làm việc" mà ở ranh giới — cú đổi đặc quyền, lưu/khôi phục trạng thái, và xả cache/TLB.
Bài học đo lường: ranh giới user/kernel có một cái giá cố định cho mỗi lần vượt, độc lập với việc bạn nhờ nhân làm gì; nên chi phí syscall được quyết định bởi số lần vượt, không phải khối lượng công việc — và cách rẻ là vượt ít lần hơn (batch) hoặc không vượt (vDSO). Nếu tôi tin "syscall như gọi hàm", tôi đã rải chúng thoải mái trong vòng nóng (ghi từng byte, hỏi giờ bằng syscall trong mỗi vòng lặp) và trả giá hàng chục nghìn lần mà không hiểu vì sao. Con số 66.000× cho cùng 64 MB nói rõ: tối ưu I/O thường là tối ưu số lần vượt ranh giới, không phải tốc độ xử lý.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: gộp việc để vượt ranh giới ít lần hơn. Đừng đọc/ghi từng byte hay từng phần tử nhỏ qua syscall; dùng buffer lớn (đọc 64 KB thay vì 1 byte), gộp nhiều thao tác (writev, sendmmsg), hay dùng io_uring để nộp một loạt việc trong một cú vượt. Bất cứ khi nào bạn thấy một syscall trong một vòng lặp chặt, hãy hỏi: mình có thể làm nó một lần cho cả lô không?
Hệ quả thứ hai: biết cái gì KHÔNG vượt ranh giới — dùng nó ở đường nóng. Vài "lời gọi hệ thống" chạy qua vDSO và gần như free: clock_gettime, gettimeofday, getcpu, time. Nếu bạn cần lấy giờ hàng triệu lần (đo đạc, timestamp log), dùng clock_gettime (vDSO) chứ đừng dùng một syscall thật — chênh 8 lần. Đọc phần vDSO ở bài clock để biết loại nào rẻ.
Hệ quả thứ ba là tinh thần đo lường: đếm số lần vượt ranh giới, không đếm số byte hay số lời gọi API. Con số mang theo: vượt ranh giới user→kernel tốn ~100-140× một lời gọi hàm (103 ns vs 0,74 ns) vì đổi mức đặc quyền + lưu/khôi phục trạng thái + xả cache/TLB (hậu Spectre/Meltdown), độc lập với việc nhân làm (getpid gần như không làm gì vẫn 103 ns); bằng chứng là vDSO (clock_gettime) chạy user-space KHÔNG vượt ranh giới nên chỉ 13 ns, rẻ hơn syscall thật 8×; và ghi cùng 64 MB qua 67M lần vượt (1 byte/write, 7,7 s) chậm hơn 66.000× so với 1.024 lần vượt (64 KB/write). Giảm chi phí = vượt ít lần hơn (batch) hoặc tránh vượt (vDSO). Cái đắt là ranh giới, và bạn trả nó theo số lần chạm, không theo lượng việc.
Thử ba mươi giây
Viết một vòng lặp gọi syscall(SYS_getpid) một triệu lần và bấm giờ; rồi một vòng lặp gọi một hàm rỗng của chính bạn (đánh dấu noinline) một triệu lần và bấm giờ. Bạn sẽ thấy cái syscall chậm hơn hàng trăm lần — dù getpid gần như không làm gì. Rồi thử ghi một megabyte ra /dev/null theo hai cách: từng byte một, và một lần cả buffer — cái từng byte sẽ chậm đến khó tin, chỉ vì nó vượt ranh giới hàng triệu lần. Ba mươi giây đó cho bạn thấy điều mà lời gọi hàm quen thuộc che giấu: giữa code của bạn và nhân có một cái ranh giới thật, mỗi lần vượt qua có giá, và phần lớn nghệ thuật viết code hiệu năng hệ thống là vượt qua nó ít lần nhất có thể.