Đọc đồng hồ là một trong những việc chương trình làm nhiều nhất: đo thời gian, đóng dấu log, đặt timeout, tính tốc độ. Hàm clock_gettime đọc giờ từ nhân — nghe như nó phải là một lời gọi hệ thống, tốn cả trăm nano giây mỗi lần như chi phí syscall bài đầu sê-ri đã đo. Nếu đúng vậy thì một chương trình đóng dấu thời gian hàng triệu lần mỗi giây sẽ tốn kém khủng khiếp. Tôi đo trong container — và con số cho clock_gettime bất khả đến mức nó tự tố cáo một cơ chế tôi chưa để ý.

clock_gettime và vDSO

Đo: một con số bất khả

Tôi đo chi phí mỗi lần gọi, ghim vào một lõi, lấy trung bình nhỏ nhất qua nhiều loạt:

clock_gettime(CLOCK_MONOTONIC): 13 ns
getpid()      (một syscall thật): 100 ns

clock_gettime nhanh hơn getpid. Điều này vô lý nếu cả hai đều là syscall: getpid là syscall đơn giản nhất có thể — nó chỉ trả về một số nguyên đã nằm sẵn trong nhân, gần như không làm gì ngoài cú vượt rào vào nhân và ra. Còn clock_gettime phải đọc đồng hồ phần cứng, tính toán, ghi vào một cấu trúc. Một việc nhiều hơn mà lại rẻ hơn cú vượt rào tối thiểu? Bất khả — trừ khi clock_gettime(MONOTONIC) không hề vượt rào vào nhân.

Đây đúng là bài học "số bất khả tố cáo công cụ nói dối", nhưng theo hướng tích cực: con số không phá vỡ mô hình của tôi vì phép đo sai, mà vì mô hình của tôi sai — tôi tưởng mọi clock_gettime là syscall. Bằng chứng nằm ở strace.

vDSO: nhân cho mượn một trang nhớ

Tôi strace một trăm nghìn lần gọi và đếm:

100.000 lần clock_gettime(CLOCK_MONOTONIC): 0 lời gọi hệ thống

Không một syscall nào. Cơ chế đứng sau là vDSO (virtual Dynamic Shared Object) — một thư viện tí hon mà nhân ánh xạ thẳng vào không gian địa chỉ của mọi tiến trình. Nhìn vào bản đồ bộ nhớ của chính tiến trình:

/proc/self/maps:
  ...  r--p  [vvar]    <- trang dữ liệu giờ của nhân (chỉ đọc)
  ...  r-xp  [vdso]    <- một mẩu mã của nhân

Nhân liên tục cập nhật giờ hiện tại vào trang [vvar], và trang này được ánh xạ chỉ-đọc vào mọi tiến trình. Khi bạn gọi clock_gettime(CLOCK_MONOTONIC), thư viện C không bẫy vào nhân; nó chạy một mẩu mã trong [vdso] đọc thẳng giờ từ trang [vvar] ngay trong không gian người dùng, cộng chút tính toán, rồi trả về. Không vượt rào, không chuyển ngữ cảnh — chỉ là đọc một trang bộ nhớ đã ánh xạ sẵn. Đó là lý do nó chỉ tốn 13 ns và không sinh syscall nào.

Một lần tôi đo hớ: không phải đồng hồ nào cũng vào vDSO

Tôi suýt chốt bài ở đây với thông điệp gọn gàng "clock_gettime luôn miễn phí nhờ vDSO". Nhưng khi đo thêm các loại đồng hồ khác, con số làm tôi khựng:

clock_gettime(CLOCK_MONOTONIC)        : 13 ns   (0 syscall)
clock_gettime(CLOCK_PROCESS_CPUTIME_ID): 143 ns  (1 syscall mỗi lần)
clock_gettime(CLOCK_THREAD_CPUTIME_ID) : 141 ns  (1 syscall mỗi lần)

Cùng một hàm clock_gettime, mà loại đồng hồ này 13 ns còn loại kia 143 ns — chênh 11 lần. Hai con số mâu thuẫn cho cùng một API nghĩa là chúng đi qua hai cơ chế khác nhau, và strace xác nhận: 100.000 lần gọi CLOCK_PROCESS_CPUTIME_ID sinh đủ 100.000 syscall.

Lý do: CLOCK_MONOTONICCLOCK_REALTIME đo "giờ tường" — một giá trị chung cho cả hệ thống, nên nhân đặt nó vào trang [vvar] chung và mọi tiến trình đọc được qua vDSO. Nhưng CLOCK_PROCESS_CPUTIME_ID đo thời gian CPU mà riêng tiến trình này đã dùng — một con số sống, riêng cho từng tiến trình, thay đổi theo lịch chạy, không thể đặt sẵn vào một trang chia sẻ. Muốn lấy nó, thư viện buộc phải hỏi nhân bằng một syscall thật. Cùng tên hàm, nhưng bản chất dữ liệu khác nhau quyết định có dùng được vDSO hay không.

