Suốt sê-ri này tôi liên tục nhắc "tin đồng hồ hơn bộ đếm". Nhưng "đồng hồ" nào? Linux có nhiều nguồn đồng hồ khác nhau, và chọn nhầm loại là một trong những lỗi timing kín đáo nhất — code chạy đúng hàng tháng trời rồi một ngày cho ra một khoảng thời gian âm, hay một timeout dài vô lý, mà không ai tái hiện được. Bài này đo hai nguồn đồng hồ quan trọng nhất — CLOCK_REALTIME và CLOCK_MONOTONIC — cả về chi phí đọc lẫn hành vi khi thời gian bị chỉnh, và cho thấy tận mắt cái bẫy đã sinh ra vô số bug bí ẩn.
Hai loại đồng hồ, hai công dụng
CLOCK_REALTIME là giờ treo tường: số giây tính từ mốc epoch (1/1/1970), đúng cái thời gian mà date in ra và người ta hiểu là "bây giờ mấy giờ". Nhưng nó có một tính chất nguy hiểm: nó có thể nhảy. NTP đồng bộ với máy chủ thời gian và kéo đồng hồ tới lui; admin chỉnh giờ bằng tay; giây nhuận chèn vào. Realtime không đơn điệu — nó có thể nhảy tiến, và tệ hơn, nhảy lùi về quá khứ.
CLOCK_MONOTONIC là thời gian tăng đều từ một mốc tùy ý (thường là lúc khởi động máy). Nó không mang ý nghĩa "mấy giờ", nhưng nó có một bảo đảm vàng: không bao giờ nhảy lùi, và không bị admin hay NTP giật tới lui. Đúng loại để đo khoảng thời gian (thời lượng, timeout, đo hiệu năng). Còn hai họ hàng ít gặp: CLOCK_MONOTONIC_RAW miễn nhiễm cả với việc NTP kéo giãn tốc độ, và CLOCK_BOOTTIME cộng thêm cả thời gian máy ngủ (suspend). Tôi muốn đo hai chuyện: đọc mỗi loại tốn bao nhiêu, và chuyện gì xảy ra khi đồng hồ bị chỉnh giữa một phép đo.
Đo: chi phí đọc, và cú nhảy chết người
Trước hết là chi phí đọc. Tôi gọi clock_gettime hai chục triệu lần cho mỗi nguồn:
| Nguồn đồng hồ | Chi phí đọc |
|---|---|
| CLOCK_REALTIME | 12 ns |
| CLOCK_MONOTONIC | 11 ns |
| CLOCK_MONOTONIC_RAW | 11 ns |
| CLOCK_BOOTTIME | 11 ns |
| CLOCK_PROCESS_CPUTIME_ID | 145 ns |
Bốn nguồn đầu đều nhanh như nhau (~11–12 ns) vì chúng được phục vụ qua vDSO — một vùng nhân ánh xạ vào không gian người dùng cho phép đọc giờ không cần lời gọi hệ thống. Nhưng CLOCK_PROCESS_CPUTIME_ID (thời gian CPU tiến trình đã dùng) tốn 145 ns, chậm gấp 12 lần — vì nó không nằm trong vDSO mà phải thật sự trap vào nhân (một syscall). strace xác nhận: chỉ nguồn cuối này mới hiện ra dưới dạng lời gọi clock_gettime, còn bốn nguồn kia hoàn toàn vô hình với nhân.
Giờ tới phần quan trọng: cú nhảy. Tôi viết một chương trình đo một khoảng thời gian ngắn bằng cả hai đồng hồ, nhưng ngay giữa phép đo, tôi mô phỏng NTP (hay admin) đẩy đồng hồ realtime lùi 10 giây rồi khôi phục lại ngay sau đó:
REALTIME đo khoảng = -10,000 s <- ÂM! khoảng thời gian đi ngược
MONOTONIC đo khoảng = +0,000003 s <- đúng thời gian thật đã trôi
Kết quả nói tất cả. Đo cùng một khoảng thời gian, CLOCK_MONOTONIC cho +3 micro giây — thời gian thật đã trôi giữa hai lần đọc. Nhưng CLOCK_REALTIME cho -10 giây — một khoảng thời gian âm, như thể thời gian chạy ngược. Bởi vì trong lúc đo, đồng hồ realtime bị giật lùi 10 giây, nên "kết thúc trừ bắt đầu" ra một số âm khổng lồ. Monotonic không hề hấn gì vì nó không bao giờ nhảy.
Một lần tôi đo hớ: đo khoảng bằng đồng hồ treo tường
Đây chính là chỗ bẫy, và tôi phải thú thật bản năng đầu tiên của mình cũng sai. Khi cần đo một khoảng thời gian, phản xạ tự nhiên là lấy đồng hồ "thật" — CLOCK_REALTIME, hay gettimeofday, hay hàm now()/System.currentTimeMillis()/time.time() mặc định của rất nhiều ngôn ngữ, vốn đều dựa trên realtime. Và nó cho số đúng 99,9% thời gian, nên ta dễ đinh ninh mình chọn đúng và không bao giờ nghĩ lại.
Nhưng "đúng 99,9%" là cái bẫy nguy hiểm nhất, vì 0,1% còn lại xảy ra ngẫu nhiên và không tái hiện được: đúng vào lúc một phép đo đang chạy thì NTP đồng bộ, hoặc ai đó chỉnh giờ máy chủ, và đồng hồ nhảy. Kết quả là một khoảng thời gian âm, hoặc một khoảng dài hàng giờ vô lý — một timeout bỗng dưng kích hoạt sai, một phép đo hiệu năng ra số điên rồ, một cache TTL tính sai. Bug kiểu này hiếm tới mức khi nó xảy ra một lần, không ai gỡ được vì chạy lại thì hết. Phép đo của tôi cho thấy nó tận mắt: -10 giây.
Bài học đo lường có hai tầng. Thứ nhất, cụ thể: đo thời lượng thì luôn dùng CLOCK_MONOTONIC (không bao giờ nhảy), đừng dùng đồng hồ treo tường — realtime là một giá trị có thể bị sửa ngay dưới chân mình, không phải một dòng thời gian đáng tin để đo khoảng. Thứ hai, tổng quát và đúng với cả sê-ri: một con số bất khả là dấu hiệu rõ ràng nhất rằng ta đang đo nhầm đại lượng. Một khoảng thời gian không thể âm; khi nó ra âm, đừng tìm lỗi trong logic — hãy nhận ra ta đang đọc nhầm loại đồng hồ. "Tin đồng hồ hơn bộ đếm" phải kèm điều kiện: tin đồng hồ đúng loại.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là mọi phép đo khoảng thời gian phải dùng đồng hồ monotonic. Đo độ trễ, tính timeout, đặt deadline, đo thời gian chạy hàm, tính rate-limit — tất cả phải dựa trên monotonic. Nhiều ngôn ngữ đã tách rõ hai loại và bạn phải chọn đúng: Go có time.Now() (chứa cả phần monotonic, nên phép trừ tự đúng) nhưng time.Since mới là cách chuẩn; Java có System.nanoTime() (monotonic) tách khỏi currentTimeMillis() (realtime); Python có time.monotonic() tách khỏi time.time(). Dùng nhầm cái sau để đo khoảng là gieo một quả bom hẹn giờ.
Hệ quả thứ hai là realtime vẫn cần, nhưng cho đúng việc: hiển thị và ghi mốc thời gian tuyệt đối. Khi bạn cần biết "sự kiện này xảy ra lúc mấy giờ ngày nào" — dấu thời gian log, hạn dùng của token, lịch hẹn — thì phải là realtime, vì monotonic không cho biết giờ thật. Quy tắc gọn: realtime để trả lời "khi nào", monotonic để trả lời "bao lâu". Lẫn hai câu hỏi này là gốc của lỗi.
Hệ quả thứ ba, về đo lường: một kết quả bất khả không phải để sửa, mà để đặt lại câu hỏi. Con số mang theo: đọc realtime/monotonic đều nhanh ~12 ns qua vDSO (còn CPU-time clock là syscall, 145 ns); nhưng realtime CÓ THỂ nhảy lùi (NTP, admin, giây nhuận) nên đo khoảng bằng nó có thể ra số ÂM (-10 giây trong phép đo), còn monotonic không bao giờ nhảy — đo thời lượng phải dùng monotonic, realtime chỉ để hiện giờ tuyệt đối. Khi phép đo cho một con số không thể tồn tại, thủ phạm gần như luôn là ta đang đo nhầm đại lượng — ở đây là nhầm đồng hồ.
Thử ba mươi giây
Kiểm code của bạn có đang đo khoảng bằng nhầm đồng hồ không: tìm những chỗ tính thời_gian_kết_thúc - thời_gian_bắt_đầu và xem hàm lấy thời gian là loại gì. Nếu là time.time() (Python), System.currentTimeMillis() (Java), Date.now() (JS), gettimeofday/CLOCK_REALTIME (C) — đó là đồng hồ treo tường, và phép đo khoảng của bạn có một quả bom hẹn giờ. Đổi sang time.monotonic(), System.nanoTime(), performance.now(), hay CLOCK_MONOTONIC. Muốn thấy tận mắt cú nhảy, trên một máy thử (không phải production!) chạy một vòng in date +%s.%N rồi ở terminal khác gõ sudo date -s '10 seconds ago' — bạn sẽ thấy con số giây tụt lùi, đúng cái làm phép đo khoảng ra âm mà bài này đo. Đừng bao giờ làm điều đó trên máy thật đang chạy dịch vụ.