gettimeofday là cách kinh điển để lấy giờ trong C, có mặt trong vô số chương trình cũ. Ở bài trước ta thấy các hàm đọc giờ tránh được syscall nhờ vDSO — gettimeofday cũng vậy. Nhưng bài này không nói về tốc độ. Nó nói về một cái bẫy nguy hiểm hơn nhiều: dùng gettimeofday để đo khoảng thời gian có thể cho ra con số sai, thậm chí âm. Tôi tái hiện đúng cảnh đó trong container và nhận về một khoảng thời gian -1 giây.

gettimeofday và giờ nhảy

Hai loại đồng hồ, hai công việc khác nhau

Hệ điều hành có hai loại đồng hồ trả lời hai câu hỏi khác nhau. Giờ tường (CLOCK_REALTIME, chính là cái gettimeofday đọc) trả lời "bây giờ là mấy giờ theo lịch" — 14:32:07 ngày 3 tháng 9. Giờ đơn điệu (CLOCK_MONOTONIC) trả lời "đã bao nhiêu giây trôi qua kể từ một mốc bất kỳ" — một bộ đếm chỉ tiến, không gắn với lịch.

Khác biệt sống còn nằm ở chỗ: giờ tường được phép nhảy. Máy tính giữ giờ bằng một dao động không hoàn hảo, nên nó liên tục được đồng bộ với giờ chuẩn qua NTP; khi phát hiện lệch, NTP nhích giờ tường tới hoặc lui. Một quản trị viên có thể date -s đặt lại giờ. Có cả giây nhuận. Mỗi lần như vậy, giờ tường giật — có thể lùi lại. Giờ đơn điệu thì không bao giờ: nó chỉ đếm tiến đều, bất kể ai chỉnh lịch.

Điều này nghĩa là: nếu bạn đo "thời gian trôi qua" bằng cách lấy giờ tường ở đầu và cuối rồi trừ, một cú nhảy giờ giữa chừng sẽ làm hỏng phép đo. Tôi dựng đúng thí nghiệm đó.

Một lần tôi đo hớ: khoảng thời gian âm

Tôi đo thời gian một đoạn code chạy, theo cách quen thuộc: lấy giờ đầu, chạy, lấy giờ cuối, trừ. Nhưng giữa chừng, tôi giả lập một cú chỉnh giờ — dùng clock_settime đẩy giờ tường lùi lại 1 giây (đúng như NTP hay một admin có thể làm bất cứ lúc nào):

Đo bằng REALTIME (giống gettimeofday): elapsed = -1,000 giây
Đo bằng MONOTONIC (cùng đoạn code)   : elapsed = +0,000032 giây

Phép đo bằng giờ tường ra -1 giây. Một khoảng thời gian âm — đoạn code chạy xong trước khi nó bắt đầu. Về mặt vật lý đó là điều bất khả, và như mọi con số bất khả trong sê-ri này, nó tố cáo rằng công cụ đang nói dối: tôi đã dùng nhầm đồng hồ. Cùng đoạn code, đo bằng CLOCK_MONOTONIC, cho +0,000032 giây — đúng đắn, dương, hợp lý, vì đồng hồ đơn điệu phớt lờ cú chỉnh giờ và cứ đếm tiến.

Cái bẫy này âm hiểm vì nó không lộ ra trong lúc thử. Chạy trên máy của bạn, mọi phép đo bằng gettimeofday trông đúng — cho tới cái ngày NTP tình cờ nhích giờ đúng lúc bạn đang đo một request, và bỗng dưng log ghi "request mất -0,3 giây" hoặc "mất 4200 giây". Những sự cố đo thời gian kỳ quái nhất trong hệ thống thật thường có gốc ở đây: ai đó đo thời lượng bằng giờ tường.

Cái bẫy thứ hai: độ phân giải micro giây

gettimeofday còn một hạn chế nữa, ít nguy hiểm hơn nhưng dễ vấp. Cấu trúc timeval của nó chỉ có trường micro giây — nó không thể biểu diễn thời gian mịn hơn một phần triệu giây. Tôi đo bước nhỏ nhất mà mỗi đồng hồ phân biệt được:

gettimeofday  : bước nhỏ nhất = 1 micro giây (1000 ns)
clock_gettime : bước nhỏ nhất = 41 ns

Và khi đo một phép tính ngắn (vài trăm nano giây):

gettimeofday  đo được: 0 micro giây   (dưới ngưỡng phân giải!)
clock_gettime đo được: 84 ns

