Start Debugging

Thread.Sleep vs Task.Delay vs SpinWait for waiting in C#

Use await Task.Delay for any timed wait in async code, Thread.Sleep only on a dedicated thread you own, and SpinWait only for sub-microsecond waits inside lock-free code. To wait for a condition, use a signal (ManualResetEventSlim, SemaphoreSlim, TaskCompletionSource) instead of any of the three. Measured on .NET 10.0.10.

For a timed pause, use await Task.Delay(...) in anything async, because it releases the thread while it waits. Use Thread.Sleep only on a thread you created yourself (a console loop, a dedicated worker), never on a thread pool or UI thread. Use SpinWait only when you expect the wait to end within a few microseconds, typically inside lock-free code. And if you are actually waiting for something to happen rather than for time to pass, none of the three is the right tool: use a signal such as ManualResetEventSlim, SemaphoreSlim, or a TaskCompletionSource, which in my measurements woke up roughly 50 times faster than any polling loop.

Every number in this post was measured on .NET 10.0.10 (SDK 10.0.302), C# 14, on an Apple M4 with 10 cores running macOS 26.6. The APIs themselves have not changed in years: Task.Delay has existed since .NET Framework 4.5, SpinWait since 4.0, and everything here applies equally to .NET 8, 9, and the .NET 11 previews.

The comparison at a glance

Thread.Sleepawait Task.DelaySpinWait
Holds the thread while waitingYes, blockedNo, thread returns to the poolYes, running or yielding
CPU while waiting (500 ms, measured)0 ms0 ms4 ms with SpinUntil, 500 ms with SpinOnce(-1)
Shortest real wait (1 ms requested, macOS)~1.5 ms~1.3 ms~1 us per early spin
Shortest real wait on Windows (default timer)~1-15.6 ms~15.6 ms~1 us per early spin
CancellableNoYes, via CancellationTokenOnly by your own condition
Testable with fake timeNoYes, Task.Delay(TimeSpan, TimeProvider) (.NET 8+)No
Safe on a thread pool threadNoYesOnly for microseconds
Safe on a UI threadNo, freezes the UIYesNo
Typical useDedicated thread loops, quick console toolsRetry backoff, throttling, polling in async codeLock-free retry loops, spin-then-block primitives

What each one actually does to your thread

Thread.Sleep(ms) suspends the calling OS thread. The scheduler takes it off the CPU and does not consider it again until the interval elapses. No CPU is burned, but the thread still exists, still holds its stack, and cannot do anything else. That is fine for a thread whose only job is to wait. It is a problem for a thread that belongs to a shared resource.

Task.Delay(ms) returns a Task that completes when an internal TimerQueueTimer fires. When you await it, your method returns to its caller, the thread goes back to the pool (or back to the UI message loop), and the rest of the method is scheduled as a continuation when the timer fires. While the delay is pending, no thread is associated with your method at all.

SpinWait keeps the thread on the CPU and checks again. It is a struct with one method that matters, SpinOnce(), and its behavior depends on how many times you have called it. Reading the .NET 10 source, the constants are YieldThreshold = 10, Sleep0EveryHowManyYields = 5, and DefaultSleep1Threshold = 20:

So “SpinWait” is less a spin than an escalation ladder. That detail decides most of its trade-offs below.

Measured: how long does “wait 1 ms” really take?

This probe times 200 calls of each and prints the median and the 99th percentile:

// .NET 10.0.10, C# 14, dotnet run probe.cs
using System.Diagnostics;

foreach (var ms in new[] { 0, 1, 10, 15 })
{
    var sleep = new List<double>();
    for (int i = 0; i < 200; i++)
    {
        var t = Stopwatch.GetTimestamp();
        Thread.Sleep(ms);
        sleep.Add(Stopwatch.GetElapsedTime(t).TotalMilliseconds);
    }

    var delay = new List<double>();
    for (int i = 0; i < 200; i++)
    {
        var t = Stopwatch.GetTimestamp();
        await Task.Delay(ms);
        delay.Add(Stopwatch.GetElapsedTime(t).TotalMilliseconds);
    }

    sleep.Sort(); delay.Sort();
    Console.WriteLine($"{ms} ms: Sleep p50={sleep[100]:F3} | Delay p50={delay[100]:F3}");
}
RequestedThread.Sleep p50Thread.Sleep p99Task.Delay p50Task.Delay p99
0 ms0.000 ms0.015 ms0.000 ms0.001 ms
1 ms1.510 ms1.633 ms1.297 ms1.513 ms
10 ms12.012 ms12.033 ms12.043 ms12.083 ms
15 ms16.763 ms17.071 ms17.023 ms17.215 ms

