Một script đọc file log 1 MiB để tìm một chuỗi. Người viết chọn buffer 1 byte, vì nghĩ rằng đọc từng byte thì không bao giờ vượt quá biên file. Trên máy soạn bài (macOS 27, Apple M1, page 16 KiB), script đó chạy 453,08 ms. Đổi đúng một tham số — buffer từ 1 byte lên 65.536 byte — cùng file, cùng đĩa, cùng kernel, cùng tiến trình: 0,04 ms. Chênh hơn 10.000 lần.
Đĩa không nhanh lên. Điều duy nhất thay đổi là số lần chương trình gõ cửa kernel: lần đầu 1.048.576 lần, lần sau 16 lần. Mỗi lần gõ cửa, CPU phải rời chế độ người dùng, nhảy vào một địa chỉ do kernel cài sẵn trong một thanh ghi cấu hình, chạy một hàm C bên trong kernel, rồi quay ra. Toàn bộ chuyến đi đó tốn khoảng 440 ns, và nó không quan tâm bạn mang theo 1 byte hay 64 KiB.
Con số 440 ns đó là thứ đáng chú ý nhất trong bài này, vì nó nhỏ tới mức người ta quên nó tồn tại — cho tới khi có một vòng lặp gọi nó một triệu lần. Trên Linux, strace -c trên đúng script đó sẽ in ra một dòng read với cột calls bằng 1048576, và cột thời gian hệ thống chiếm gần như toàn bộ thời gian chạy. Không có lỗi logic nào trong code. Chỉ có một quyết định về kích thước buffer, và quyết định đó đắt gấp ba bậc so với mọi thứ khác trong chương trình.
Nhưng câu hỏi thật của bài không phải “dùng buffer to hơn”. Câu hỏi là: vì sao chương trình không thể tự đọc đĩa, tự lấy giờ hệ thống, tự gửi byte lên màn hình? Nếu nó tự làm được thì đã không có ai thu 440 ns mỗi lần. Vậy cái gì đứng giữa chương trình và phần cứng, cái gì bắt buộc phải đi qua nó, và cái gì đã được thiết kế để đi vòng?
Ba câu đó dẫn tới toàn bộ nội dung bài: mode bit và ranh giới đặc quyền, lệnh syscall và đường vào kernel, lớp API nằm trên system call, chi phí copy dữ liệu qua biên, và vDSO — trang bộ nhớ duy nhất mà kernel tự nguyện để chương trình đọc thẳng. Cuối bài, bạn sẽ tự đo được cả ba con số: chi phí một system call, chi phí một lời gọi hàm thường, và chi phí một lời gọi đi vòng qua vDSO.
Một lưu ý về cách đọc số trong bài, để không lẫn ba loại khác nhau:
/proc và không có strace.strace -c, và biết con số nào là bất thường với một lệnh như ls.access), một lời gọi hàm thường, và một lời gọi đi qua vDSO (gettimeofday), rồi giải thích vì sao tỉ lệ thứ nhất dao động mạnh giữa các lần biên dịch.printf không phải system call, bằng cách đếm số lần write thật của một chương trình in 1.000 dòng ra file và ra terminal.System call giống cửa kiểm soát hộ chiếu ở sân bay. Mỗi lần qua cửa, bạn mất khoảng 40 giây — bất kể bạn mang một phong bì hay một vali. Nếu mỗi phong bì bạn phải qua cửa một lần, cả ngày bay mất vào việc xếp hàng; nếu bạn gom 4.096 phong bì vào một vali và qua cửa một lần, 40 giây đó được chia cho 4.096 món. Cửa không mở rộng, chỉ có số lần đi qua thay đổi.
Phép chia thứ nhất: số lần qua cửa. File 1 MiB là 1.048.576 byte. Với buffer 4 KiB, chương trình gọi read() đúng 1.048.576 ÷ 4.096 = 256 lần, cộng một lần open() và một lần close(). Với buffer 1 byte, số lần là 1.048.576 — gấp 4.096 lần. Đây là con số bất biến, đúng trên mọi hệ điều hành, mọi page size, mọi ổ đĩa; nó chỉ phụ thuộc hai tham số bạn chọn: kích thước file và kích thước buffer.
Phép cộng thứ hai: một lần gọi gồm những gì. Đo trên máy soạn bài, một read() 4 KiB trúng page cache tốn 586 ns. Trong đó, phần chuyển dữ liệu là 4.096 byte ở băng thông copy đo được khoảng 28 GB/s, tức 4.096 ÷ 28×10⁹ = 146 ns. Khoản thứ hai — 440 ns — là chi phí cố định: đổi chế độ CPU, lưu thanh ghi, tra bảng file descriptor, cập nhật vị trí đọc, kiểm tra quyền, quay về chế độ người dùng. Ở buffer 4 KiB, 75% thời gian của một lần gọi không dùng để chuyển một byte dữ liệu nào.
Hai con số 440 và 146 đó cho ra một mô hình hai tham số, dùng xuyên suốt bài:
chi phí một read(B) ≈ 440 ns + B ÷ 28 GB/s B = 1 byte → 440 + 0,04 = 440 ns (99,99% là phí gõ cửa) B = 512 byte → 440 + 18 = 458 ns (96% là phí gõ cửa) B = 4 KiB → 440 + 146 = 586 ns (75% là phí gõ cửa) B = 64 KiB → 440 + 2.341 = 2.781 ns (16% là phí gõ cửa)
Mô hình này khớp năm điểm đo của bảng quét buffer ở khối 5 trong sai số 2%. Nó cũng trả lời ngay câu hỏi thiết kế: muốn phí cố định xuống dưới 10% tổng chi phí thì phần copy phải lớn hơn 9 lần phần cố định, tức 9 × 440 = 3.960 ns, tức buffer ≥ 3.960 ns × 28 GB/s = 110.880 byte, làm tròn 108 KiB. Đó là lý do mọi thư viện I/O nghiêm túc đều mặc định buffer quanh 64–128 KiB chứ không phải 4 KiB: 4 KiB là kích thước một trang, không phải kích thước tối ưu cho thông lượng.
Phép nhân thứ ba: cái giá khi số lần gọi là hàng triệu. Với 1.048.576 lần gọi và 440 ns mỗi lần, riêng phí cố định đã là 1.048.576 × 440 ns = 461 ms. Đo được 453 ms cho toàn bộ script, nghĩa là mô hình hơi cao — vì ở buffer 1 byte, CPU dùng chung đường ống và cache tốt hơn mô hình tuyến tính giả định. Sai số 2% theo hướng nào không quan trọng; điều quan trọng là bậc độ lớn: một triệu lần gõ cửa = gần nửa giây, trong khi 16 lần gõ cửa = 0,04 ms. Cùng một file.
Phần này đi qua sáu cơ chế, theo đúng thứ tự một lần gọi read() đi qua: từ chỗ CPU từ chối chạy lệnh của bạn, tới chỗ kernel nhận việc, tới chỗ dữ liệu được copy, tới chỗ một số lời gọi được phép không đi qua biên nào cả. Mỗi cơ chế có ba nhãn cố định — Vì sao nó tồn tại, Cách nó chạy từng bước, Đánh đổi — vì một ranh giới bảo mật chỉ hiểu được khi biết nó chặn cái gì và nó làm chậm cái gì.
Vì sao. Nếu chương trình người dùng chạy được mọi lệnh của CPU, thì ba dòng assembly là đủ để đọc mật khẩu của tiến trình khác: đặt thanh ghi cr3 sang bảng trang của tiến trình đó, rồi đọc địa chỉ vật lý. Cùng cách đó, một lệnh outb ghi thẳng vào cổng điều khiển đĩa sẽ bỏ qua toàn bộ file system, và một lệnh hlt sẽ tắt CPU giữa lúc 300 tiến trình khác đang chờ. Không có cách nào kiểm soát được một chương trình có toàn quyền với phần cứng, nên phần cứng phải tự chia quyền: một bit trong thanh ghi trạng thái quyết định CPU đang chạy ở mức đặc quyền nào.
Cách nó chạy. Trên x86-64, bit đó là CS.RPL — hai bit thấp của thanh ghi đoạn mã — cho hai mức ring 0 và ring 3 (phần cứng có bốn ring, hệ điều hành thực tế chỉ dùng hai). Trên ARM64 của máy soạn bài, hai mức tương ứng là EL0 (ứng dụng) và EL1 (kernel). Khi ở ring 3, CPU từ chối khoảng 15–20 lệnh: hlt, cli/sti (tắt bật ngắt), outb/inb (vào cổng I/O), wrmsr, lgdt, mov vào các thanh ghi điều khiển cr0–cr4. Chương trình chạy một trong số đó sẽ nhận SIGILL hoặc SIGSEGV ngay tại lệnh, do CPU báo lỗi, không do kernel phát hiện. Kernel chạy ở ring 0 nên chạy được cả bốn nhóm lệnh; thư viện C chạy ở ring 3 nên không chạy được lệnh nào trong số đó.
Đánh đổi. Ranh giới này mua được tính bảo mật của cả hệ thống, và thu phí ở mọi thao tác chạm tài nguyên dùng chung. Mỗi lần đổi ring là một lần CPU xả đường ống lệnh đang chạy dở, và trên CPU có bật các bản vá Spectre thì còn thêm 50–150 ns cho việc làm sạch bộ đệm dự đoán — đó là lý do một system call trên máy ảo hoá hoặc máy bật mitigations đầy đủ có thể lên 1 µs thay vì 350 ns. Cách giảm phí là gộp nhiều việc vào một lần qua biên (readv, io_uring, sendmmsg), chứ không phải bỏ biên — bỏ biên là bỏ luôn khả năng cách ly giữa 300 tiến trình.
syscall và bảng sys_call_tableVì sao. Cần đúng một lệnh mà CPU cho phép ring 3 dùng để yêu cầu ring 0 làm việc, và lệnh đó phải nhảy tới một địa chỉ do kernel quyết định, không do chương trình quyết định. Nếu chương trình tự chọn địa chỉ đích thì nó chọn được cả địa chỉ của hàm đọc sector đĩa, và ranh giới ở 4.1 thành vô nghĩa. Vì vậy địa chỉ đích nằm trong một thanh ghi cấu hình chỉ ring 0 ghi được.
Cách nó chạy. Trên x86-64, chương trình đặt số hiệu system call vào rax (0 là read, 1 là write, 257 là openat), tham số vào rdi, rsi, rdx, r10, r8, r9, rồi chạy lệnh syscall. CPU đọc MSR LSTAR để lấy địa chỉ entry_SYSCALL_64, đặt CS/SS sang ring 0, và nhảy. Lệnh syscall không đổi stack — nên việc đầu tiên của entry_SYSCALL_64 là swapgs để lấy con trỏ dữ liệu per-CPU, chuyển sang stack kernel, đẩy sáu thanh ghi thành một struct pt_regs, rồi kiểm tra rax < NR_syscalls (khoảng 450 trên x86-64) trước khi gọi sys_call_table[rax]. Chỉ số nằm ngoài bảng thì kernel trả -ENOSYS thay vì nhảy vào rác. Trên ARM64, lệnh là svc kèm hằng số ngay sau (svc #0 trên Linux, svc #0x80 trên macOS), số hiệu system call nằm trong x8 trên Linux và x16 trên macOS, còn kernel đọc vector từ VBAR_EL1.
RING 3 — chương trình của bạn
┌──────────────────────────────────────────────┐
│ read(fd, buf, 4096) │
│ │ glibc: rax=0, rdi=fd, rsi=buf, rdx=4096 │
│ ▼ │
│ lệnh `syscall` ──────────────────────────┐ │
└────────────────────────────────────────────┼─┘
│ CPU: CS.RPL 3→0, RIP ← MSR LSTAR
RING 0 — kernel ▼
┌──────────────────────────────────────────────┐
│ entry_SYSCALL_64 │
│ swapgs · đổi sang stack kernel │
│ đẩy pt_regs (6 thanh ghi, ~60 ns) │
│ kiểm tra rax < 450 │
└───────────────────┬──────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ sys_call_table[0] = ksys_read │
│ VFS: fd → struct file → vị trí đọc │
│ page cache: trang đã có sẵn, không chạm đĩa│
│ copy_to_user(buf, page + off, 4096) │
└───────────────────┬──────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ sysretq: khôi phục pt_regs, CS.RPL 0→3 │
└───────────────────┬──────────────────────────┘
▼
RING 3 — eax = 4096, hoặc -errno
Đánh đổi. Một cửa duy nhất, một bảng số hiệu, nghĩa là kernel kiểm soát được chính xác chương trình được yêu cầu những gì — và cũng nghĩa là mọi chức năng mới đều phải thêm một mục vào bảng đó, giữ nó ổn định vĩnh viễn vì nhị phân cũ vẫn gọi số hiệu cũ. Linux giữ gần 450 mục theo cách đó suốt 20 năm, kể cả những mục đã lỗi thời, vì xoá một số hiệu là làm hỏng chương trình biên dịch từ 1995. Cái giá thứ hai: một số hiệu duy nhất cho mỗi thao tác khiến việc gộp việc phải làm bằng tay (readv, sendmmsg, io_uring), và mỗi lần gộp là một API mới phải học.
Vì sao. Nếu chương trình gọi thẳng số hiệu system call, thì mỗi kiến trúc CPU cần một phiên bản nhị phân riêng, mỗi lỗi phải tự giải mã errno, và mỗi lần đổi cách kernel nhận tham số là mọi chương trình phải biên dịch lại. Lớp thư viện C — glibc, musl, hay libSystem trên macOS — tồn tại để che ba việc đó: dịch một lời gọi ổn định thành đúng lệnh của kiến trúc đang chạy, đặt mã lỗi vào errno và trả -1, và đệm dữ liệu để giảm số lần qua biên.
Cách nó chạy. read() trong glibc là khoảng 20 dòng assembly: đặt rax theo __NR_read của kiến trúc, đặt ba tham số, chạy syscall, kiểm tra rax có nằm trong khoảng -4095..-1 không, nếu có thì đàm phán lại thành errno và trả -1. printf() thì không có system call nào tên là printf: nó định dạng chuỗi vào một buffer 4 KiB trong user space, và chỉ gọi write(1, buf, n) khi buffer đầy, khi gặp \n trên terminal (line-buffered), hoặc khi fflush. Một chương trình in 1.000 dòng, mỗi dòng 20 byte, tổng 20.000 byte: ra file là 5 lần write (bốn lần đầy buffer, một lần flush cuối); ra terminal là 1.000 lần write, vì mỗi \n làm buffer được đẩy đi. Cùng code, cùng dữ liệu, khác nhau 200 lần số lần qua biên.
Đánh đổi. Đệm giảm số lần gọi nhưng làm mất dữ liệu khi tiến trình chết giữa chừng — đó là lý do log của một chương trình bị kill -9 thường mất những dòng cuối, và lý do phải setvbuf(fp, NULL, _IONBF, 0) cho file log quan trọng. Ở chiều ngược lại, gọi thẳng system call bằng syscall(SYS_read, ...) bỏ được lớp glibc, nhưng mất tính khả chuyển và mất luôn các bản vá hủy (cancellation point) mà glibc thêm vào. Chọn lớp nào là chọn giữa “chạy được ở mọi nơi, hành vi đệm khó đoán” và “hành vi rõ ràng, gắn chết vào một kiến trúc”.
copy_to_userVì sao. Kernel không được tin con trỏ do chương trình đưa vào. Nếu read(fd, buf, 4096) copy thẳng vào buf mà không kiểm tra, chương trình có thể đưa địa chỉ của chính kernel — hoặc một địa chỉ ánh xạ vào vùng nhớ của tiến trình khác — và biến một lần đọc file thành một lần ghi đè bộ nhớ kernel. Vì vậy mọi lần copy qua biên đều đi qua một hàm có kiểm tra, và hàm đó là nơi chi phí phụ thuộc khối lượng xuất hiện.
Cách nó chạy. copy_to_user(dst, src, n) gọi access_ok() để kiểm tra toàn bộ khoảng [dst, dst+n) nằm dưới địa chỉ TASK_SIZE của tiến trình; sau đó copy từng đoạn bằng rep movsb (x86) hoặc vòng lặp ldp/stp 128-bit (ARM64). Nếu trang đích chưa được ánh xạ — trường hợp bình thường với buffer vừa malloc — CPU phát page fault ngay giữa lệnh copy, kernel xử lý fault đó bên trong lần gọi system call, cấp một khung trang, rồi chạy lại lệnh copy bị hỏng. Vì vậy lần read() đầu tiên vào một buffer mới cấp đắt hơn hẳn các lần sau: mỗi 4 KiB buffer mới còn cộng thêm một page fault, khoảng 1–3 µs trên máy thật.
Đánh đổi. Copy là cách an toàn nhất và cũng là cách đắt nhất: dữ liệu đi qua bộ nhớ hai lần (đĩa → page cache → buffer của bạn), và băng thông đo được 28 GB/s chính là trần của con đường đó. Ba cách tránh copy đều đổi một cái giá khác: mmap() ánh xạ thẳng page cache vào không gian địa chỉ, bỏ được 146 ns mỗi 4 KiB nhưng mỗi lần chạm trang mới lại phát page fault; sendfile() copy trong kernel, bỏ được lần copy thứ hai nhưng chỉ dùng được cho file → socket; io_uring với buffer đăng ký trước bỏ được cả kiểm tra lẫn copy cho từng lần nhưng buộc chương trình đăng ký buffer từ trước và không được đụng vào chúng. Không có cách nào vừa an toàn, vừa không copy, vừa không cần chuẩn bị trước.
Vì sao. Có những system call mà dữ liệu trả về nằm trong bộ nhớ kernel nhưng không bí mật và không cần thay đổi gì: giờ hệ thống, độ phân giải đồng hồ, số hiệu CPU đang chạy. Gọi gettimeofday() một triệu lần để lấy mốc thời gian trong một bộ đếm hiệu năng sẽ tốn 357 ms cho một thông tin mà kernel đã công khai. vDSO (virtual dynamic shared object) tồn tại để bán đúng thông tin đó mà không thu phí qua biên: kernel ánh xạ một trang chỉ đọc vào không gian địa chỉ của mọi tiến trình, và trang đó chứa cả dữ liệu lẫn một đoạn code đọc dữ liệu.
Cách nó chạy. Lúc execve(), kernel đặt một mục [vvar] và một mục [vdso] vào bảng ánh xạ của tiến trình (xem /proc/self/maps trên Linux; trên macOS cơ chế tương đương tên là commpage). glibc phát hiện trang này khi khởi động và trỏ gettimeofday, clock_gettime, time, getcpu vào đoạn code trong đó. Đoạn code đọc một chuỗi seqcount và giá trị thời gian, kiểm tra lại seqcount để chắc kernel không ghi giữa lúc đọc, rồi trả về — toàn bộ ở ring 3, không có lệnh syscall nào. Đo trên máy soạn bài: 21,1 ns cho gettimeofday() so với 356,8 ns cho access("/"), tức nhanh 16,9 lần, và so với 440 ns của một read() 4 KiB thì nhanh 20,8 lần.
Đánh đổi. Chỉ những thao tác chỉ đọc và không cần quyền mới đi được đường này. read() không thể, vì nó phải cập nhật vị trí đọc trong struct file — trạng thái dùng chung, phải sửa dưới khoá. getpid() không đi vDSO trên Linux dù nghe có vẻ hiển nhiên, vì giá trị nằm trong task_struct và kernel không muốn ánh xạ cấu trúc đó ra ngoài. Cái giá của vDSO là một lớp ABI thứ hai phải giữ ổn định: đoạn code trong trang đó chạy trong tiến trình người dùng, nên nó không được giả định gì về kernel, và kernel phải giữ đúng bố cục dữ liệu mà đoạn code đó đọc — một hợp đồng ngầm khó đổi hơn nhiều so với một số hiệu system call.
Vì sao. Ranh giới ring 3/ring 0 không nhất thiết phải là ranh giới duy nhất. Câu hỏi thiết kế là: bao nhiêu chức năng đặt bên trong ring 0? Đặt nhiều thì mỗi thao tác chỉ cần một lần qua biên, nhưng một lỗi trong driver mạng là một lỗi trong kernel. Đặt ít thì mỗi thao tác cần nhiều lần qua biên, nhưng driver chết chỉ làm chết một tiến trình. Đây là lựa chọn kiến trúc duy nhất trong bài mà bạn có thể thấy hậu quả bằng số học thuần.
Cách nó chạy. Trong kernel nguyên khối (monolithic), driver, file system và bộ định thời cùng nằm trong một ảnh nhị phân chạy ở ring 0; read() là một lần qua biên, gọi thẳng ksys_read rồi tới driver. Trong microkernel, chỉ phần tối thiểu — lập lịch, không gian địa chỉ, IPC — nằm ở ring 0, còn file system và driver là tiến trình riêng ở ring 3; một read() thành bốn lần qua biên: client → kernel (IPC) → file server → kernel → driver → kernel → client. Với 440 ns mỗi lần, bốn lần là 1.760 ns thay vì 440 ns, tức chậm 4 lần cho cùng một thao tác — trước khi tính bất kỳ công việc thật nào. Mô hình lai (hybrid) đặt driver và file system trong kernel nhưng giữ một lớp con trỏ hàm để nạp/rỡ module động; Linux dùng cách này (khoảng 70% driver là module nạp được), Windows và XNU của macOS gọi nó là kiến trúc lai.
| Kiểu | Chức năng ở ring 0 | Số lần qua biên cho một read() |
Chi phí biên (mô hình) | Hỏng driver thì sao |
|---|---|---|---|---|
| Monolithic (Linux) | driver, FS, lập lịch, mạng | 1 | 440 ns | kernel panic, mất cả máy |
| Microkernel (seL4, QNX) | lập lịch, bộ nhớ, IPC | 4 | 1.760 ns | một tiến trình chết, khởi động lại |
| Hybrid (Windows, XNU) | driver, FS trong kernel + lớp module | 1 | 440 ns | kernel panic nếu driver lỗi |
| Unikernel | toàn bộ ứng dụng + kernel | 0 | 0 ns | ứng dụng chết, máy ảo chết |
Đánh đổi. Microkernel trả 4 lần chi phí biên để mua khả năng cô lập: một driver USB lỗi chỉ làm chết tiến trình driver, hệ thống khởi động lại nó trong vài mili-giây. Nhưng 1.760 ns cho mỗi read() là mức chấp nhận được cho một thiết bị nhúng, không chấp nhận được cho một cơ sở dữ liệu làm 200.000 thao tác đọc mỗi giây — ở mức đó, chênh 1.320 ns mỗi lần là 264 ms CPU mỗi giây, tức 26% một lõi, chỉ để đi qua các ranh giới. Linux chọn ngược lại: một lần qua biên, đổi lấy việc 70% driver chạy trong kernel và mỗi lỗi driver là một lần panic. Cả hai lựa chọn đều đúng với ràng buộc của nó, và cả hai đều phải trả giá bằng đúng thứ mà lựa chọn kia tối ưu.
Trace một lần read(fd, buf, 4096) trúng page cache, chia theo bước. Cột chi phí là mô hình dựng từ hai phép đo ở khối 6 (356,8 ns cho access("/") không copy byte nào, 586 ns cho read() 4 KiB); các bước được chia theo tỉ lệ đo được trên máy soạn bài, không phải số đo trực tiếp từng bước.
| Bước | Việc xảy ra | Ai làm | Chi phí (mô hình) |
|---|---|---|---|
| 1 | Đặt tham số read(fd, buf, 4096) |
chương trình | 2 ns |
| 2 | glibc: rax=0, rdi, rsi, rdx |
glibc | 5 ns |
| 3 | Lệnh syscall: ring 3 → ring 0 |
CPU | 25 ns |
| 4 | entry_SYSCALL_64: swapgs, đẩy 6 thanh ghi |
kernel | 70 ns |
| 5 | ksys_read → VFS → fd → page cache trúng |
kernel | 160 ns |
| 6 | copy_to_user 4.096 byte ở 28 GB/s |
kernel | 146 ns |
| 7 | sysretq: khôi phục thanh ghi, ring 0 → 3 |
CPU | 170 ns |
| Tổng mô hình | 578 ns (đo được 586 ns) |
Bốn điều đọc được từ bảng này, và cả bốn đều dùng được để quyết định thiết kế:
read() nhỏ bằng cách làm kernel nhanh hơn thì phí công; tối ưu bằng cách gọi ít lần hơn thì hiệu quả gấp hàng nghìn lần.sysretq cộng khôi phục thanh ghi) đắt gần gấp ba bước 3, vì đường ra phải khôi phục đủ sáu thanh ghi và trên CPU có mitigations còn phải làm sạch bộ đệm dự đoán. Một system call không đối xứng: vào rẻ hơn ra.io_uring gộp bước 5 cho nhiều lần đọc, và với file đọc tuần tự thì nhân Linux còn bật đọc trước (readahead) để bước 5 gần như luôn trúng cache.access("/") đo được 356,8 ns, thấp hơn 432 ns của phần cố định trong bảng, dù nó phải đi qua toàn bộ đường dẫn /. Chênh lệch nằm ở bước 5: access chỉ tra một inode, còn read phải tra bảng file descriptor, cập nhật vị trí đọc và kiểm tra quyền trên struct file. Hai system call “cùng loại” có thể chênh nhau 20% vì phần việc bên trong khác nhau.Đo thật, cùng một file 1 MiB, chỉ đổi buffer (macOS, Apple M1, syscall_cost ở khối 6):
| Buffer | Số read() |
Thời gian | ns mỗi lần | ns mỗi byte |
|---|---|---|---|---|
| 1 byte | 1.048.576 | 453,08 ms | 432 | 432 |
| 64 byte | 16.384 | 7,18 ms | 438 | 6,85 |
| 512 byte | 2.048 | 0,92 ms | 449 | 0,88 |
| 4 KiB | 256 | 0,15 ms | 586 | 0,14 |
| 64 KiB | 16 | 0,04 ms | 2.750 | 0,04 |
Ba kết luận rút ra từ bảng, không phải từ trực giác. Thứ nhất, từ 1 byte lên 4 KiB, số lần gọi giảm 4.096 lần nhưng thời gian chỉ giảm 3.021 lần (453,08 ÷ 0,15), vì mỗi lần gọi đắt thêm 36% (432 → 586 ns). Không có gì miễn phí: bạn đổi số lần gõ cửa lấy khối lượng mỗi lần, và ở đây vẫn lãi 3.000 lần. Thứ hai, ns mỗi byte giảm từ 432 xuống 0,04 — khoảng 10.000 lần — nghĩa là thông lượng là đại lượng đúng để tối ưu, không phải thời gian trên mỗi lần gọi. Thứ ba, đường cong bão hoà rất sớm: 512 byte đã đạt 0,88 ns/byte, chỉ còn kém mức tốt nhất 22 lần; từ 4 KiB lên 64 KiB chỉ cải thiện thêm 3,5 lần. Đó là lý do buffer 8–64 KiB là điểm dừng thực dụng: dưới 4 KiB bạn trả tiền cho việc gõ cửa, trên 64 KiB bạn không mua thêm được gì đáng kể mà lại tốn bộ nhớ đệm.
Lưu thành syscall_cost.c, biên dịch cc -O1 -o syscall_cost syscall_cost.c, chạy ./syscall_cost. Code chạy được trên cả Linux và macOS. Ba điểm phải giữ nếu bạn sửa lại: gọi qua con trỏ hàm volatile (nếu không, trình biên dịch sẽ hoist vòng lặp và bạn đo được 1 ns cho một system call); lấy giờ bằng gettimeofday (đi qua vDSO, nên bộ đếm không tự tính phí system call vào chính nó); và mỗi phép đo chạy 1.000.000 lần để nhiễu đồng hồ nhỏ hơn 1%.
/* syscall_cost.c — đo chi phí syscall so với hàm thường và so với vDSO,
* rồi đếm số lần read(2) khi đổi kích thước buffer.
*
* Biên dịch: cc -O1 -o syscall_cost syscall_cost.c
* Chạy: ./syscall_cost
* (Linux và macOS đều chạy; số tuyệt đối khác nhau, tỉ lệ thì không.)
*
* Ghi chú: gọi qua con trỏ hàm volatile để trình biên dịch không hoist/inline
* vòng lặp — nếu không, getpid() 1 triệu lần sẽ bị tối ưu thành 1 lần gọi.
*/
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <unistd.h>
#define N 1000000
static double now(void) {
struct timeval tv;
gettimeofday(&tv, NULL);
return tv.tv_sec + tv.tv_usec / 1e6;
}
__attribute__((noinline)) static long f_local(long x) { return x + 1; }
static long (*volatile p_local)(long) = f_local;
static double bench(void) {
volatile long sink = 0;
double t = now();
for (long i = 0; i < N; i++) sink += p_local(i);
double a = now() - t;
t = now();
for (long i = 0; i < N; i++) sink += access("/", F_OK); /* 1 syscall thật */
double b = now() - t;
t = now();
for (long i = 0; i < N; i++) sink += (long)now(); /* vDSO: không vào kernel */
double c = now() - t;
printf("%-26s %9.1f ns/lần\n", "hàm thường f_local()", a / N * 1e9);
printf("%-26s %9.1f ns/lần\n", "access(\"/\") [syscall]", b / N * 1e9);
printf("%-26s %9.1f ns/lần\n", "gettimeofday() [vDSO]", c / N * 1e9);
printf("%-26s %9.1f lần\n", "syscall / hàm thường", b / a);
printf("%-26s %9.1f lần\n", "syscall / vDSO", b / c);
return sink;
}
static void read_sweep(void) {
const char *path = "syscall_cost.tmp";
size_t sizes[] = {1, 64, 512, 4096, 65536};
char buf[65536];
FILE *fp = fopen(path, "wb");
for (int i = 0; i < 1024 * 1024; i++) fputc('x', fp); /* 1 MiB */
fclose(fp);
printf("\n%-10s %12s %13s %10s\n", "buffer", "số read()", "ms", "MB/s");
for (int s = 0; s < 5; s++) {
size_t bs = sizes[s];
int fd = open(path, O_RDONLY);
long calls = 0;
double t = now();
ssize_t n;
while ((n = read(fd, buf, bs)) > 0) calls++;
double dt = now() - t;
close(fd);
printf("%-10zu %12ld %13.2f %10.1f\n", bs, calls, dt * 1000, 1.0 / dt);
}
unlink(path);
}
int main(void) {
volatile long s = bench();
(void)s;
read_sweep();
return 0;
}
Output thật của lần chạy dùng để viết bài (macOS 27, Apple M1, cc -O1, Apple clang):
hàm thường f_local() 1.7 ns/lần
access("/") [syscall] 356.8 ns/lần
gettimeofday() [vDSO] 21.1 ns/lần
syscall / hàm thường 205.2 lần
syscall / vDSO 16.9 lần
buffer số read() ms MB/s
1 1048576 453.08 2.2
64 16384 7.18 139.2
512 2048 0.92 1091.7
4096 256 0.15 6754.1
65536 16 0.04 23831.3
Bốn lần chạy liên tiếp trên cùng máy cho thấy cột nào ổn định và cột nào không:
| Lần chạy | access |
gettimeofday |
hàm thường | tỉ lệ syscall/hàm | 1 MiB @1 byte |
|---|---|---|---|---|---|
| 1 | 356,8 ns | 21,1 ns | 1,7 ns | 205,2 lần | 453,08 ms |
| 2 | 348,2 ns | 20,8 ns | 1,0 ns | 358,5 lần | 468,46 ms |
| 3 | 334,5 ns | 20,8 ns | 1,0 ns | 347,0 lần | 465,99 ms |
| 4 | 339,8 ns | 21,6 ns | 1,0 ns | 350,3 lần | 494,90 ms |
Đọc bảng này đúng cách: tử số ổn định, mẫu số thì không. access nằm trong khoảng 335–357 ns, gettimeofday trong 20,8–21,6 ns, nhưng hàm thường nhảy từ 1,0 lên 1,7 ns tuỳ lần biên dịch — vì nó chỉ là ba lệnh và chịu ảnh hưởng của căn chỉnh bộ nhớ. Hệ quả là tỉ lệ “system call đắt gấp bao nhiêu lần lời gọi hàm” dao động từ 205 tới 358 lần, dù system call không hề chậm đi. Bài học: đừng dùng một tỉ lệ có mẫu số 1 ns làm kết luận kỹ thuật; dùng số tuyệt đối 335–357 ns, và nếu cần tỉ lệ thì nói rõ mẫu số là bao nhiêu. Tỉ lệ với vDSO thì ổn định hơn nhiều (15,7–16,9 lần) vì cả hai vế đều là phép đo thật trên cùng một đồng hồ.
Còn một con số cần giải thích: gettimeofday 21,1 ns so với access 356,8 ns, tức 16,9 lần — không phải hằng số vũ trụ mà là đúng tỉ lệ bạn chờ đợi giữa “đọc một trang đã ánh xạ sẵn” và “đi qua biên hai chiều”. Nếu bạn đo được tỉ lệ chỉ 4–5 lần thì gần như chắc chắn gettimeofday của bạn không đi vDSO: chương trình biên dịch tĩnh, hoặc seccomp lọc clock_gettime, hoặc chạy trong một số loại sandbox sẽ đẩy nó về syscall thật.
Khối dưới chạy trong trình duyệt, dùng đúng mô hình 440 ns + B ÷ 28 GB/s của khối 3. Kéo buffer từ 1 byte lên 64 KiB với file 1 MiB: số read() rơi từ 1.048.576 xuống 16, còn phần trăm thời gian dành cho việc gõ cửa rơi từ 99,99% xuống 16%.
Mô hình hai tham số dựng từ phép đo trên máy soạn bài (macOS, Apple M1): mỗi read() tốn 440 ns cố định cộng khối lượng chia 28 GB/s. Số lần gọi là số học chính xác; thời gian là mô hình, khớp bốn mức buffer đo được của bảng trong bài với sai số dưới 2% (mức 64 KiB bị làm tròn 0,04 ms nên không so được chính xác hơn).
Trước hết là phép đo chạy được trên cả macOS và Ubuntu, không cần quyền root: so dd với hai kích thước block khác nhau trên cùng một file 16 MiB đã nằm trong page cache. Đây là số đo thật trên máy soạn bài:
mkfile -n 16m ddtest.bin # Linux: dd if=/dev/zero of=ddtest.bin bs=1M count=16
dd if=ddtest.bin of=/dev/null bs=1
dd if=ddtest.bin of=/dev/null bs=4096
dd if=ddtest.bin of=/dev/null bs=65536
16777216 bytes transferred in 25.407983 secs (660313 bytes/sec) real 0m25.412s user 0m3.047s sys 0m21.934s 16777216 bytes transferred in 0.007227 secs (2321463401 bytes/sec) real 0m0.010s user 0m0.002s sys 0m0.009s 16777216 bytes transferred in 0.001579 secs (10625215959 bytes/sec) real 0m0.005s user 0m0.001s sys 0m0.004s
Ba con số đáng đọc. Thứ nhất, thời gian thực 25,41 s so với 0,007 s: chênh 3.516 lần cho cùng 16 MiB, cùng đĩa, cùng page cache. Thứ hai, sys 0m21.934s so với sys 0m0.009s: gần như toàn bộ 25 giây nằm trong kernel, không phải trong code của dd — dấu hiệu chắc chắn nhất của một bài toán system call. Thứ ba, 25,407983 s ÷ 16.777.216 bản ghi = 1,51 µs mỗi bản ghi, trong khi hai system call (read + write) ở mức 430 ns mỗi lần chỉ tốn 0,86 µs; phần 0,65 µs còn lại là vòng lặp và kế toán của chính dd. Bài học: chi phí bạn đo được luôn gồm chi phí system call cộng chi phí chương trình, và ở buffer 1 byte thì khoản thứ hai cũng không nhỏ.
Trên Ubuntu 22.04 (chưa đo trên máy soạn bài, nên mọi số dưới đây là kỳ vọng ≈), bước tiếp theo là đếm thay vì đo:
strace -c -f ls -l /usr/bin 2>&1 | tail -6
# ≈ 1.100–1.500 system call cho thư mục vài trăm file
# ≈ 1–5 ms thời gian hệ thống, chiếm phần lớn thời gian chạy
Hai dòng đáng chú ý trong output đó là openat (một lần mỗi file được mở) và getdents64 (một lần mỗi lần đọc thư mục). Nếu số openat lớn hơn số file trong thư mục, chương trình đang mở lại những file nó đã có — lỗi phổ biến của code dùng stat() trong vòng lặp.
Đếm đúng chương trình ở khối 6, chỉ lọc read và write:
strace -c -e trace=read,write ./syscall_cost 2>&1 | tail -4
# kỳ vọng ≈ 1.067.280 lần read: 1.048.576 + 16.384 + 2.048 + 256 + 16
Con số 1.067.280 là tổng của cả năm lượt quét trong read_sweep(), nên nó là một phép kiểm tra chéo: nếu strace báo khác, code của bạn đọc khác số lần bạn tưởng — thường vì read() trả về ít hơn yêu cầu ở lần cuối, hoặc vì bạn quên close() khiến strace gộp nhầm. Trên macOS, lệnh tương đương là sudo dtruss -c ./syscall_cost, cần quyền root vì nó dùng cơ chế trace của kernel; bài này không chạy nó, nên không có số để đối chiếu.
Xem thời lượng của từng lần gọi, thay vì tổng:
strace -T -e trace=openat,read ls /tmp 2>&1 | head -5
# kỳ vọng ≈: openat(...) = 3 <0.000042> → 42 µs, vì phải tra thư mục
# kỳ vọng ≈: read(3, "..."..., 32768) = 32768 <0.000009> → 9 µs cho 32 KiB
-T in thời gian trong ngoặc nhọn. Hai con số đó giải thích toàn bộ bài: openat 42 µs là một system call đắt vì nó chạm cấu trúc thư mục, còn read 9 µs là một system call rẻ vì nó chỉ copy từ page cache — và cả hai đều đắt hơn 350 ns của access("/") vì khối lượng việc bên trong khác nhau. Đừng dùng một con số "chi phí system call" duy nhất cho mọi system call; hãy dùng nó làm bậc độ lớn.
Lỗi 1 — Tưởng printf là một system call. Triệu chứng: đếm 1.000 dòng in ra và kết luận chương trình gọi 1.000 write. Nguyên nhân: stdio đệm theo dòng khi ghi ra terminal, theo khối khi ghi ra file, nên cùng code cho 1.000 lần hoặc 5 lần. Cách sửa: kiểm bằng strace -c -e trace=write, và nhớ setvbuf đổi được hành vi này mà không đổi một dòng logic nào.
Lỗi 2 — Bỏ qua việc read trả về ít hơn yêu cầu. Triệu chứng: file đọc thiếu vài byte cuối, hoặc dữ liệu bị cắt ở mốc 4.096 byte. Nguyên nhân: read(fd, buf, 65536) được phép trả 1.000 byte — trên pipe, socket, terminal, và cả trên file thường khi gặp tín hiệu. Cách sửa: luôn cộng dồn n tới khi n == 0, và không bao giờ giả định một lần gọi đọc đủ.
Lỗi 3 — Không xử lý EINTR. Triệu chứng: chương trình chết với Interrupted system call khi có tín hiệu, thường chỉ trên máy chịu tải. Nguyên nhân: mọi system call chặn đều có thể trả -1 với errno = EINTR trước khi kernel kịp làm gì. Cách sửa: bọc trong while (n < 0 && errno == EINTR), hoặc dùng SA_RESTART khi cài handler tín hiệu.
Lỗi 4 — Buffer 1 byte vì "cho an toàn". Triệu chứng: 453 ms cho 1 MiB, sys time chiếm 95%. Nguyên nhân: 1.048.576 lần qua biên, mỗi lần 432 ns, cộng 0,65 µs chi phí vòng lặp của chính chương trình. Cách sửa: buffer 8–64 KiB cho file và pipe thông thường; chỉ dùng buffer nhỏ khi cần độ trễ thấp trên một pipe tương tác, và khi đó dùng read không chặn thay vì giảm kích thước buffer.
(a) Tính tay. Cần đọc 100 MB (104.857.600 byte) từ page cache. Cho trước: chi phí cố định của một read() là 350 ns, băng thông copy là 20 GB/s, dữ liệu không phụ thuộc đĩa.
(i) Tính tổng thời gian với buffer 4 KiB và với buffer 64 KiB, theo ms.
(ii) Trả lời bằng một con số: buffer phải lớn tối thiểu bao nhiêu byte để chi phí cố định chiếm dưới 5% tổng thời gian?
(iii) Với 100 MB và buffer 4 KiB, phần trăm thời gian dành cho việc gõ cửa là bao nhiêu?
(b) Sửa lỗi. Đoạn code sau đọc đúng nhưng mất dữ liệu khi file dài hơn buffer, và chết khi có tín hiệu:
int fd = open(path, O_RDONLY);
char buf[4096];
ssize_t n = read(fd, buf, sizeof buf);
write(out, buf, n); /* chỉ ghi được 4096 byte đầu */
close(fd);
Chỉ ra ba lỗi (đọc một lần, không kiểm tra n, không xử lý n < 0), viết lại vòng lặp đúng, và cho biết với file 1 MiB thì bản đúng gọi read() bao nhiêu lần trong trường hợp xấu nhất và bao nhiêu lần trong trường hợp tốt nhất.
(c) Thiết kế. Một proxy giữ 10.000 kết nối TCP, mỗi kết nối nhận 2 KiB mỗi giây. Hai phương án: (i) một thread cho mỗi kết nối, mỗi thread một buffer 2 KiB và stack mặc định 512 KiB; (ii) một vòng lặp epoll duy nhất với buffer 64 KiB dùng chung. Mỗi system call tốn 0,4 µs, mỗi lần chuyển ngữ cảnh tốn 2 µs. Bảo vệ lựa chọn bằng ba con số: số system call mỗi giây, bộ nhớ buffer, và bộ nhớ stack — rồi nói rõ phương án nào thắng ở tiêu chí nào và thua ở tiêu chí nào.
Một system call tốn khoảng 350–440 ns phí cố định cộng một khoản tỉ lệ với số byte, nên mọi tối ưu I/O trên đời chỉ là bài toán chọn xem bạn đặt ranh giới giữa hai khoản đó ở đâu.
Năm thuật ngữ: mode bit, system call table, copy_to_user, vDSO, page cache.
man 2 syscall, man 2 read, man 2 access, man 2 vdso, man 2 copy_from_user — đường vào kernel và ngoại lệ vDSO.man 1 strace, man 1 dtruss — đếm và đo system call trên Linux và macOS.arch/x86/entry/entry_64.S (entry_SYSCALL_64), arch/x86/entry/syscall_64.tbl (bảng số hiệu), Documentation/vdso/*.rst (cách kernel ánh xạ trang thời gian).