Bạn viết nanosleep với một khoảng thời gian nhỏ — 1 micro-giây, 10 micro-giây — vì bạn muốn một độ trễ chính xác cỡ đó: chờ một chút giữa hai lần thử, giãn nhịp một vòng lặp, làm một cái đồng hồ đếm ngược mịn. Cái tên hàm còn có chữ "nano" — nghe như nó hẹn giờ được tới nano-giây. Nhưng ngủ, ở tầng hệ điều hành, không là một phép đo thời gian chính xác — nó là "hãy cất tôi đi, rồi đánh thức tôi dậy sau". Và việc đánh thức có một cái giá cố định. Tôi đo thời gian ngủ thực khi yêu cầu ngủ 1µs, 10µs, 100µs, 1ms, 10ms trong container gcc:13 (ARM), và con số cho thấy một cái sàn mà không lời gọi nào lách qua được.
Ngủ nghĩa là gì với hệ điều hành
Khi bạn gọi nanosleep(X), chuyện xảy ra không phải là CPU "đếm X nano-giây rồi tiếp tục". Luồng của bạn bị đưa vào trạng thái ngủ: nhân gỡ nó khỏi CPU, ghi một cái hẹn giờ (timer) "đến thời điểm hiện-tại + X thì đánh thức luồng này", rồi cho luồng khác chạy. Khi timer đến hạn, nhân đánh dấu luồng bạn "sẵn sàng" — nhưng nó chưa chạy ngay: nó phải chờ bộ lập lịch chọn nó và chuyển ngữ cảnh vào nó, đúng cái chi phí ~vài µs mà phần 5 về chuyển ngữ cảnh đã đo.
Nghĩa là mỗi lần ngủ có một chuỗi cố định: cất luồng đi, đặt timer, đến hạn, đánh thức, lập lịch lại. Chuỗi đó tốn thời gian bất kể bạn yêu cầu ngủ bao lâu. Nếu bạn yêu cầu ngủ lâu hơn chi phí đó nhiều, nó chìm nghỉm trong khoảng ngủ. Nhưng nếu bạn yêu cầu ngủ ngắn hơn chi phí đánh thức, thì thời gian ngủ thực bị chính cái chi phí đó khống chế — có một sàn, và bạn không ngủ ngắn hơn sàn được.
Tôi đo bằng cách lặp: đọc đồng hồ CLOCK_MONOTONIC (đúng loại đồng hồ phần 23 chốt là loại duy nhất đo thời lượng đúng), gọi nanosleep(X), đọc đồng hồ lại, lấy hiệu — lặp 30 lần và lấy giá trị nhỏ nhất (min) cho mỗi X, để loại nhiễu từ những lần bị lập lịch xui.
Đo: một cái sàn ~15µs
YÊU CẦU -> THỰC NGỦ (min) HỆ SỐ
1 us -> 15,8 us 15,8x
10 us -> 60,0 us 6,0x
100 us -> 108,8 us 1,1x
1 ms -> 1.110 us 1,1x
10 ms -> 10.186 us 1,0x
Nhìn cột hệ số. Yêu cầu ngủ 1µs nhưng thực ngủ 15,8µs — dài gấp gần 16 lần. Yêu cầu 10µs ra 60µs — gấp 6 lần. Đó không phải sai số nhỏ; đó là một cái sàn: không lần nào ngủ ngắn hơn ~15µs được, dù bạn yêu cầu 1µs hay 10ns. Cái ~15µs đó chính là chi phí "đánh thức + lập lịch lại luồng" — một chuyển ngữ cảnh — mà mọi lần ngủ đều phải trả.
Giờ nhìn xuống dưới. Khi yêu cầu ngủ vượt qua cái sàn đó, mọi thứ sát lại rất nhanh. 100µs ra 108,8µs (chỉ dư ~9µs, hệ số 1,1×). 1ms ra 1.110µs (dư ~110µs). 10ms ra 10.186µs (dư ~186µs, chỉ lệch 2%). Ngủ càng dài, cái overhead cố định càng nhỏ so với tổng, nên tỉ lệ càng tiến về 1. Với 10ms, sai số 186µs gần như không đáng kể.
Nên bức tranh rất rõ: nanosleep không hẹn giờ chính xác ở thang micro-giây. Nó có một sàn ~15µs (trên máy này; con số cụ thể tùy nhân và cấu hình timer), và chỉ khi bạn ngủ lâu hơn sàn nhiều lần thì thời gian ngủ thực mới sát yêu cầu. Với ngủ dài (mili-giây trở lên), nó khá chính xác; với ngủ ngắn (micro-giây), nó tròn lên mạnh.
Một lần tôi đo hớ: "nanosleep(1µs) ngủ đúng 1µs"
Tôi vào đo với niềm tin thẳng thắn nhất — cái tên hàm hứa gì thì tin nấy: "nanosleep nhận đơn vị nano-giây, nên nanosleep(1µs) sẽ ngủ đúng cỡ 1µs, sai lệch chút đỉnh thôi". Đo phá tan điều đó: 1µs thành 15,8µs, sai gấp 16 lần. Đây không phải "chút đỉnh" — đây là một sàn mà đối số của bạn không chạm tới.
Vì sao có sàn? Vì đánh thức một luồng đang ngủ là một lần lập lịch lại — nhân phải chọn luồng bạn, chuyển ngữ cảnh vào nó, khôi phục thanh ghi. Việc đó tốn ~vài chục µs (ở đây ~15µs), và đó là sàn cứng: bạn không thể được đánh thức nhanh hơn tốc độ hệ điều hành đánh thức nổi một luồng. Cộng thêm độ hạt của timer — nhân kiểm timer theo nhịp, nên thời điểm đánh thức bị làm tròn lên tới mốc nhịp gần nhất. Hai thứ đó dựng nên cái sàn.
Đáng nói: đây đúng cái đo hớ tôi đã gặp ở Series 25 khi đo khóa và livelock — có lần tôi định "ngủ 200ns giữa hai lần thử khóa" và đo ra mỗi lần ngủ ngốn ~50µs, làm hỏng cả phép đo. Lúc đó tôi tưởng mình chèn một khoảng nghỉ tí xíu; thực ra tôi chèn một lần chuyển ngữ cảnh đầy đủ. Cùng một cơ chế, cùng một cái bẫy — chỉ khác ngữ cảnh.
Bài học đo lường: ngủ là một thao tác lập lịch, không phải một phép đếm thời gian; nó có sàn bằng chi phí đánh thức, nên đừng dùng nanosleep để hẹn giờ chính xác dưới cái sàn đó. Nếu tôi tin "ngủ 1µs được", tôi sẽ viết một vòng lặp tưởng chạy mỗi 1µs mà thực ra chạy mỗi 16µs — chậm 16 lần so với dự tính, và không hiểu vì sao.
Muốn trễ dưới micro-giây: phải busy-spin
Vậy nếu bạn thật sự cần một độ trễ chính xác 500ns, hay 2µs, thì sao? Câu trả lời khó chịu nhưng thành thật: bạn không dùng nanosleep được — bạn phải busy-spin. Tức là đọc đồng hồ (CLOCK_MONOTONIC) trong một vòng lặp chặt cho tới khi đủ thời gian, không nhường CPU. Vì bạn không ngủ, không có chi phí đánh thức, nên bạn đạt được độ trễ dưới µs.
Nhưng busy-spin đốt trọn một lõi CPU suốt khoảng chờ — đúng cái đánh đổi "độ trễ thấp đổi bằng CPU" mà spinlock so với mutex ở Series 25 đã đo. Ngủ thì nhường CPU (0% khi chờ) nhưng có sàn ~15µs; spin thì đạt độ trễ nano-giây nhưng chiếm 100% một lõi. Không có lựa chọn nào miễn phí. Đây là lý do các thư viện độ trễ cực thấp (giao dịch tần suất cao, xử lý gói mạng) thường busy-spin: chúng có lõi để đốt, và cần độ trễ mà ngủ không cho được.
Một điểm nữa: đừng lẫn nanosleep với usleep/sleep. Cả ba đều ngủ qua cùng cơ chế lập lịch, nên cả ba đều có cùng cái sàn. Đơn vị mịn hơn (nano so với micro so với giây) không làm thời gian ngủ mịn hơn — nó chỉ cho bạn khai báo một con số mịn hơn mà hệ điều hành sẽ tròn lên y hệt.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: đừng ngủ những khoảng micro-giây và mong nó chính xác. Nếu code của bạn sleep(1µs) trong một vòng lặp — ví dụ để "nghỉ nhẹ" giữa các lần thử — mỗi lần nghỉ thực ra ngốn ~15µs, và nếu vòng lặp chạy triệu lần thì bạn mất 15 giây thay vì 1 giây. Đo cái sàn của hệ bạn (làm đúng thí nghiệm này) trước khi rải nanosleep khắp một vòng nóng.
Hệ quả thứ hai: chọn công cụ theo độ trễ cần. Cần chờ mili-giây trở lên (nhịp tim, hết giờ, thăm dò chậm)? nanosleep/sleep là đúng — chính xác đủ và nhường CPU. Cần độ trễ dưới ~50µs và chính xác? Ngủ không làm được; hoặc busy-spin (đổi bằng CPU), hoặc thiết kế lại để không cần chờ ngắn thế (dùng epoll chờ sự kiện thay vì thăm dò theo nhịp). "Ngủ một tí" giữa hai lần thử khóa hầu như luôn là mùi của một thiết kế nên dùng biến điều kiện thay vì thăm dò.
Hệ quả thứ ba là tinh thần đo lường: đừng tin đơn vị của một hàm là độ phân giải của nó. Con số mang theo: nanosleep có một sàn ~15µs (chi phí đánh thức = một lần lập lịch lại luồng + độ hạt timer): yêu cầu 1µs thực ngủ 15,8µs (16 lần!), 10µs → 60µs (6 lần); chỉ khi ngủ ≥100µs mới sát yêu cầu (100µs → 108,8µs, 10ms → 10,19ms, ~2%). Muốn trễ dưới µs chính xác phải busy-spin (đốt CPU, như spinlock). nanosleep để ngủ đủ lâu, không phải để hẹn giờ vi mô. Cái tên "nano" nói về đối số, không nói về độ chính xác.
Thử ba mươi giây
Viết một vòng lặp gọi nanosleep với tv_nsec = 1000 (1µs), đọc clock_gettime(CLOCK_MONOTONIC) ngay trước và ngay sau, in ra hiệu — lặp vài chục lần và lấy giá trị nhỏ nhất. Bạn sẽ thấy con số không gần 1.000ns mà là hàng chục nghìn ns — cái sàn của máy bạn. Rồi tăng dần đối số lên 10µs, 100µs, 1ms và xem tỉ lệ (thực/yêu cầu) tụt từ hàng chục lần về gần 1 khi bạn vượt qua sàn. Ba mươi giây đó dạy bạn một sự thật mà cái tên hàm che giấu: ngủ là một thao tác lập lịch có giá cố định, không phải một cái đồng hồ bấm giờ chính xác — và nếu bạn cần độ trễ dưới cái sàn ấy, bạn phải trả bằng một lõi CPU quay tại chỗ, không phải bằng một con số nhỏ hơn trong đối số.