Chương trình của bạn không tự đọc được file, tự cấp bộ nhớ, hay tự tạo tiến trình — nó phải nhờ hệ điều hành làm những việc đó, qua một lời gọi hệ thống (system call, syscall). Đây là mở màn cho sê-ri mới về hệ điều hành, và cũng là viên gạch nền: gần như mọi thứ thú vị mà một chương trình làm với thế giới bên ngoài đều đi qua một syscall. Câu hỏi đầu tiên, rất thực tế: một syscall đắt cỡ nào so với một lời gọi hàm bình thường? Tôi đo trong container Linux — và ngay bài đầu đã vấp một "sự thật" mình nhớ nhầm.

Lời gọi hệ thống

Vì sao syscall đắt hơn lời gọi hàm

Một lời gọi hàm thường chỉ là một cú nhảy trong không gian người dùng (user space): lưu vài thanh ghi, nhảy tới địa chỉ hàm, chạy, quay về. Rẻ như vài lệnh máy. Một syscall thì khác về bản chất: nó phải vượt qua ranh giới bảo vệ giữa chương trình của bạn và nhân hệ điều hành (kernel). CPU chuyển từ chế độ người dùng sang chế độ nhân (một sự đổi chế độ được phần cứng canh giữ), lưu trạng thái thanh ghi, chuyển sang ngăn xếp của kernel, chạy mã kernel, rồi làm ngược lại để trả về. Chuỗi vào-ra đó tốn kém hơn hẳn một cú nhảy hàm, và đó là lý do các thư viện gom syscall lại (đệm I/O để không gọi write cho từng byte) và kernel dựng cơ chế vDSO cho vài syscall chỉ-đọc để né hẳn việc vào kernel.

Đo: 2 ns so với 120 ns

Tôi so ba thứ trong một vòng lặp 5 triệu lần, ghim một lõi: một lời gọi hàm tầm thường (plain, đánh dấu noinline để nó thật sự là một lời gọi), getpid() qua thư viện C, và syscall(SYS_getpid) gọi thẳng syscall bỏ qua wrapper của thư viện.

Thao tác Thời gian mỗi lần Vượt vào kernel?
plain() — lời gọi hàm 2,16 ns không
getpid() — qua glibc 122,66 ns
syscall(SYS_getpid) — thô 117,42 ns

Một syscall tốn khoảng 120 nano giây, gấp ~55 lần một lời gọi hàm (2,16 ns). Con số này chính là cái giá của việc vượt ranh giới user↔kernel — không phải vì công việc getpid làm (nó chỉ trả về một số nguyên), mà vì đường đi vào và ra khỏi kernel. Với thang đo đó, gọi một syscall cho mỗi phần tử trong một vòng lặp nóng là một sai lầm hiệu năng nghiêm trọng; gom nhiều thao tác vào một syscall là lý do tồn tại của bộ đệm.

Một lần tôi đo hớ: một "sự thật" đã hết hạn

Tôi vào bài với một điều tôi chắc chắn là đúng, vì đã đọc trong nhiều tài liệu kinh điển: thư viện C cache số hiệu tiến trình (pid). Sau khi tiến trình khởi động, getpid() chỉ trả về giá trị đã nhớ sẵn mà không cần trap vào kernel. Nên tôi dự đoán: getpid() sẽ nhanh gần như một lời gọi hàm, và chỉ có syscall(SYS_getpid) gọi thẳng mới chịu cái giá 120 ns.

Phép đo nói ngược lại: getpid() = 122,66 ns, gần bằng raw syscall 117,42 ns, và cả hai đều gấp ~55 lần lời gọi hàm. Nếu glibc còn cache pid, getpid() phải nằm ở nhóm 2 ns, không phải 120 ns. Vậy là dự đoán của tôi sai — nhưng sai vì sao?

strace là công cụ đúng để không phải đoán: nó cho tôi thấy mọi syscall một chương trình thực hiện. Tôi viết một chương trình gọi getpid() đúng năm lần trong mã nguồn rồi chạy strace:

so syscall getpid THẬT: 5

