Phần trước ta thấy nanosleep có một cái sàn — không ngủ ngắn hơn ~chi phí đánh thức được. Nhưng câu hỏi thực tế hơn là làm một việc định kỳ: chạy một tác vụ mỗi 1ms, gửi một gói tim mạch mỗi 10ms, lấy mẫu cảm biến đều đặn. Cách đầu tiên ai cũng nghĩ tới là một vòng lặp nanosleep(period) — ngủ một chu kỳ, làm việc, ngủ tiếp. Nghe hợp lý. Nhưng nó có một khuyết tật nghiêm trọng mà chỉ đo mới thấy: sai số dồn lại. Linux có hai công cụ đúng cho việc này — timerfdsetitimer — và tôi đo cả ba trong container gcc:13 để cho thấy khác biệt là bản chất, không phải mức độ.

Timer: timerfd và setitimer

Hai cách hẹn giờ định kỳ

Cách ngây thơ — vòng lặp ngủ: while(1){ làm_việc(); nanosleep(period); }. Vấn đề: mỗi vòng, bạn ngủ đúng một period — nhưng thời điểm bạn bắt đầu ngủ đã bị trễ so với lịch lý tưởng, vì vòng trước đã tốn thời gian (làm việc + cái sàn đánh thức ~vài chục µs của phần 24 + chi phí lập lịch). Ngủ thêm một period tính từ bây giờ nghĩa là bạn không bao giờ bù lại phần đã trễ. Mỗi vòng thêm một chút lỗi, và không ai sửa — nên lỗi dồn tích lũy. Đây là timer tương đối (relative): "ngủ thêm period nữa".

Cách đúng — kỳ hạn tuyệt đối: timerfdsetitimer bảo nhân: "đánh thức tôi tại các mốc t0, t0+period, t0+2·period, …". Nhân giữ kỳ hạn tuyệt đối (absolute deadline). Nếu tick thứ 5 bị trễ vì hệ bận, tick thứ 6 vẫn nhắm đúng mốc t0+6·period của nó — cái trễ không đẩy các tick sau. Drift vì thế bị chặn, không tích lũy.

  • timerfd: tạo một file descriptor; mỗi lần timer đến hạn, đọc fd trả về số lần đã hết hạn (nếu bạn xử lý chậm và lỡ vài tick, con số này >1 — bạn không mất tick, biết chính xác đã bỏ lỡ mấy nhịp). Và vì nó là fd, bạn cắm thẳng vào epoll cùng socket và các fd khác — một vòng lặp sự kiện duy nhất.
  • setitimer: timer cổ điển, báo hết hạn qua tín hiệu SIGALRM (phần 16). Đơn giản nhưng phải viết trình xử lý tín hiệu, và tín hiệu khó ghép với vòng lặp sự kiện.

Tôi đo drift tích lũy của cả ba: đặt timer chu kỳ 1ms và 10ms, chạy nhiều tick, so tổng thời gian thực với tổng lý tưởng.

Đo: lặp ngủ trôi 30%, timer tuyệt đối không trôi

timerfd    1ms × 200 tick : jitter tb 2063µs, drift sau 200 tick = 2178µs (~hằng)
timerfd   10ms ×  50 tick : drift sau 50 tick = 3152µs
naive lặp  1ms × 200 tick : drift = 61.166µs = 61 ms   (30,6% mỗi chu kỳ!)
naive lặp 10ms ×  50 tick : drift = 170.824µs = 171 ms (34,2%)
setitimer  1ms trong 2s   : nhận 1999 / 2000 tick (mất 1 = 0,1%)

Nhìn hai dòng naive. Với chu kỳ 1ms, sau 200 tick lẽ ra là 200ms — nhưng thực tế mất 261ms, trôi 61ms, tức mỗi chu kỳ dài hơn dự tính 30,6%. Với 10ms còn tệ hơn: trôi 34,2%. Đây là drift tích lũy: một chương trình tưởng chạy 100 lần/giây thực ra chỉ chạy ~77 lần/giây, và càng chạy lâu càng lệch pha với đồng hồ tường.

Giờ nhìn timerfd: drift sau 200 tick chỉ 2178µs — và điều quan trọng là nó ~hằng số, không lớn lên theo số tick (jitter trung bình ≈ drift cuối, dấu hiệu mỗi tick lệch một lượng gần như cố định chứ không cộng dồn). setitimer cũng vậy: chạy 2 giây với chu kỳ 1ms, nó nhận 1999/2000 tick — chỉ mất 0,1%, giữ đúng nhịp. Cả hai dùng kỳ hạn tuyệt đối nên sai số của một tick không truyền sang tick sau.

Một sự thật tôi phải nói thẳng: cái jitter tuyệt đối ~2ms của timerfd ở đây là lớn — lớn hơn cả cái sàn 15µs của phần trước. Đó là vì môi trường này là container ảo hoá ARM, nơi đánh thức timer bị thô (host có thể đang bận, độ hạt timer ảo lớn). Trên máy vật lý bình thường, jitter timerfd thường cỡ vài chục µs. Con số tuyệt đối phụ thuộc môi trường; điều bền vững qua mọi môi trường là dáng của drift: timer tuyệt đối chặn nó ở một hằng số, còn vòng lặp ngủ để nó dồn tuyến tính. Đó mới là bài học, không phải con số 2ms cụ thể.

