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.
Cái bẫy: đ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.
Điều rút ra 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ề đến code hằng ngày
Điều đầ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ờ.
Điều 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.
Điều cuối, 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. Gói lại bằng số: đọ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 (cho +3 µs đúng đắn) — đ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ồ.
Cái bẫy này không dừng ở một phép đo lẻ — nó gây ra những sự cố production có thật và đắt giá, và các hệ thống lớn đều học được bài học đó theo cách đau đớn. Java tách rạch ròi nanoTime() (monotonic, để đo) khỏi currentTimeMillis() (realtime, để hiện giờ) chính vì lý do này, và mọi thư viện đo thời gian nghiêm túc (Micrometer, các timeout của Netty) đều dựa trên nanoTime; ai lỡ tính timeout bằng currentTimeMillis sẽ gặp treo lơ lửng khi NTP giật. Go còn khéo hơn: time.Time mang kèm một "phần monotonic" ẩn, nên t2.Sub(t1) tự đúng ngay cả khi đồng hồ treo tường nhảy — một quyết định thiết kế sinh ra sau vô số bug timing. Cơ sở dữ liệu và hệ phân tán thì cả một lĩnh vực xoay quanh chuyện này: các cụm dựa vào timestamp treo tường để xếp thứ tự sự kiện có thể mất dữ liệu khi đồng hồ node nhảy lùi (bug NTP kinh điển), nên nhiều hệ dùng đồng hồ logic/hybrid (Lamport clock, HLC của CockroachDB, TrueTime của Spanner) thay vì tin giờ treo tường. Mẫu số chung, đúng như bài này: bất cứ khi nào bạn trừ hai thời điểm hay xếp thứ tự theo thời gian, hãy hỏi "đồng hồ này có thể nhảy không?" — nếu có, dùng nó để đo khoảng hay sắp thứ tự là gieo một quả bom hiếm khi nổ nhưng nổ thì không ai gỡ được.
Muốn 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ụ.