Two things to take from this. First, both are “at least this long”, never “exactly this long”. On macOS the overshoot was a steady 0.3 to 2 ms. Second, Task.Delay(0) completes synchronously: it returns an already-completed task, so await Task.Delay(0) does not yield at all. If you want to yield in async code, await Task.Yield() is the API that does that.

On Windows the gap is much larger. The Task.Delay documentation states that the delay depends on the system clock, whose resolution is about 15 ms on Windows, so Task.Delay(1) there typically takes around 15.6 ms. Thread.Sleep(1) on Windows can be close to 1 ms or close to 15.6 ms depending on whether some process on the machine has raised the global timer resolution. Neither API is a precision timer. If your loop depends on a 1 ms tick, it will run at a fraction of the rate you expect on a default Windows box.

Measured: what Thread.Sleep does to the thread pool

The real reason Thread.Sleep is dangerous is not CPU or precision. It is what it does to a shared pool. This probe queues 200 work items that each wait one second, then measures from a dedicated thread how long a brand-new work item waits before it even starts:

// .NET 10.0.10, C# 14, dotnet run probe3.cs -- sleep|delay
using System.Diagnostics;

string mode = args[0];
var sw = Stopwatch.StartNew();

var tasks = Enumerable.Range(0, 200).Select(_ => Task.Run(async () =>
{
    if (mode == "sleep") Thread.Sleep(1000);
    else await Task.Delay(1000);
})).ToArray();

var sampler = new Thread(() =>
{
    using var started = new ManualResetEventSlim();
    var t = Stopwatch.GetTimestamp();
    ThreadPool.UnsafeQueueUserWorkItem(_ => started.Set(), null);
    started.Wait();
    Console.WriteLine($"new work item waited {Stopwatch.GetElapsedTime(t).TotalMilliseconds:F0} ms");
});
sampler.Start();

Task.WaitAll(tasks);
sampler.Join();
Console.WriteLine($"{mode}: 200 waits done in {sw.ElapsedMilliseconds} ms, pool threads = {ThreadPool.ThreadCount}");
Thread.Sleep(1000)await Task.Delay(1000)
Time for all 200 one-second waits18,034 ms1,005 ms
Pool threads at the end1211
Delay before an unrelated work item started17,030 ms1 ms

With Task.Delay, all 200 waits overlap and the whole batch takes one second on ten threads. With Thread.Sleep, the pool can only run as many sleeps at once as it has threads, so the batch is serialized in groups of roughly ten, and every other piece of work queued behind it, in this case a trivial started.Set(), sat in the queue for 17 seconds. Note that the pool did not rescue the situation by adding threads: work items kept completing every second, so its hill-climbing logic grew it from 10 to only 12 threads.

In an ASP.NET Core app that queued work item is the next HTTP request. This is the classic thread pool starvation pattern, and a Thread.Sleep inside a request handler or a BackgroundService is one of the easiest ways to trigger it. The same thing happens with Task.Delay(1000).Wait(), which is just Thread.Sleep with extra allocations. If that pattern looks familiar, the post on fixing deadlocks from .Result and .Wait() covers the sync-over-async side of it.

Measured: CPU cost and wake-up latency of SpinWait

SpinWait.SpinUntil(condition) sounds like it would burn a core for as long as you wait. Thanks to the escalation ladder, it does not:

Wait strategy, 500 ms of wall timeCPU time used
Thread.Sleep(500)0 ms
await Task.Delay(500)0 ms
SpinWait.SpinUntil(...)4 ms
SpinOnce(sleep1Threshold: -1) loop500 ms (one full core)
empty while loop499 ms

The first 10 pure spins took 9.8 us in total and spins 10 to 19 another 10 us. After that, every SpinOnce() is a Thread.Sleep(1), which I measured at about 1,265 us each. Pass sleep1Threshold: -1 to disable the Sleep(1) stage and you get exactly what the name suggests: a pegged core.

The other half of the trade-off is how fast each strategy notices that the thing it waits for has happened. Here a second thread sets a flag after a random 0 to 3 ms, and the main thread measures the time from the flag being set to noticing it (200 runs each):

// .NET 10.0.10, C# 14
int flag = 0;
using var ev = new ManualResetEventSlim();
// setter thread: busy-waits a random 0-3 ms, then
//   Volatile.Write(ref flag, 1); ev.Set();

SpinWait.SpinUntil(() => Volatile.Read(ref flag) == 1);       // "spinuntil"
while (Volatile.Read(ref flag) == 0) Thread.Sleep(1);         // "sleep1"
while (Volatile.Read(ref flag) == 0) await Task.Delay(1);     // "delay1"
ev.Wait();                                                    // "mres"
Strategyp50 wake-up lagp90 wake-up lag
SpinWait.SpinUntil745 us1,182 us
Thread.Sleep(1) polling710 us1,158 us
await Task.Delay(1) polling836 us1,205 us
ManualResetEventSlim.Wait()15 us37 us

