Hình dung bạn nhờ một người đang ngủ trưa: "chợp mắt rồi dậy làm tiếp nhé". Cái nghi thức đánh thức — có ai đó để ý tới giờ, lay vai, người kia mở mắt, ngồi dậy, định thần — tốn một khoảng cố định, cứ cho là 15 phút. Nếu bạn bảo "ngủ 3 tiếng", 15 phút đó chìm nghỉm, dậy đúng giờ. Nhưng nếu bạn bảo "ngủ 1 phút", thì tới lúc lay được người ta dậy đã trôi 16 phút rồi — cái bạn xin (1 phút) nhỏ hơn cái nghi thức đánh thức, nên nó chẳng còn nghĩa lý gì. nanosleep hành xử đúng như vậy, và cái tên có chữ "nano" của nó đánh lừa không ít người.
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 — đúng cái nghi thức đánh thức người ngủ trưa. 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ôi trường đo: container gcc:13 trên host ARM AArch64, CLOCK_MONOTONIC, mỗi X lấy MIN qua 30 lần. Con số sàn tuyệt đối (~15µs) đổi theo nhân, cấu hình timer và mức ảo hoá; cái bền vững là có một cái sàn cỡ một chuyển ngữ cảnh, và tỉ lệ tiến về 1 khi ngủ vượt xa nó.)
Hai niềm tin đối nghịch, cùng sai
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". Con số 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, dựng nên bởi chi phí đánh thức một luồng (một lần lập lịch lại: chọn luồng, chuyển ngữ cảnh, khôi phục thanh ghi) cộng độ hạt của timer. Đâ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 — tôi tưởng mình chèn một khoảng nghỉ tí xíu, thực ra chèn một lần chuyển ngữ cảnh đầy đủ.
Nhưng có một niềm tin ngược, sinh ra ngay sau khi biết chuyện này, cũng sai: "vậy nanosleep vô dụng, ngủ chả bao giờ chính xác". Không đúng — nửa dưới của bảng cho thấy với ngủ ≥100µs nó rất sát (10ms chỉ lệch 2%). Vấn đề không phải "ngủ không chính xác", mà là ngủ có một sàn: dưới sàn thì vô nghĩa, trên sàn thì tốt dần. Dùng nanosleep để chờ mili-giây trở lên là hoàn toàn đúng đắn và còn nhường CPU cho việc khác; chỉ đừng dùng nó để hẹn giờ dưới cái sàn. Vứt bỏ nó hoàn toàn cũng sai lầm như tin nó chính xác tới nano-giây.
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ười thức trắng nhìn đồng hồ thay vì chợp mắt rồi nhờ lay dậy.
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ề đến code hằng ngày
Điều đầ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.
Điều 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. 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ò.
Chỗ này cái sàn đó theo bạn lên tận tầng cao, và biết nó giải thích được nhiều "vì sao chậm" khó hiểu. Thread.sleep(1) của Java hiếm khi ngủ đúng 1ms — nó chịu đúng cái sàn này, và trên Windows cũ Sleep(1) từng ngủ tận ~15ms vì độ hạt timer hệ điều hành, khiến vô số game/vòng lặp phải gọi timeBeginPeriod để hạ nó xuống. LockSupport.parkNanos(1000) của Java hay time.Sleep(time.Microsecond) của Go cũng vấp cùng cái sàn. Đó là lý do Java có Thread.onSpinWait() và Go có vòng spin trong runtime cho chờ cực ngắn — chính là busy-spin có kiểm soát. Còn ở tầng thiết kế, đây là một lý do nữa để chờ sự kiện (epoll, channel, condition variable) thay vì thăm dò theo nhịp ngắn: chờ sự kiện được đánh thức đúng lúc có việc, không phải quay lại mỗi µs để hỏi "xong chưa". Cùng một bài học nghi-thức-đánh-thức, ở mọi runtime.
Muốn tự thấy thì 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. Vài phút đó 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 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ố.