Muốn biết một cuộc chạy mất bao lâu, bạn có hai cách. Cách một: nhìn cái đồng hồ treo tường lúc xuất phát và lúc về đích rồi trừ. Cách hai: bấm một cái đồng hồ bấm giờ. Thường thì cả hai cho cùng kết quả — cho tới cái ngày ai đó vặn lại đồng hồ tường giữa cuộc chạy (đổi giờ mùa hè, chỉnh lại cho đúng), và phép trừ của bạn ra một con số vô nghĩa, thậm chí âm. Đồng hồ bấm giờ thì không bao giờ bị thế — nó chỉ đếm tiến. Trong lập trình, chọn nhầm loại "đồng hồ" khi đo thời lượng là đúng cái bug chực chờ đó, và nó ẩn rất kỹ.
Suốt sê-ri này mọi phép đo đều bắt đầu bằng một lời gọi clock_gettime để lấy giờ. Ở phần đầu ta thấy clock_gettime rẻ bất ngờ (~12,7 ns) nhờ vDSO. Nhưng clock_gettime không phải một thứ — nó nhận một clockid chọn nguồn thời gian, và các nguồn khác nhau rất nhiều về cả chi phí lẫn ý nghĩa. Chọn nhầm clock có thể làm chương trình chậm, hoặc tệ hơn, cho sai kết quả đo thời lượng. Tôi đo các clock chính trong container gcc:13 (ARM) — cả tốc độ lẫn độ phân giải.
Nhiều clock, khác cả giá lẫn nghĩa
clock_gettime(clockid, &ts) đọc thời gian, nhưng clockid chọn nguồn nào:
CLOCK_MONOTONIC/CLOCK_REALTIME: thời gian tường (wall clock). Được phơi bày qua vDSO nên đọc không vào nhân — rẻ và phân giải mịn.CLOCK_MONOTONIC_COARSE: một bản "thô" của MONOTONIC. Nhân cập nhật một biến thời gian định kỳ (mỗi nhịp lịch ~1 ms) và đọc COARSE chỉ là đọc biến đó — rẻ hơn nữa, nhưng phân giải chỉ ~1 ms. Đây là cái đồng hồ chỉ đổi số mỗi phút.CLOCK_PROCESS_CPUTIME_ID/CLOCK_THREAD_CPUTIME_ID: thời gian CPU mà tiến trình/luồng đã dùng (khác thời gian tường) — như hỏi "tôi đã bỏ ra bao nhiêu công sức thật", cần ai đó cộng dồn giùm. Tính chúng cần nhân cộng dồn thống kê lập lịch — phải vào nhân, không qua vDSO.
Và một khác biệt ý nghĩa quan trọng: CLOCK_REALTIME có thể nhảy — khi NTP đồng bộ, admin đổi giờ, hay giây nhuận, nó có thể tiến vọt hoặc lùi. Đó là cái đồng hồ tường ai cũng vặn lại được. CLOCK_MONOTONIC không bao giờ lùi — nó chỉ tăng đều, đúng cái đồng hồ bấm giờ. Điều này quyết định clock nào dùng để đo thời lượng.
Đo: chênh 10 lần về giá, 25.000 lần về phân giải
clock | ns/lần | phân giải
CLOCK_MONOTONIC | 13,5 | ~41 ns (vDSO, mịn)
CLOCK_REALTIME | 13,0 | ~41 ns (vDSO, NHẢY được)
CLOCK_MONOTONIC_COARSE | 3,2 | ~1 ms (thô)
CLOCK_PROCESS_CPUTIME_ID | 143,7 | (vào nhân)
CLOCK_THREAD_CPUTIME_ID | 140,9 | (vào nhân)
Chi phí trải một dải rộng. MONOTONIC/REALTIME ~13 ns — khớp con số vDSO ở phần 1, không vào nhân. COARSE còn rẻ hơn: 3,2 ns, gần 4 lần nhanh hơn MONOTONIC — vì nó chỉ đọc một giá trị nhân đã cập nhật sẵn, không cần đọc bộ đếm phần cứng. Nhưng đổi lại phân giải thô ~1 ms: nó chỉ đổi giá trị mỗi ~1 ms, nên bạn không thể đo bất cứ thứ gì ngắn hơn ~1 ms với nó.
Còn hai clock CPU-time (PROCESS/THREAD_CPUTIME) tốn ~140 ns — gấp ~11 lần MONOTONIC. Vì chúng không ở vDSO: tính thời gian CPU đã dùng cần vào nhân cộng dồn thống kê lập lịch. Nếu bạn gọi chúng trong một vòng nóng để "đo CPU của mình liên tục", bạn trả 140 ns mỗi lần — đáng kể. Đáng nói thêm về độ phân giải: clock_getres báo MONOTONIC là 1 ns, nhưng thực đo bước nhảy nhỏ nhất là ~41 ns — vì đó xấp xỉ thời gian thật trôi giữa hai lần đọc liên tiếp; con số nhân báo là lý thuyết, cái đo được mới là giới hạn thực.
(Môi trường đo: container gcc:13 trên host ARM AArch64, mỗi clock lấy sau warmup. Con số tuyệt đối và độ hạt COARSE đổi theo nhân/cấu hình; cái bền vững là thứ tự: COARSE < vDSO << CPU-time, và ý nghĩa REALTIME-nhảy vs MONOTONIC-không-lùi thì đúng ở mọi hệ.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với hai giả định. Thứ nhất: "lấy giờ là lấy giờ — clock nào cũng cùng giá". Sai — dải chi phí trải 10 lần (COARSE 3 ns tới CPU-time 140 ns). Thứ hai, ngầm và nguy hiểm hơn: "clock nào cũng đo thời lượng được, cứ trừ hai mốc". Sai — dùng CLOCK_REALTIME để đo thời lượng là một bug chực chờ: nếu NTP chỉnh giờ giữa hai mốc, hiệu số có thể âm hoặc lệch cả giây, cho một phép đo vô nghĩa mà không có lỗi rõ ràng. Đúng cái đồng hồ tường bị vặn lại giữa cuộc chạy.
Nhưng có một niềm tin ngược, sinh ra khi đã biết chuyện này, cũng sai: "vậy cứ dùng MONOTONIC cho mọi thứ cho chắc". Không — MONOTONIC không cho bạn thời điểm lịch. Ghi timestamp vào log, hẹn "chạy lúc 2 giờ sáng", so với giờ thế giới, đối chiếu sự kiện giữa nhiều máy — những việc đó phải dùng REALTIME, vì MONOTONIC chỉ là "số giây kể từ lúc máy bật", vô nghĩa với thế giới bên ngoài. Quy tắc đúng là chọn theo việc: đo thời lượng (dt = t2 − t1) → MONOTONIC (không bao giờ lùi, hiệu luôn đúng); ghi/so thời điểm lịch → REALTIME (và chấp nhận nó nhảy). Còn giữa MONOTONIC và COARSE thì theo độ phân giải cần: đo vi mô (dưới ms) phải MONOTONIC; chỉ cần biết "đã qua ~vài ms chưa" (timeout thô, rate limit, dấu thời gian log tần suất cao) thì COARSE rẻ hơn 4 lần và đủ tốt. (Đây chính là lý do hàm now() trong mọi phép đo của cả sê-ri này dùng CLOCK_MONOTONIC — đúng tinh thần đo lường vi mô đúng cách.)
Về đến code hằng ngày
Điều đầu tiên: đo thời lượng luôn dùng CLOCK_MONOTONIC, không bao giờ CLOCK_REALTIME. Đây là một lỗi correctness thật: code đo "mất bao lâu" bằng gettimeofday/CLOCK_REALTIME sẽ chạy đúng gần như mọi lúc, rồi cho một con số âm hay sai lệch đúng lúc NTP chỉnh giờ hoặc đổi múi giờ — một bug hiếm, khó tái hiện, y như các bug đồng thời của sê-ri trước.
Điều thứ hai: chọn clock theo độ phân giải cần, để tiết kiệm khi có thể. Nếu bạn đóng dấu thời gian tần suất rất cao và chỉ cần độ chính xác ~ms (log, rate limiting, cache TTL), CLOCK_MONOTONIC_COARSE rẻ hơn 4 lần — đáng kể ở hàng triệu lần gọi. Ngược lại, đừng dùng COARSE để đo những khoảng dưới ms: phân giải 1 ms của nó sẽ cho bạn 0 hoặc 1 ms, vô dụng. Và biết rằng đo thời gian CPU (CPUTIME clocks) đắt gấp ~11 lần thời gian tường — đừng gọi trong vòng nóng.
Chỗ này khác biệt clock theo bạn lên mọi ngôn ngữ, và chọn nhầm là một bug kinh điển. Java có đúng cặp này: System.nanoTime() là MONOTONIC (đo thời lượng), System.currentTimeMillis() là REALTIME (thời điểm lịch, có thể nhảy) — dùng currentTimeMillis để đo elapsed là lỗi sách giáo khoa. C++ phân biệt tường minh std::chrono::steady_clock (đo thời lượng) với system_clock (lịch). Go khéo hơn: time.Now() nhúng cả hai — một phần wall và một phần monotonic — và phép trừ t2.Sub(t1) tự dùng phần monotonic để miễn nhiễm với chỉnh giờ. Python thì time.monotonic() so với time.time(). Và ở tầng hệ phân tán, chính vì REALTIME nhảy và lệch giữa các máy mà người ta phải có NTP, rồi logical clock (Lamport) / TrueTime của Spanner để sắp thứ tự sự kiện — không ai tin nổi hai đồng hồ tường khớp nhau tới mili-giây. Cùng một bài học đồng-hồ-bấm-giờ-so-đồng-hồ-tường, từ một vòng lặp C tới cả một hệ phân tán.
Muốn tự thấy thì tìm trong code chỗ bạn đo "mất bao lâu" — quanh một thao tác, một request, một vòng lặp. Nó dùng clock gì? Nếu là time(), gettimeofday(), hay clock_gettime(CLOCK_REALTIME, ...) để đo thời lượng, đó là một bug tiềm ẩn: một lần NTP chỉnh giờ giữa chừng và phép đo của bạn cho số âm hoặc nhảy vọt. Đổi sang CLOCK_MONOTONIC (hay steady_clock trong C++, time.monotonic() trong Python, nanoTime() trong Java). Rồi hỏi tiếp: nếu bạn lấy timestamp tần suất rất cao mà chỉ cần độ chính xác mili-giây, có đáng đổi sang COARSE để rẻ hơn 4 lần không? Vài phút kiểm "clock nào cho việc gì" đó bắt được một lỗi correctness kín (REALTIME đo thời lượng) và một cơ hội tối ưu (COARSE cho timestamp thô) mà cái nhìn "lấy giờ là lấy giờ" bỏ lỡ.