Đây là bài mở đầu một sê-ri mới — "Hệ điều hành cho lập trình viên" — nối tiếp tinh thần của sê-ri mạng: không tin lời mô tả, mà dựng lên và đo. Và không có điểm khởi đầu nào hợp hơn ranh giới cơ bản nhất của hệ điều hành: lời gọi hệ thống (system call), cái cửa mà mọi chương trình phải đi qua để nhờ nhân làm hộ những việc nó không được tự làm — đọc file, mở kết nối, cấp bộ nhớ. Cửa đó không miễn phí. Bài này đo cái giá của việc bước qua nó, so với một lời gọi hàm bình thường — và vấp một bất ngờ khiến tôi phải xem lại cả định nghĩa "syscall".

Chi phí một syscall

Không gian người dùng và không gian nhân

Chương trình của bạn chạy trong không gian người dùng (user space), một chế độ CPU bị hạn chế: nó không được trực tiếp đụng phần cứng, đọc đĩa, hay chạm bộ nhớ của tiến trình khác. Muốn làm những việc đó, nó phải nhờ nhân (kernel) — chạy trong chế độ đặc quyền — thông qua một lời gọi hệ thống. Một syscall là một sự kiện đổi chế độ: CPU chuyển từ user sang kernel, nhân làm việc rồi trả kết quả và chuyển ngược lại.

Cú chuyển đó có giá: lưu và khôi phục trạng thái, kiểm tra quyền, và trên các CPU hiện đại còn thêm chi phí phòng chống các lỗ hổng suy đoán (Spectre/Meltdown) khiến việc ra vào nhân đắt hơn xưa. Một lời gọi hàm thường thì chỉ là một lệnh nhảy trong cùng không gian — rẻ hơn nhiều bậc. Câu hỏi: rẻ hơn bao nhiêu bậc?

Đo: hàm 0,8ns, syscall 113ns, vDSO 15ns

Tôi viết một chương trình C lặp 5 triệu lần mỗi phép, đo thời gian trung bình một lần (trung vị 3 lần chạy):

Loại gọi ns mỗi lần So với hàm thường
Hàm rỗng (trong user space) 0,8
syscall(getpid) — bẫy vào nhân ~113 ~140×
clock_gettime qua vDSO ~15 ~19×
clock_gettime ép qua syscall ~142 ~175×

Con số đầu tiên đúng như dự đoán: một lời gọi hàm thường gần như miễn phí (0,8 ns — chưa tới một nano giây). Một syscall thật (getpid, gọi thẳng qua syscall() để chắc chắn nó bẫy vào nhân) tốn ~113 ns — gấp khoảng 140 lần một lời gọi hàm. Đó là cái giá của việc bước qua cửa nhân: hơn một trăm nano giây cho một việc mà bản thân nó (trả về số PID) đáng lẽ tức thì.

Nhưng dòng thứ ba làm tôi khựng lại: clock_gettime — cũng là một "lời gọi hệ thống" theo sách vở — chỉ tốn ~15 ns, gần bằng một lời gọi hàm và rẻ hơn getpid gần 8 lần. Sao lại thế?

Một lần tôi đo hớ: clock_gettime không phải một syscall rẻ

Phản xạ đầu tiên của tôi là ghi ngay vào bài: "hóa ra không phải syscall nào cũng đắt — clock_gettime là một syscall rẻ bất ngờ". Rồi tôi kịp dừng lại, vì con số quá lệch so với getpid (cùng là syscall mà chênh 8 lần thì vô lý nếu cả hai cùng bẫy vào nhân).

Sự thật: clock_gettime ở đây không hề bẫy vào nhân. Nó nhanh không phải vì là "syscall rẻ", mà vì nó không phải là syscall theo nghĩa đổi chế độ. Linux ánh xạ một trang bộ nhớ đặc biệt gọi là vDSO (virtual dynamic shared object) vào không gian địa chỉ của mọi tiến trình. Trên trang đó, nhân đặt sẵn mã và dữ liệu cho vài thao tác hay được gọi và không cần đặc quyền — điển hình là lấy thời gian. Khi bạn gọi clock_gettime(CLOCK_MONOTONIC), glibc chạy đoạn mã vDSO đọc thẳng đồng hồ từ trang chia sẻ đó ngay trong không gian người dùng, không bao giờ chuyển sang nhân. Vì thế nó chỉ tốn ~15 ns.

