DEV Community

Morgan Ma
Morgan Ma

Posted on

The Wait Returned in Zero Milliseconds

I lost events because a timeout rounded to zero. duration_cast truncated a leftover under one millisecond. wait_for stopped sleeping and started spinning. The queue looked idle. The core ran hot.

That is the whole failure. The rest is the autopsy. Want the short version first? Never convert a remaining budget with truncating casts.

The symptom I actually saw

Unit tests looked boring and green. A one-hour soak did not. One consumer core stuck at 100 percent. Logs screamed timeout thousands of times per second. Queue depth stayed near one. Packets were fresh. So why spin?

I did not start with chrono lore. I started with a lie I liked. I blamed the producer. Then I blamed notify_one. Both leads were wrong.

False leads I burned first

Was the producer stalled on a disk flush? No. The push log kept moving. Was the mutex convoy hiding a waiter? Unlikely here. Did I miss a wakeup? The traces said the waiter was awake. Too awake.

I needed numbers, not folklore. I needed the wait duration itself. Not the budget I intended.

The helper that looked adult

I reconstructed the consumer as a small program. The production code was larger. The bug fit in one function. Here is the shape I kept.

#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <iostream>
#include <mutex>
#include <queue>
#include <thread>

using namespace std::chrono;

struct SliceQueue {
    std::mutex mu;
    std::condition_variable cv;
    std::queue<int> q;
    bool stop = false;
};

// Truncates toward zero. Sub-millisecond remainders become 0ms.
milliseconds remaining_ms(steady_clock::time_point deadline) {
    auto leftover = deadline - steady_clock::now();
    return duration_cast<milliseconds>(leftover);
}

bool wait_for_item(SliceQueue& s, steady_clock::time_point deadline, int& out) {
    std::unique_lock<std::mutex> lock(s.mu);
    for (;;) {
        if (!s.q.empty()) {
            out = s.q.front();
            s.q.pop();
            return true;
        }
        if (s.stop) {
            return false;
        }
        auto d = remaining_ms(deadline);
        if (d.count() <= 0) {
            return false;  // "timed out"
        }
        s.cv.wait_for(lock, d);
        // loop and re-check the predicate
    }
}
Enter fullscreen mode Exit fullscreen mode

Looks tidy, right? Deadline in. Milliseconds out. Tests used 50ms slices. They never left 800 microseconds on the clock. Production used 200us slices. Different planet.

A repro you can compile

Label this as a reconstructed example. I am not citing a vendor incident. Build it locally. Watch the spin with your own eyes.

int main() {
    SliceQueue s;
    auto deadline = steady_clock::now() + microseconds{800};

    std::thread producer([&] {
        std::this_thread::sleep_for(microseconds{400});
        std::lock_guard<std::mutex> lock(s.mu);
        s.q.push(42);
        s.cv.notify_one();
    });

    int item = -1;
    auto t0 = steady_clock::now();
    bool ok = wait_for_item(s, deadline, item);
    auto spun = steady_clock::now() - t0;

    producer.join();

    std::cout << "ok=" << ok
              << " item=" << item
              << " spun_us="
              << duration_cast<microseconds>(spun).count()
              << "\n";
}
Enter fullscreen mode Exit fullscreen mode

Suggested commands:

g++ -std=c++17 -O2 -Wall -Wextra -o wait_zero wait_zero.cpp
./wait_zero

# then the interesting one
g++ -std=c++17 -O0 -g -fsanitize=thread -o wait_zero_tsan wait_zero.cpp
./wait_zero_tsan
Enter fullscreen mode Exit fullscreen mode

What should you see? ok=0 while the producer still pushed. Or ok=1 after a tight spin. Either way the wait never slept a full slice. The leftover was smaller than one millisecond. The cast ate it.

Debug steps I would repeat

Do not start inside the condition variable. Start at the duration you pass it.

  1. Log leftover in nanoseconds before any cast. Print d.count() after the cast. If those disagree by a full unit, you found the knife.
  2. Pin the clock. Use steady_clock for intervals. Do not mix in system_clock for remaining time. NTP steps will gaslight you.
  3. Print deadline - now on every loop entry. A shrinking leftover that jumps to 0 is the smoking gun.
  4. Sample CPU with top or perf stat. A waiter at 100 percent is not waiting. It is polling.
  5. Compile a soak with -O0 first. Then -O2. Truncation is not an optimizer bug. Still check both.
  6. Add a counter of immediate timeouts. If it explodes, your slice is smaller than your unit.

I asked myself one rude question. Did any test use a budget under 1ms? No. That is why review missed it.

Root cause, without poetry