Bài học đo lường: đừng khái quát từ một trường hợp. Nếu tôi chỉ đo CLOCK_MONOTONIC rồi tuyên bố "clock_gettime miễn phí", tôi đã sai với nửa số đồng hồ. Con số chỉ có nghĩa kèm đúng ngữ cảnh — ở đây là loại đồng hồ. (Tôi cũng lường trước một cái bẫy khác: sợ trình biên dịch -O2 xóa mất vòng đo vì kết quả không dùng, nên đã cộng giá trị vào một biến volatile. Đo ra vòng lặp vẫn còn — vì clock_gettime là lời gọi hàm có tác dụng phụ thấy được, trình biên dịch không dám bỏ; nhưng dự phòng vẫn đúng.)

Vì sao nhân phải bày ra cả một cơ chế cho việc đọc giờ

Có thể hỏi: tại sao nhân dựng hẳn vDSO chỉ để đọc giờ, trong khi hàng nghìn syscall khác vẫn phải vượt rào bình thường? Câu trả lời là đọc giờ quá nóng. Rất nhiều chương trình đọc đồng hồ hàng triệu lần mỗi giây: mọi khung hình game, mọi gói mạng được đóng dấu, mọi dòng log, mọi phép đo timeout. Nếu mỗi lần đọc giờ tốn 100 ns của một cú vượt rào, riêng việc xem giờ đã ngốn một phần đáng kể CPU của cả hệ thống. Ngày xưa gettimeofday chính là syscall bị gọi nhiều nhất trên nhiều máy chủ, đủ để trở thành nút thắt hiệu năng thật. vDSO ra đời để cắt cái thuế đó: những thao tác chỉ đọc một dữ liệu mà nhân sẵn lòng công khai — giờ hiện tại, và vài thứ nữa như getcpu — được đưa ra một trang chia sẻ để user-space đọc thẳng. Nó là một ngoại lệ có chủ đích cho đúng những đường đi nóng nhất, chứ không phải một cửa hậu tổng quát vào nhân.

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

Hệ quả đầu tiên: đọc giờ tường rẻ, cứ dùng thoải mái. Vì clock_gettime(CLOCK_MONOTONIC) chỉ tốn ~13 ns và không vào nhân, bạn có thể đóng dấu thời gian trong vòng lặp nóng, đo từng đoạn nhỏ, mà không sợ tốn kém — nó rẻ ngang một phép tính đơn giản. Đây là lý do các thư viện đo hiệu năng, tracing, và chính hàm now() tôi dùng trong mọi bài đo của sê-ri này đều dựa trên CLOCK_MONOTONIC.

Hệ quả thứ hai: chọn đúng loại đồng hồ, và biết cái nào đắt. Cần đo thời gian trôi qua (đo hiệu năng, timeout) thì dùng CLOCK_MONOTONIC — rẻ, và không nhảy lùi khi giờ hệ thống được chỉnh. Cần giờ lịch thật thì CLOCK_REALTIME (cũng vDSO, cũng rẻ). Nhưng nếu bạn đo thời gian CPU riêng của tiến trình/luồng (CLOCK_PROCESS_CPUTIME_ID), hãy biết mỗi lần gọi là một syscall ~143 ns — đừng gọi nó hàng triệu lần trong vòng nóng như thể nó cũng rẻ.

Hệ quả thứ ba là bài học đo lường: một con số bất khả là món quà, không phải phiền toái — nó chỉ thẳng vào một lỗ hổng trong mô hình của bạn. Con số mang theo: clock_gettime với đồng hồ tường (MONOTONIC/REALTIME) chỉ tốn ~13 ns và 0 syscall nhờ vDSO ánh xạ trang giờ của nhân vào user-space; nhưng với đồng hồ giờ-CPU thì tốn ~143 ns và một syscall mỗi lần, vì dữ liệu đó riêng từng tiến trình, không nằm trong trang chung được. Cùng một API, hai cơ chế, chênh mười lần — và chỉ strace với /proc/self/maps mới cho bạn thấy cái nào là cái nào.

Thử ba mươi giây

Chạy cat /proc/self/maps | grep -E 'vdso|vvar' — bạn sẽ thấy hai dòng [vdso][vvar] mà nhân đã lặng lẽ ánh xạ vào tiến trình cat vừa chạy, chính là cơ chế bài này đo. Rồi so hai lệnh dưới strace -c: strace -c -e trace=clock_gettime perl -e 'clock_gettime(1) for 1..1e6' gần như không đếm được lời gọi nào (giờ tường qua vDSO), trong khi đọc giờ-CPU sẽ hiện đủ số syscall. Nếu chương trình của bạn đóng dấu thời gian rất nhiều mà thấy tốn CPU bất thường, hãy strace -c nó và tìm clock_gettime trong bảng — nếu con số lớn, bạn đang dùng nhầm một loại đồng hồ không đi qua vDSO.