Một lần tôi đo hớ: "lặp ngủ là timer chính xác"

Tôi vào đo với niềm tin rất tự nhiên: "muốn chạy mỗi 1ms thì cứ nanosleep(1ms) trong vòng lặp — đơn giản và chính xác". Đo phá tan: drift 30,6% sau 200 tick. Vì sao? Vì mỗi vòng, thời gian thực trôi qua là period + (sàn đánh thức) + (thời gian làm việc), và tôi cộng thêm một period nữa tính từ điểm hiện tại — không bao giờ trừ đi phần đã lố. Lỗi mỗi vòng nhỏ (vài trăm µs), nhưng nhân 200 vòng thành 61ms. Đây đúng mẫu "chi phí nhỏ nhân số lần lớn" xuyên suốt sê-ri, lần này ở dạng sai số thay vì chi phí.

Cái bẫy nằm ở chỗ tương đối so với tuyệt đối. Vòng lặp ngủ hỏi "ngủ thêm bao lâu nữa" (tương đối), nên mọi trễ trong quá khứ đều được giữ lại và cộng dồn. Timer tuyệt đối hỏi "đánh thức tôi tại thời điểm nào" (tuyệt đối), nên một trễ chỉ ảnh hưởng đúng tick đó rồi tự điều chỉnh về lịch. Nếu muốn tự sửa bằng nanosleep, bạn phải tự tính kỳ hạn tuyệt đối và ngủ tới nó (dùng clock_nanosleep với cờ TIMER_ABSTIME) — nhưng lúc đó bạn đã dựng lại đúng cái mà timerfd cho sẵn.

Bài học đo lường: timer định kỳ đúng phải dựa trên kỳ hạn tuyệt đối; một vòng lặp ngủ tương đối sẽ trôi, và cái trôi đó chỉ lộ ra khi đo dài — chạy vài tick thì trông vẫn ổn. Nếu tôi chỉ đo 5 nhịp rồi kết luận "ổn mà", tôi đã bỏ lỡ cái drift chỉ hiện hình sau vài trăm nhịp — đúng lúc nó gây hại thật (lệch pha, dồn độ trễ).

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

Hệ quả đầu tiên: đừng làm timer định kỳ bằng vòng lặp sleep(period). Nó trôi 30% trở lên trên workload thật, và cái trôi tích lũy — một dịch vụ chạy nhiều giờ sẽ lệch pha nghiêm trọng với thời gian thực. Dùng timerfd (nếu bạn có vòng lặp sự kiện — nó cắm epoll gọn) hoặc setitimer/clock_nanosleep(TIMER_ABSTIME) để nhân giữ kỳ hạn tuyệt đối cho bạn.

Hệ quả thứ hai: chọn timerfd khi bạn đã có epoll, setitimer khi cần đơn giản. timerfd hợp nhất timer với mọi fd khác trong một vòng lặp epoll duy nhất — đây là cách các server mạng hiện đại làm hết-giờ (timeout) mà không cần luồng riêng hay tín hiệu. Nó còn cho bạn số expirations mỗi lần đọc, nên nếu lỡ vài nhịp vì bận, bạn biết chính xác đã lỡ mấy cái và xử lý bù. setitimer qua SIGALRM đơn giản hơn cho một timer lẻ, nhưng tín hiệu khó ghép và chỉ có một ITIMER_REAL mỗi tiến trình.

Hệ quả thứ ba là tinh thần đo lường: đo drift qua nhiều nhịp, đừng tin một vài tick. Con số mang theo: vòng lặp nanosleep(period) là timer tương đối nên sai số DỒN — drift 61ms sau 200 tick 1ms (30,6%!), 34,2% với 10ms; timerfd và setitimer giữ KỲ HẠN TUYỆT ĐỐI (tick i tại t0+i·period) nên drift KHÔNG dồn (timerfd ~hằng; setitimer mất chỉ 0,1% tick trong 2s); timerfd còn cắm epoll được và đếm expirations (không mất tick). Định kỳ chính xác đến từ kỳ hạn tuyệt đối, không phải từ việc cộng thêm một khoảng ngủ mỗi vòng.

Thử ba mươi giây

Viết một vòng lặp gọi nanosleep với chu kỳ 1ms, 500 lần, đo tổng thời gian thực bằng clock_gettime(CLOCK_MONOTONIC) trước và sau, so với 500ms lý tưởng. Bạn sẽ thấy nó lố hàng chục phần trăm — cái drift tích lũy. Rồi làm lại bằng timerfd_create + timerfd_settime với it_interval 1ms, đọc fd 500 lần, đo lại: tổng sẽ sát 500ms hơn hẳn, và không lớn thêm theo số vòng. Ba mươi giây đó cho bạn thấy vì sao mọi vòng lặp sự kiện nghiêm túc dùng timer tuyệt đối chứ không dùng sleep để đếm nhịp: sai số của một cái "ngủ thêm period" là tương đối và dồn lại, còn một kỳ hạn tuyệt đối thì tự kéo bạn về đúng lịch mỗi nhịp.