Hình dung ranh giới hải quan giữa hai quốc gia. Đi lại trong nước bạn thì tự do, tức thì. Nhưng mỗi lần qua cửa khẩu, bạn phải xếp hàng, xuất trình giấy tờ, bị soi hành lý — một thủ tục cố định, tốn như nhau dù bạn mang một món đồ hay cả vali. Nếu phải qua cửa khẩu một lần cho mỗi món hàng, bạn sẽ mất cả ngày; gom hết vào một chuyến thì nhanh gấp bội. Máy tính có đúng một cửa khẩu như vậy giữa user mode (code của bạn) và kernel mode (nhân), và mỗi lần "qua cửa" — mỗi lời gọi hệ thống — trả một cái giá cố định cho thủ tục qua cửa, không phải cho việc nhân làm.
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 — giữa user mode (quyền hạn chế) và kernel mode (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 — đúng cái thủ tục xếp-hàng-soi-hành-lý ở cửa khẩu — 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, đi lại trong nhà. 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 — như một quầy tự phục vụ đặt ngay bên phía bạn, khỏi qua cửa khẩu. Đâ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 — một món qua cửa mỗi chuyến, hay cả vali một chuyến. Đâ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.
Hai niềm tin đối nghịch, cùng sai
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". Con số 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.
Còn một niềm tin ngược cũng đáng chỉnh: "syscall đắt thế thì phải tránh bằng mọi giá, càng ít syscall càng tốt tuyệt đối". Không hẳn — một syscall lẻ ~100ns là rẻ nếu bạn chỉ gọi thi thoảng; cái giết bạn là gọi nó hàng triệu lần trong vòng lặp. Đừng vặn vẹo code để bỏ một syscall khởi tạo gọi một lần; hãy nhắm vào những cú vượt lặp lại trên đường nóng. Nói gọn: ranh giới có giá cố định 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í được quyết định bởi số lần vượt, và cách rẻ là vượt ít lần hơn (batch) hoặc không vượt (vDSO), không phải né syscall một cách mù quáng.
Về đến code hằng ngày
Điều đầ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?
Điều 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.
Điều này cũng lý giải chuyện hiệu năng ở tầng cao mà thoạt nhìn khó hiểu. Đây là toàn bộ lý do tồn tại của I/O có đệm trong mọi ngôn ngữ (BufferedReader/BufferedWriter của Java, bufio của Go, buffer của stdio C) — chúng gom nhiều thao tác nhỏ để trả một cú vượt cho cả lô. io_uring của Linux (và IOCP của Windows) sinh ra để đẩy hàng nghìn thao tác I/O qua một điểm vào duy nhất, cắt số lần vượt xuống gần bằng không cho server tải cao. Và các runtime có nhiều "green thread" (goroutine của Go, virtual thread của Java) gom I/O của hàng nghìn tác vụ qua một tầng epoll chung, thay vì mỗi luồng OS tự vượt ranh giới. Cùng một bài học đi-hải-quan-theo-lô, lặp lại ở mọi tầng phần mềm.
Muốn tự thấy thì 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. Vài phút đó 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ể.