DEV Community

Dhana
Dhana

Posted on

Task.Delay vs Thread.Sleep: 100 Waiting Tasks Took 1 Second or 5 Seconds

In my last article, 100 simulated one-second waits finished in about one second. That worked because each wait was Task.Delay, which doesn't hold a thread while it waits. So I ran the obvious follow-up: what if each task waits by blocking a thread instead?

The answer was a result I could predict only in direction, not in size: about five seconds instead of one. This article shows the numbers, explains why the figure came out near five, and covers the places where this mistake hides in real code.

The Experiment
csharp
using System.Diagnostics;

Console.WriteLine($"Logical processors: {Environment.ProcessorCount}");
Console.WriteLine($"Pool threads at start: {ThreadPool.ThreadCount}");

void Report(string label, long ms) =>
Console.WriteLine($"{label,-30} {ms,7} ms pool threads: {ThreadPool.ThreadCount}");

var sw = Stopwatch.StartNew();
await Task.WhenAll(Enumerable.Range(1, 100).Select(_ => Task.Delay(1000)));
Report("100 x Task.Delay(1000)", sw.ElapsedMilliseconds);

sw.Restart();
await Task.WhenAll(Enumerable.Range(1, 100).Select(_ => Task.Run(() => Thread.Sleep(1000))));
Report("100 x Task.Run(Thread.Sleep)", sw.ElapsedMilliseconds);

Both versions start 100 tasks and wait for all of them. The only difference is how each task waits: Task.Delay schedules a timer and frees the thread, while Thread.Sleep inside Task.Run keeps a pool thread occupied for the whole second.

The Results

I ran this three times in a Release build on .NET 8, on a laptop with 20 logical processors. The thread pool started with 0 threads each time.

Run Task.Delay x100 Pool threads Thread.Sleep x100 Pool threads
1 1,063 ms 4 5,093 ms 22
2 1,046 ms 4 5,085 ms 22
3 1,040 ms 3 5,102 ms 22

Blocking took roughly 4.8 to 4.9 times as long, and the pool held 22 threads instead of 3 or 4. The numbers barely moved between runs.

Why It Came Out Near Five Seconds

Here is an explanation that fits the numbers. The thread pool starts with a minimum number of worker threads that, by default, equals the number of processors. On this machine that is 20. Each blocked task holds one thread for a full second, so the first 20 tasks occupy all 20 threads, the next 20 have to wait for a free thread, and so on:

100 tasks ÷ about 20 threads at a time = 5 waves of 1 second, which is about 5 seconds.

The measured 5.09 seconds is very close to that arithmetic, and the 22 threads suggest the pool added a couple beyond its minimum. I should be clear that this is an inference, not something I observed directly. You can check the first half yourself:

csharp
ThreadPool.GetMinThreads(out int minWorker, out int minIo);
Console.WriteLine($"Min worker threads: {minWorker}");

If the explanation is right, this prints 20 on a 20-processor machine.

Why the Delay Version Needed Almost No Threads

Task.Delay doesn't sit on a thread while it waits. The method returns a task right away, and a timer completes it after a second. Nothing is blocked, so 100 of them can wait together with almost no threads, which is why the pool reported only 3 or 4. This is the same property that made the 100 overlapping waits finish in about a second in the last article.

Where This Hides in Real Code

Nobody writes Thread.Sleep(1000) inside Task.Run on purpose in production code. The same blocking happens in quieter forms:

csharp
// Blocks a pool thread for the whole call
var data = GetDataAsync().Result;
GetDataAsync().Wait();
GetDataAsync().GetAwaiter().GetResult();

// Frees the thread while waiting
var data = await GetDataAsync();

An ASP.NET Core server handles requests on pool threads. If many requests block threads while they wait for a database or a web call, the pool can run out of free threads, new requests queue, and the pool adds threads only gradually. This is usually called thread pool starvation. I did not test a web server, so I'm describing the mechanism, not a measurement. The experiment above is the same effect in a small console program.

A Quick Experiment I Haven't Run

If the explanation above is right, raising the minimum thread count should shrink the blocked version's time:

csharp
ThreadPool.GetMinThreads(out _, out int minIo);
ThreadPool.SetMinThreads(100, minIo);
// then run the Thread.Sleep test again

I'd expect about one second, since all 100 tasks could then run at once, but I haven't measured it. I'm showing it to test the explanation, not as a fix. Raising the minimum hides the symptom and costs memory, while await removes the blocking.

What Not to Conclude
The five seconds depends on the machine. By the same arithmetic, a 4-processor machine would take about 25 seconds for the blocked version (100 ÷ 4 × 1 s). I didn't measure that.
This is one laptop, three runs. The results were consistent, but that's still a small sample.
The thread counts are a snapshot taken after each test, not the peak during it.
My runs probably had a debugger attached. Timings from a waiting test are unlikely to change much, but I'd rerun without one before quoting exact figures.
When Blocking Is Fine

Blocking isn't wrong everywhere. In a small console tool or a one-off script, Thread.Sleep or .Result is harmless. The problem is blocking threads on a server that handles many requests at once, where those threads are shared. Task.Run is meant for CPU-heavy work, not for waiting.

A Decision Rule
Situation Use
Waiting on a database, web call, or timer await, and Task.Delay for delays
CPU-heavy calculation Task.Run or parallel code, measured against sequential
A synchronous API you can't change Wrap it carefully, and keep it off request paths
Console script or one-off tool Blocking is acceptable
Check Your Own Code

Search your project for .Result, .Wait(), .GetAwaiter().GetResult() and Thread.Sleep, and focus on anything on a request path. For each hit, ask whether the surrounding method could be async and use await instead.

Takeaway

Waiting with Task.Delay released the thread, so 100 waits finished in about one second using 3 or 4 pool threads. Waiting by blocking held a thread per task, so the same 100 waits took about five seconds on 22 threads. The extra time came from tasks queuing for threads, and on a server that same queuing can slow down every request. Use await for waiting, and keep blocking for places where only one thing is running.

Top comments (0)