Để chứng minh chứ không đoán, tôi làm hai việc. Thứ nhất, ép cùng phép lấy giờ đó đi qua cổng syscall thật bằng syscall(SYS_clock_gettime, ...) — bỏ qua vDSO. Kết quả: ~142 ns, đúng bằng cái giá bẫy của getpid. Vậy bản thân thao tác lấy giờ không rẻ; chỉ có con đường vDSO là rẻ. Thứ hai, tôi chạy strace -c để đếm syscall thật sự xảy ra: getpid hiện 1000 lần (mỗi lần gọi đều bẫy vào nhân nên strace bắt được), còn clock_gettime hiện 0 lần — strace không thấy nó, vì nó không hề đi qua cổng syscall để mà bị bắt.

Bài học đo lường: một con số nhanh bất ngờ nên làm bạn hỏi "nó có thật sự làm điều mình nghĩ không?" trước khi mừng. clock_gettime nhanh không phải vì syscall rẻ, mà vì nó được cơ chế syscall hoàn toàn. Nếu tôi tin con số 15 ns và viết "syscall có thể rất rẻ", tôi đã dạy người đọc một mô hình sai — họ sẽ tưởng gọi read hay write triệu lần cũng rẻ như vậy, trong khi thực tế mỗi cái tốn cả trăm nano giây vì chúng phải bẫy vào nhân. Cái strace không thấy hóa ra lại là manh mối quan trọng nhất.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là giảm số syscall trong đường nóng (hot path). Một trăm nano giây nghe nhỏ, nhưng một vòng lặp gọi read từng byte, hay write từng dòng log, có thể phát ra hàng triệu syscall mỗi giây — và chỉ riêng thuế bẫy vào nhân đã ngốn phần lớn CPU. Đây là lý do người ta đệm (buffering): đọc 64 KB một lần rồi xử lý trong bộ nhớ, thay vì gọi read 65536 lần; gom log rồi ghi một cú. Cùng dữ liệu, ít syscall hơn hàng vạn lần. Các cơ chế mới như io_uring sinh ra chính để cắt số lần bẫy vào nhân cho I/O quy mô lớn.

Hệ quả thứ hai là hiểu vì sao lấy thời gian lại rẻ đến vậy, và tận dụng nó. Nhờ vDSO, clock_gettimegettimeofday gần như miễn phí — bạn có thể đo thời gian dày đặc trong code (đúng như chính bài này làm, gọi clock_gettime để đo các phép khác) mà không lo nó làm sai lệch phép đo. Nhưng đừng nhầm: chỉ một nhúm thao tác được vDSO tăng tốc (lấy giờ, getcpu); phần còn lại — read, write, open, send — vẫn phải bẫy vào nhân với giá đầy đủ. Biết cái nào đi vDSO, cái nào không, giúp bạn đoán đúng chi phí.

Hệ quả thứ ba là một thói quen chẩn đoán: strace -c cho bạn thấy chương trình thật sự nói chuyện với nhân bao nhiêu. Một chương trình chậm bất thường thường lộ nguyên nhân ngay trong bảng strace -c: hàng triệu lời gọi read nhỏ, hay stat lặp lại, hay futex do tranh khóa. Con số mang theo: một lời gọi hàm ~0,8 ns, một syscall thật bẫy vào nhân ~113 ns (gấp ~140 lần), còn clock_gettime chỉ ~15 ns vì nó chạy trong vDSO chứ không bẫy — ép nó qua syscall thật thì cũng ~142 ns như mọi syscall khác. Ranh giới user–kernel là một cái cửa có thu phí; lập trình hiệu năng phần lớn là nghệ thuật bước qua cửa đó ít lần nhất có thể.

Thử ba mươi giây

Trên một máy Linux, chạy strace -c -f trước một lệnh bất kỳ, ví dụ strace -c -f ls -R /usr (rồi Ctrl-C nếu lâu). Bảng tổng kết in ra cuối cùng cho bạn thấy chương trình đã gọi những syscall nào và mỗi loại bao nhiêu lần — sắp theo thời gian tốn nhiều nhất. Bạn sẽ thấy openat, read, getdents, stat... và ngạc nhiên vì số lượng. Rồi thử strace -c một chương trình mà bạn nghĩ là "chỉ tính toán trong bộ nhớ" — nếu nó vẫn phun ra nhiều syscall, đó là những chỗ nó đang lén bước qua cửa nhân, và mỗi lần bước là hơn trăm nano giây. Đó chính là cái giá mà bài này đo, hiện ra ngay trên máy bạn.