Có hai cách đặt báo thức. Cách thứ nhất: mỗi sáng tỉnh dậy, bạn vặn đồng hồ "cho tôi ngủ thêm 8 tiếng nữa". Cách thứ hai: bạn đặt chuông reo đúng 7 giờ mỗi ngày. Nghe thì như nhau, nhưng nếu mỗi sáng bạn nằm nướng 20 phút rồi mới vặn "8 tiếng nữa", giờ thức của bạn sẽ trôi mỗi ngày một muộn — còn cái chuông 7 giờ thì luôn reo đúng 7 giờ, bất kể hôm qua bạn nướng bao lâu. Đó chính xác là khác biệt giữa một vòng lặp sleep và một timer tuyệt đối, và nó là cái bẫy tôi từng rơi vào khi cần chạy một việc đều đặn.

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 — timerfd và setitimer — 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 độ.

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" — đúng cái "cho tôi ngủ thêm 8 tiếng".

Cách đúng — kỳ hạn tuyệt đối: timerfd và setitimer 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. Đúng cái chuông 7 giờ. 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 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 — vì môi trường này là container ảo hoá ARM, nơi đánh thức timer bị thô (host 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ể.)

Hai niềm tin đối nghịch, cùng sai

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". Con số 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òn một niềm tin ngược cũng dễ mắc, theo hướng khác: "chạy thử vài nhịp thấy đúng giờ mà, timer nào chả như nhau". Đây mới là cái bẫy nguy hiểm — vì drift tích lũy, nên đo 5 tick thì cả hai cách trông y hệt nhau, sai lệch chỉ vài trăm µs không ai để ý. Nó chỉ lộ mặt sau vài trăm nhịp, đúng lúc chương trình đã chạy lâu ngoài đời thật và bắt đầu lệch pha nghiêm trọng. Kết luận "ổn mà" từ một phép đo ngắn chính là cách cái bug này sống sót tới production. Cái bẫy gốc nằm ở tương đối so với tuyệt đối: vòng lặp ngủ hỏi "ngủ thêm bao lâu nữa", nên mọi trễ quá khứ đượ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", nên một trễ chỉ ảnh hưởng đúng tick đó rồi tự kéo về lịch. (Muốn tự sửa bằng nanosleep thì phải tự tính mốc tuyệt đối và ngủ tới nó bằng clock_nanosleep(TIMER_ABSTIME) — nhưng lúc đó bạn đã dựng lại đúng thứ timerfd cho sẵn.)

Về đến code hằng ngày

Điều đầ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.

Điều 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.

Chỗ này cái bẫy tương-đối lặp lại y hệt ở tầng cao, và biết nó cứu bạn khỏi một bug kinh điển. Java có đúng hai phương thức lập lịch phản chiếu chính xác phép đo trên: ScheduledExecutorService.scheduleAtFixedRate giữ nhịp tuyệt đối (không drift, như timerfd/setitimer), còn scheduleWithFixedDelay chờ "delay sau khi task xong" — tương đối, và drift dồn đúng như vòng lặp sleep. Chọn nhầm là bug lệch pha kinh điển. Go cũng vậy: time.Ticker giữ nhịp tuyệt đối (và bỏ tick nếu receiver chậm, giống timerfd đếm expirations), còn một vòng for { work(); time.Sleep(period) } thì trôi. Game loop nghiêm túc dùng "fixed timestep" tích lũy thời gian thật thay vì sleep cố định vì cùng lý do. Cùng một bài học chuông-7-giờ: nhịp đúng đến từ mốc tuyệt đối, không từ việc cộng thêm một khoảng chờ mỗi vòng.

Muốn tự thấy thì 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. Vài phút đó 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.