gettimeofday báo thao tác đó tốn 0 thời gian — lại một con số bất khả, lần này vì độ phân giải của nó quá thô để thấy được cái gì nhanh hơn 1 micro giây. clock_gettime với trường nano giây đo ra 84 ns thật. Nếu bạn từng thấy một hàm "chạy trong 0 mili giây", rất có thể bạn đang đo bằng một đồng hồ thô hơn thứ mình muốn đo.

Nhảy giật hay trượt êm: hai kiểu chỉnh giờ

Không phải cú chỉnh giờ nào cũng là một bước nhảy thô như thí nghiệm của tôi. NTP có hai cách kéo giờ tường về đúng. Khi lệch nhỏ, nó trượt (slew): tạm thời làm đồng hồ chạy nhanh hơn hoặc chậm hơn một chút cho tới khi bắt kịp, không bao giờ nhảy — cách này giữ giờ luôn tăng đơn điệu nhưng với nhịp hơi sai trong lúc điều chỉnh. Khi lệch lớn (mới khởi động, hay đồng hồ trôi quá xa), nó bước (step): đặt phắt giờ sang giá trị đúng, và đây chính là cú nhảy có thể lùi lại. Cái tôi giả lập bằng clock_settime là kiểu bước.

Điều đáng nói: ngay cả kiểu trượt êm ái cũng làm hỏng phép đo thời lượng bằng giờ tường một cách tinh vi hơn — một giây "trôi qua" theo giờ tường lúc đó không dài đúng một giây thật, nên phép đo lệch vài phần trăm mà không ai để ý. Đồng hồ đơn điệu miễn nhiễm với cả hai: dù NTP đang bước hay đang trượt giờ tường, CLOCK_MONOTONIC vẫn đếm thời gian thật đều đặn. Đó là lý do nó là lựa chọn duy nhất đúng cho mọi phép đo khoảng thời gian.

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

Hệ quả đầu tiên, và là quy tắc vàng: đo khoảng thời gian bằng đồng hồ đơn điệu, xem lịch bằng giờ tường. Bất cứ khi nào bạn tính "mất bao lâu" — thời gian phản hồi, timeout, tốc độ, đo hiệu năng — hãy dùng CLOCK_MONOTONIC (hay System.nanoTime() của Java, time.monotonic() của Python, Instant/steady_clock của C++). Chỉ dùng giờ tường (gettimeofday, System.currentTimeMillis, time.time()) khi bạn thật sự cần biết ngày giờ theo lịch — đóng dấu một sự kiện, hiển thị cho người dùng, ghi vào cơ sở dữ liệu. Lẫn hai cái là mời một lớp lỗi chỉ xuất hiện khi giờ hệ thống nhảy.

Hệ quả thứ hai: các timeout dựa trên giờ tường có thể hỏng bất ngờ. Một vòng lặp "chờ cho tới khi gettimeofday() vượt mốc + 30 giây" sẽ chờ mãi nếu giờ bị đẩy lùi, hoặc kết thúc ngay lập tức nếu bị đẩy tới. Đây là lý do các thư viện timeout tử tế đều dùng đồng hồ đơn điệu bên dưới. Khi tự viết logic hết hạn, hãy kiểm xem nó dựa trên đồng hồ nào.

Hệ quả thứ ba là bài học đo lường xuyên suốt: chọn đúng dụng cụ đo trước khi tin số nó cho. Con số mang theo: gettimeofday đọc giờ tường với độ phân giải micro giây, và giờ tường có thể nhảy khi được chỉnh — nên đo khoảng thời gian bằng nó có thể ra số sai hoặc âm (-1 giây trong thí nghiệm của tôi), còn clock_gettime(CLOCK_MONOTONIC) mịn tới nano giây và không bao giờ nhảy nên luôn đúng cho việc đo thời lượng. Một khoảng thời gian âm hay một thao tác "tốn 0 giây" đều là công cụ đang mách bạn rằng nó không phải dụng cụ hợp cho việc này.

Thử ba mươi giây

Trong bất kỳ ngôn ngữ nào bạn dùng, tìm xem hàm đo thời gian của bạn dựa trên đồng hồ nào. Trong Python: time.time() là giờ tường (có thể nhảy), time.monotonic() là đơn điệu — thử import time; print(time.get_clock_info('monotonic')) và để ý monotonic: True cùng resolution. Trong Java: System.currentTimeMillis() là giờ tường, System.nanoTime() là đơn điệu. Rồi soát lại code đo thời lượng của mình: nếu bạn thấy hiệu của hai lần gọi currentTimeMillis hay time.time() để tính một khoảng, đó là một quả bom hẹn giờ chờ NTP kích nổ — đổi sang đồng hồ đơn điệu là gỡ được nó.