duration_cast<milliseconds> truncates toward zero. It does not round. It does not ceil. 800us becomes 0ms. 1999us becomes 1ms. Your 200us deadline becomes a no-op wait.

wait_for(lock, 0ms) does not sleep. It is a try-wait. You re-check the queue. You cast again. You still have a leftover under 1ms. You spin until the deadline check returns false. You drop the item path even if a push is in flight.

Is the condition variable broken? No. Your unit is coarser than your budget. The API did what you typed.

A second cut hides nearby. People write if (d.count() <= 0) return false before waiting. That treats "almost now" as a hard timeout. A producer 100us away loses. You wanted a wait until the deadline. You coded a wait in whole milliseconds.

The fix I actually want

Stop converting remaining time into a fatter unit. Wait until the time point. Let the library do the math.

bool wait_for_item_until(SliceQueue& s,
                         steady_clock::time_point deadline,
                         int& out) {
    std::unique_lock<std::mutex> lock(s.mu);
    while (s.q.empty() && !s.stop) {
        if (s.cv.wait_until(lock, deadline) == std::cv_status::timeout) {
            break;
        }
    }
    if (!s.q.empty()) {
        out = s.q.front();
        s.q.pop();
        return true;
    }
    return false;
}
Enter fullscreen mode Exit fullscreen mode

Need a millisecond log line anyway? Ceil, do not truncate. And keep the wait on the time point.

#include <cmath>

milliseconds ceil_ms(nanoseconds ns) {
    if (ns <= nanoseconds{0}) {
        return milliseconds{0};
    }
    auto ms = duration_cast<milliseconds>(ns);
    if (ms < ns) {
        return ms + milliseconds{1};
    }
    return ms;
}
Enter fullscreen mode Exit fullscreen mode

I still prefer wait_until. Logging can ceil. Control flow should not. One more test belongs in the suite.

// Labeled test sketch. Run it under your harness.
void test_submillisecond_deadline_still_wakes() {
    SliceQueue s;
    auto deadline = steady_clock::now() + microseconds{800};
    std::thread p([&] {
        std::this_thread::sleep_for(microseconds{200});
        std::lock_guard<std::mutex> lock(s.mu);
        s.q.push(7);
        s.cv.notify_one();
    });
    int item = -1;
    bool ok = wait_for_item_until(s, deadline, item);
    p.join();
    // ok should be true when the push beats the deadline
}
Enter fullscreen mode Exit fullscreen mode

If that test never existed, your green build was a costume.

Where a model shoved me into the hole

I asked a coding assistant to "normalize the timeout to milliseconds." It suggested duration_cast. It even added a comment about clean logs. It did not ask the slice size. It did not mention truncation. It did not add a sub-millisecond test. Why would it? I never said 200us.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access as a second reader on that helper, and the free server option to compile the same repro files. The model restated the cast. The compiler and the soak told the truth. I kept the human checklist above. The tool did not get a veto.

Ask the model a better question. "What happens when leftover is 800us?" Watch it hesitate. Then write the test yourself.

Limitations, said plainly

wait_until is not a shutdown story by itself. If you must poll a stop flag on a coarse interval, cap the wait. Use min(deadline, now + poll_slice). Still avoid truncating that slice to a bigger unit.

Spurious wakeups still happen. The while predicate stays mandatory. A correct clock does not replace that loop. Sanitizers will not flag truncation. This is value error, not a data race. TSAN can stay quiet while you burn a core.

Do not treat this writeup as a benchmark. I am not publishing rates, hardware, or quotas. Clocks differ across machines. Your 800us case may land differently under load. Measure leftover on the box you ship.

C++20 duration_cast still truncates. std::chrono::ceil exists. Use it for display. Prefer time points for waiting.

Who should not copy this path

Skip this if you do not own the wait loop. Fixing a framework timer from the outside will lie. Skip this if your budgets are already in whole seconds. The bug will not show. Skip this if you cannot compile a repro. Pasting the helper into chat is not proof.

Skip this if you need a hard real-time scheduler. User-space condition_variable is not that. Skip this if you refuse to log durations as integers. Feelings about "about 1ms" hid the zero.

What I keep on a sticky note

Casts drop information. Timeouts live in that dropped part. Tests that only use fat budgets will bless the drop. A waiter at 100 percent is a duration bug until proven otherwise.

Would I still log milliseconds? Yes, for humans. Would I wait in milliseconds? Not when the slice is smaller. I wait until the deadline. I test the awkward leftover. I do not let a tidy helper eat the last 800 microseconds.

If you rebuild the repro, keep the compiler as the referee. A second reader on the free model path is optional. The count() print is not.

Top comments (0)