Once the wait lasts longer than about 20 us, SpinUntil is just a Thread.Sleep(1) polling loop with a nicer name, and it wakes up no faster. The event-based wait was 50 times quicker at the median, and it used no CPU while waiting. ManualResetEventSlim and SemaphoreSlim already spin briefly before they block, so they give you the cheap fast path of SpinWait and the efficient slow path of a kernel wait in one call.

When to pick await Task.Delay

// .NET 10, C# 14
async Task<HttpResponseMessage> GetWithRetryAsync(
    HttpClient http, string url, TimeProvider time, CancellationToken ct)
{
    for (int attempt = 0; ; attempt++)
    {
        var response = await http.GetAsync(url, ct);
        if (response.StatusCode != System.Net.HttpStatusCode.TooManyRequests || attempt == 4)
            return response;

        response.Dispose();
        var backoff = TimeSpan.FromMilliseconds(200 * Math.Pow(2, attempt));
        await Task.Delay(backoff, time, ct); // cancellable, testable, holds no thread
    }
}

When to pick Thread.Sleep

Even on a dedicated thread, consider WaitHandle.WaitOne(timeout) or CancellationToken.WaitHandle.WaitOne(timeout) instead. It waits for the same interval but returns early when someone asks the thread to stop, which Thread.Sleep cannot do:

// .NET 10, C# 14: a dedicated worker thread that can be stopped promptly
void PollLoop(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)
    {
        PollDevice();
        ct.WaitHandle.WaitOne(TimeSpan.FromSeconds(1)); // returns immediately on Cancel()
    }
}

When to pick SpinWait

// .NET 10, C# 14: spin briefly, then block
var spinner = new SpinWait();
while (!IsReady())
{
    if (spinner.NextSpinWillYield)
    {
        readyEvent.Wait(); // stop spinning, let the OS park the thread
        break;
    }
    spinner.SpinOnce();
}

Do not use SpinWait for waits measured in milliseconds, and do not use Thread.SpinWait(n) directly with a large n. The SpinWait documentation positions it as a low-level building block for synchronization code, and spinning only pays off when the expected wait is shorter than the cost of a context switch. If you are not writing a lock-free data structure or a synchronization primitive, you probably do not need it. The post on lock vs Monitor vs SemaphoreSlim vs System.Threading.Lock covers the higher-level primitives that already do this spinning for you.

The gotcha that picks for you: are you waiting for time or for an event?

Most Thread.Sleep and Task.Delay calls I see in code reviews are not really timed waits. They are polling loops: “sleep 100 ms, check if the job finished, repeat”. That pattern always loses on both axes. It adds up to one interval of latency after the event, and it wakes up for nothing in the meantime.

If another part of your program causes the event, signal it directly:

// .NET 10, C# 14: replace "while (!done) await Task.Delay(100);"
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);

_ = Task.Run(async () =>
{
    await DoWorkAsync();
    completion.TrySetResult();
});

await completion.Task.WaitAsync(TimeSpan.FromSeconds(30)); // a real timeout, no polling

RunContinuationsAsynchronously keeps the code that awaits completion.Task from running inline on the thread that calls TrySetResult. WaitAsync(TimeSpan) (.NET 6+) gives you the timeout without a hand-rolled Task.WhenAny(task, Task.Delay(...)). For producer/consumer handoffs use a Channel<T>, and for “at most N at once” use SemaphoreSlim.WaitAsync. Only poll when the event happens outside your process and nothing can notify you, such as a remote job status endpoint. Then await Task.Delay with a CancellationToken is the right poll interval.

Recommendation

Default to await Task.Delay(interval, cancellationToken). It holds no thread, uses no CPU, can be cancelled, and with a TimeProvider it can be tested without real waiting. In the measurement above it finished 200 one-second waits in 1 second, where Thread.Sleep took 18 seconds and stalled unrelated work for 17 of them.

Use Thread.Sleep only on a thread that nothing else shares, and prefer a cancellable WaitHandle.WaitOne(timeout) even there. Use SpinWait only for waits shorter than a context switch inside lock-free or synchronization code; for anything longer it degrades into a Sleep(1) loop anyway. And before you choose any of them, ask whether you are waiting for time or for something to happen. If it is the latter, a TaskCompletionSource, ManualResetEventSlim, SemaphoreSlim, or Channel<T> will wake you up about 50 times sooner.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back