C++ sleep_for vs sleep_until: Drift, Clock Choice and Cancellable Waits

Key takeaways

sleep_for waits at least a duration, sleep_until waits until a time_point. Use steady_clock deadlines, sleep_until for fixed-rate loops, a condition variable or stop_token when the wait must be cancellable, and never expect millisecond precision from the OS scheduler.

The two sleep functions

<thread> gives you two ways to block the current thread, both taking <chrono> types:

#include <chrono>
#include <thread>
using namespace std::chrono_literals;

std::this_thread::sleep_for(200ms);                                   // relative
std::this_thread::sleep_until(std::chrono::steady_clock::now() + 2s); // absolute

Because they take std::chrono::duration and time_point rather than a raw integer, the unit is part of the type. sleep_for(200ms) cannot be misread as 200 seconds, and passing std::chrono::seconds(2) where milliseconds is expected converts correctly. The literals (ms, s, min, us) come from std::chrono_literals; see the duration post for how conversions between units work.

Both functions share one guarantee and nothing more: the thread will not resume before the requested time. There is no upper bound. The thread goes to sleep, the OS wakes it on some later timer interrupt, and then it has to be scheduled onto a CPU, which may be busy with other work.

How long a “short” sleep really takes

The gap between requested and actual sleep is not a rounding error at small durations; it dominates. On Windows the default system timer ticks at 64 Hz (about 15.6 ms), and a sleep usually can’t end between ticks. Running this on a stock Windows 11 machine with MinGW GCC 10.3, a requested 1 ms sleep came back after roughly 15 ms:

auto a = std::chrono::steady_clock::now();
std::this_thread::sleep_for(1ms);
auto actual = std::chrono::duration<double, std::milli>(
    std::chrono::steady_clock::now() - a);
std::cout << actual.count() << " ms\n"; // printed ~14.8 on that machine

Linux with high-resolution timers typically does much better, but still adds “timer slack” (a small amount the kernel may delay wakeups to batch them; 50 µs by default for normal threads) plus scheduling latency. Under load, any platform can oversleep by several milliseconds.

Practical consequences:

  • Don’t use a sleep to measure or produce precise intervals. If you need to know how long something took, measure with steady_clock before and after.
  • sleep_for(1us) is not a microsecond pause. It’s a “give up the CPU until the next wakeup opportunity” call.
  • Sleep durations in tests (“sleep 10 ms and assume the other thread ran”) are the root of many flaky CI jobs. Synchronize on the event itself instead.

Fixed-rate loops: why sleep_for(interval) drifts

The obvious periodic loop is:

while (running) {
    do_tick();
    std::this_thread::sleep_for(1s);
}

Each iteration takes work time + 1 s + oversleep. If do_tick() takes 30 ms and the sleep overshoots by a few milliseconds, the loop runs a bit slower than once per second, and the error accumulates: after an hour you have done noticeably fewer ticks than 3600.

The fix is to schedule against an absolute deadline that advances by exactly one interval each time:

void fixed_rate_loop() {
    const auto interval = 1s;
    auto next = std::chrono::steady_clock::now();
    for (int i = 0; i < 10; ++i) {
        next += interval;
        do_tick();
        std::this_thread::sleep_until(next);
    }
}

Now each individual wake-up is still a little late, but the lateness doesn’t compound, because the next deadline is computed from the previous deadline, not from when the thread happened to wake.

There’s a catch worth handling deliberately. If do_tick() sometimes takes longer than interval, next falls into the past, sleep_until returns immediately, and the loop runs several ticks back-to-back to “catch up”. Whether that’s right depends on the job. A metrics sampler usually wants to skip missed ticks:

auto now = std::chrono::steady_clock::now();
if (next < now) next = now; // drop missed ticks instead of bursting

I’ve watched a loop like this appear to hang and then fire a burst of iterations after a laptop resumed from sleep. That is the catch-up behaviour working as written: the deadline was far in the past, so every sleep_until returned at once. Clamping next as above removes the burst.

Choosing the clock for sleep_until

sleep_until accepts a time_point of any clock, and the choice changes the meaning:

  • steady_clock is monotonic. It never goes backwards and isn’t adjusted by NTP or the user changing the time. Use it for timeouts, retry delays, heartbeats and fixed-rate loops.
  • system_clock is wall-clock time. It can jump forwards or backwards when the system time is corrected. Use it only when the wake-up is genuinely defined in wall-clock terms, such as “at 03:00 local time”, and expect it to move if the clock is changed.
// Timeout: steady_clock
auto deadline = std::chrono::steady_clock::now() + 5s;
std::this_thread::sleep_until(deadline);

