Muốn biết một cuộc chạy bộ kéo dài bao lâu, bạn bấm đồng hồ bấm giờ, không phải liếc cái đồng hồ treo tường — vì giữa chừng có thể có người chỉnh lại đồng hồ tường cho đúng giờ chuẩn, và nếu bạn lấy giờ tường lúc về trừ giờ tường lúc đi, con số ra vô nghĩa. Đồng hồ bấm giờ chỉ biết đếm tiến; đồng hồ treo tường biết "mấy giờ" nhưng có thể bị vặn tới lui. Máy tính có đúng hai loại đồng hồ đó, và tôi đã lấy nhầm cái để đo — kết quả là một khoảng thời gian âm. gettimeofday là cách kinh điển lấy giờ trong C. Ở bài trước ta thấy nó tránh được syscall nhờ vDSO. Nhưng bài này nói về một cái bẫy nguy hiểm hơn tốc độ nhiều: dùng gettimeofday để đo khoảng thời gian có thể cho ra con số sai, thậm chí âm.
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 đó.
Khoảng thời gian âm: đoạn code chạy xong trước khi bắt đầu
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ề đến code hằng ngày
Điều đầ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.
Điều 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.
Và cái ranh giới "đồng hồ nào cho việc nào" này là một trong những chỗ dễ sai nhất ở mọi tầng, kể cả khi bạn không viết C. Java tách rất rõ: System.currentTimeMillis() là giờ tường (có thể nhảy, dùng để đóng dấu lịch), System.nanoTime() là đơn điệu (dùng để đo thời lượng) — và một bug kinh điển là dùng currentTimeMillis hiệu hai lần để đo latency, rồi thấy con số âm khi NTP bước; java.time còn tách hẳn Instant (thời điểm lịch) khỏi Duration. Go có time.Now() mang cả hai đồng hồ trong một giá trị và t2.Sub(t1) tự động dùng phần đơn điệu — một thiết kế học đúng từ bài học này. Ngay cả CSDL và hệ phân tán: một TIMESTAMP trong hàng và một "khoảng thời gian giữa hai sự kiện" là hai thứ khác nhau, và các hệ như Spanner phải dùng đồng hồ chuyên biệt vì tin tưởng giờ tường đồng bộ giữa các máy là nguồn của vô số bug. Mẫu số chung: "mấy giờ rồi" và "trôi qua bao lâu" là hai câu hỏi cần hai đồng hồ khác nhau; trả lời câu thứ hai bằng đồng hồ của câu thứ nhất là một quả bom chờ đồng hồ hệ thống nhảy để phát nổ. Gói lại bằng số: gettimeofday đọc giờ tường với độ phân giải micro giây (bước nhỏ nhất 1000 ns), 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), thậm chí "0 giây" cho thao tác nhanh hơn 1µs; còn clock_gettime(CLOCK_MONOTONIC) mịn tới nano giây (bước 41 ns, đo được 84 ns thật) và không bao giờ nhảy nên luôn đúng cho việc đo thời lượng.
Muốn tự thấy thì 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ó.