Năm lần gọi trong mã nguồn ra đúng năm syscall getpid thật sự — không hề có cache. Nếu glibc còn cache, strace sẽ chỉ thấy một syscall (hoặc không có), phần còn lại trả từ giá trị nhớ. Lý do: glibc 2.25 (năm 2017) đã bỏ cache pid. Cache đó từng gây lỗi tinh vi: nếu một chương trình tạo tiến trình con bằng lệnh clone() gọi thẳng (không qua fork() của thư viện), thì cache pid của con không được cập nhật và getpid() trả về số của cha — một cái bẫy nguy hiểm. Nên glibc gỡ hẳn cache, chấp nhận getpid() luôn trap để đổi lấy tính đúng đắn.

Bài học đo lường mở màn cho cả sê-ri: trực giác của tôi là một "sự thật có tài liệu" — nhưng của năm năm trước, và nay đã sai. Hệ điều hành và thư viện thay đổi; một tối ưu từng đúng có thể bị gỡ vì lý do đúng đắn (ở đây là tính chính xác đổi lấy tốc độ). Đừng tin một sự thật nhớ được về hành vi hệ thống — đo trên máy thật đang chạy, và khi cần biết chương trình thực sự làm gì với kernel, đọc strace chứ đừng đoán. Đây đúng là tinh thần tôi muốn mang suốt sê-ri hệ điều hành: mọi tầng bên dưới đều đo được, và bằng chứng thắng trí nhớ.

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

Hệ quả đầu tiên: giảm số syscall trên đường nóng bằng cách gom. Vì mỗi syscall tốn ~120 ns của riêng nó (chưa kể công việc thật), gọi write cho từng byte, hay read một byte một lần, là thảm họa hiệu năng. Đây là lý do mọi thư viện I/O đều đệm: fwrite/fread gom dữ liệu trong bộ nhớ người dùng và chỉ gọi write/read khi đệm đầy — biến hàng nghìn syscall thành vài cái. Khi tối ưu I/O, câu hỏi đầu tiên nên là "mình đang gọi bao nhiêu syscall?", và strace -c trả lời ngay.

Hệ quả thứ hai: biết syscall nào không trap nhờ vDSO. Vài thao tác đọc-thuần cực phổ biến — lấy thời gian (clock_gettime, gettimeofday) — được kernel ánh xạ vào không gian người dùng qua một cơ chế gọi là vDSO, nên chúng chạy nhanh như lời gọi hàm dù nhìn như syscall. Đây là lý do đo thời gian bằng clock_gettime trong vòng lặp nóng lại rẻ, trong khi getpid thì không. (Sê-ri này có một bài riêng đo vDSO.) Biết ranh giới đó giúp bạn đoán đúng cái nào rẻ, cái nào đắt — nhưng vẫn nên đo để chắc.

Hệ quả thứ ba là bài học đo lường mang theo: hành vi hệ thống thay đổi theo phiên bản; đo trên thứ bạn thật sự chạy, và đọc bằng chứng thay vì tin trí nhớ. Con số mang theo: một syscall (getpid) tốn ~120 ns, gấp ~55 lần một lời gọi hàm (~2 ns), vì phải vượt vào kernel; và getpid() ngày nay trap mỗi lần (strace: 5 gọi = 5 syscall) chứ không còn được glibc cache như tài liệu cũ nói — glibc đã bỏ cache đó từ 2017. Muốn biết chương trình của bạn tốn bao nhiêu cho kernel, đừng nhẩm theo trí nhớ; chạy strace -c và đọc con số thật.

Thử ba mươi giây

Chạy strace -c ls (hoặc bất cứ lệnh nào) và đọc bảng tóm tắt: nó liệt kê từng loại syscall, số lần gọi, và tổng thời gian — bạn sẽ ngạc nhiên một lệnh đơn giản gọi bao nhiêu syscall (openat, read, mmap, write...). Rồi thử strace -c trên một chương trình của bạn ghi ra file: nếu bạn thấy hàng nghìn lời gọi write, gần như chắc chắn bạn đang ghi không có đệm, và mỗi lời gọi đang trả cái giá ~120 ns vượt vào kernel. Gói chúng lại bằng một bộ đệm, chạy strace -c lần nữa, và xem số lời gọi write tụt xuống — đó là hiệu năng bạn vừa lấy lại, đo được bằng chính công cụ soi kernel.