A 5-second timeout computed from system_clock can become a 1-hour wait if the clock is set back an hour while the thread sleeps, or return instantly if it’s set forward. The steady_clock post covers the clock differences in more detail.

Polling with a deadline

When you need to wait for a condition that nobody signals, such as a file appearing or a hardware flag changing, polling with a short sleep and an overall deadline is acceptable:

bool waitForCondition(std::chrono::milliseconds timeout) {
    auto deadline = std::chrono::steady_clock::now() + timeout;
    while (std::chrono::steady_clock::now() < deadline) {
        if (checkCondition()) return true;
        std::this_thread::sleep_for(10ms);
    }
    return checkCondition(); // one last check at the deadline
}

The poll interval is a trade-off: shorter means lower latency and more wakeups (more CPU and power), longer means the opposite. Remember the granularity above; on Windows a 1 ms poll behaves like a 15 ms poll anyway. The final checkCondition() after the loop is there because the condition might become true during the last sleep.

Spin, yield or sleep

Three ways to wait for a flag, from most to least CPU-hungry:

while (!ready) { }                                        // spin: burns 100% of a core
while (!ready) { std::this_thread::yield(); }             // still burns a core if nothing else runs
while (!ready) { std::this_thread::sleep_for(1ms); }      // cheap, but adds latency

(ready must be a std::atomic<bool> in all three. A plain bool is a data race, and the optimizer is allowed to hoist the load out of the empty spin loop, turning it into an infinite loop.)

yield() only tells the scheduler “run someone else if they’re waiting”. On an idle machine it returns almost immediately and the loop spins at full speed. Spinning is justified only when the expected wait is shorter than a context switch, which is rare outside low-level synchronization code. For waiting on another thread, the right tool is a blocking primitive, not any of the three.

Waits that can be cancelled

The biggest practical limitation of sleep_for is that nothing can interrupt it. A worker sleeping for 30 seconds between jobs makes shutdown take up to 30 seconds. Wait on a condition variable with a timeout instead; wait_for returns either when notified or when the time runs out.

C++20 makes this neat with std::jthread and std::stop_token. condition_variable_any has an overload that also wakes up when a stop is requested:

#include <chrono>
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <stop_token>
#include <thread>
using namespace std::chrono_literals;

void worker(std::stop_token st) {
    std::mutex m;
    std::condition_variable_any cv;
    int ticks = 0;
    while (!st.stop_requested()) {
        ++ticks;               // do one unit of work here
        std::unique_lock lock(m);
        // Wakes after 1s, or immediately when stop is requested.
        cv.wait_for(lock, st, 1s, [] { return false; });
    }
    std::cout << "stopped after " << ticks << " tick(s)\n";
}

int main() {
    std::jthread t(worker);
    std::this_thread::sleep_for(50ms);
} // ~jthread requests stop and joins; returns in ~50 ms, not ~1 s

This prints stopped after 1 tick(s) and exits right away, whereas the same loop built on sleep_for(1s) would block shutdown for the rest of the second. The predicate normally checks real shared state (“is there work in the queue?”); here it returns false because the only reasons to wake are the timeout and the stop request. See std::jthread and condition_variable for the full picture.

The shutdown hang is the failure I run into most with sleep-based workers: a service that takes exactly as long to stop as its longest sleep, which looks like a deadlock until you notice the timing is always the same. Replacing the sleep with a timed wait fixes it without touching the rest of the loop.

Retry with backoff and a deadline

Retry loops combine the ideas above: a steady_clock deadline for the total budget, and a growing sleep between attempts.

template <class Fn>
bool retry_with_deadline(Fn&& fn,
                         std::chrono::steady_clock::time_point deadline,
                         std::chrono::milliseconds backoff,
                         std::chrono::milliseconds max_backoff) {
    while (true) {
        if (fn()) return true;
        auto now = std::chrono::steady_clock::now();
        if (now >= deadline) return false;
        auto remaining = std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now);
        std::this_thread::sleep_for(std::min(backoff, remaining));
        backoff = std::min(backoff * 2, max_backoff);
    }
}

Capping the sleep at remaining keeps the function from overrunning its deadline by a whole backoff period. For network clients, adding random jitter to backoff prevents many clients that failed together from retrying in lockstep.

Timers in event-driven code

Every sleep_* call blocks a thread. That’s fine for a dedicated worker, but in an event loop or thread pool, a blocked thread is one that can’t run other handlers. Asio’s steady_timer (and system_timer for wall-clock deadlines) schedules a callback on an io_context instead, so thousands of pending timeouts cost no threads at all. The Asio introduction shows the pattern.