From Thread to Task: The Foundations of .NET Concurrency

From Thread to Task: The Foundations of .NET Concurrency

I started working with .NET around the 2.0 days, and I still remember the shape of a typical day: a WinForms screen that froze, a BackgroundWorker dropped onto the designer, and a ThreadPool.QueueUserWorkItem call somewhere in a service that had run untouched for years. This week I took a small program in exactly that style, written for C# 2.0 and compiled against the .NET Framework 2.0 reference assemblies, and then pointed the same source file at .NET 11. It compiled. It ran. It worked. Thread, ThreadPool, FileStream.BeginRead, BackgroundWorker and SynchronizationContext all behaved the way they did in 2005.

Backward compatibility rarely makes it into a keynote. It should, because it is the reason this series on threading and async/await in .NET can put two decades of APIs on one laptop and measure them. This first part covers 2002 to 2011, from the first Thread to the Task Parallel Library (TPL) in .NET 4. Every step solved the problem of its day well, and every step is still underneath the code we write now.

2002: Thread and ThreadPool

The .NET Framework 1.0 shipped in 2002 with System.Threading.Thread, Monitor (the lock statement), Interlocked and the ThreadPool. For developers coming from raw Win32 threads and COM apartments, a managed, garbage-collected and type-safe way to run code concurrently was a real step forward. This is what the first two building blocks looked like in C# 2.0 syntax:

// A dedicated thread: you own its lifetime.
Thread worker = new Thread(new ThreadStart(Compute));
worker.IsBackground = true;
worker.Start();
worker.Join();

// The same idea on the pool: no handle, no result, no exception channel.
ManualResetEvent done = new ManualResetEvent(false);
ThreadPool.QueueUserWorkItem(delegate(object state)
{
    Console.WriteLine("Pool thread: " + Thread.CurrentThread.IsThreadPoolThread);
    done.Set();
}, "work item");
done.WaitOne();

Notice what QueueUserWorkItem gives you. A delegate goes in, nothing comes out. It is fire and forget, with the emphasis on forget. If you want to know when it finished, you bring your own ManualResetEvent. That was deliberate minimalism, and as we will see in the TPL section, it defined exactly what the next generation would add.

What a thread costs

Why a pool at all? Jeffrey Richter explained it in his June 2003 MSDN Magazine column: developers frequently created a thread for a single task and let it die afterwards, and “creating and destroying threads is certainly not free”. Creating a thread allocates a kernel object, allocates and initializes the stack, and notifies every loaded DLL with DLL_THREAD_ATTACH.

Joe Duffy put numbers on it in MSDN Magazine in September 2006: roughly 200,000 cycles to create a Windows thread, about 100,000 to destroy one, and 2,000 to 8,000 cycles per context switch. On Windows, a managed thread of that era typically reserved and committed 1 MB of stack. Modern .NET reserves 1.5 MB on Windows and macOS and 8 MB on Linux, according to the runtime configuration docs. Duffy’s conclusion was the one that shaped a generation of designs. Use the pool of the common language runtime (CLR), because it “hides clever thread injection and retirement algorithms beneath a simple interface, amortizing the cost of creating and destroying threads over the life of your program”.

No Swabian buys a new car for every trip to the bakery, and no sensible program creates a new thread for every small work item. Note the word “amortizing”. We will measure it on today’s runtimes in a moment.

The Begin/End Pattern: One Shape for Asynchronous I/O

Threads are an expensive way to wait. A thread blocked on a socket does nothing useful and still costs its stack. Windows had an answer in I/O completion ports, and .NET Framework 1.0 exposed it through the Asynchronous Programming Model (APM): a BeginXxx method taking an AsyncCallback, returning an IAsyncResult, and an EndXxx method that harvests the result or the exception.

// APM from .NET Framework 1.0 (shown in C# 2.0 syntax)
FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
byte[] buffer = new byte[64];
IAsyncResult ar = fs.BeginRead(buffer, 0, buffer.Length, delegate (IAsyncResult iar)
{
    if (iar.CompletedSynchronously) return; // the caller continues, no stack dive
    Console.WriteLine("Read (async callback): " + fs.EndRead(iar) + " bytes");
}, null);
if (ar.CompletedSynchronously)
{
    Console.WriteLine("Read (completed synchronously): " + fs.EndRead(ar) + " bytes");
}

The CompletedSynchronously check is a small piece of craftsmanship. As Stephen Toub explains in How Async/Await Really Works in C#, a Begin method may invoke the callback synchronously when the operation completes immediately, and without a way to hand that case back to the caller, repeated calls could dive deeper and deeper into the stack. APM chose to let the caller do the continuation work in that case. Anyone who has debugged a stack overflow in callback-heavy code will appreciate that somebody thought about this in 2002.

The pattern also covered the pool itself: every delegate had a BeginInvoke and EndInvoke, which queued the call to the thread pool. That is one of the few pieces of this era that did not travel into modern .NET (Thread.Abort, which comes up below, is another), because delegate BeginInvoke depended on remoting features. On .NET Core and .NET 5+ it throws PlatformNotSupportedException, and the migration guidance is to use Task.Run instead.

Toub is refreshingly fair about APM, and I want to repeat his framing: “this isn’t really a criticism of the APM pattern. Rather, it’s a critique of callback-based asynchrony in general.” In his words, “No other mainstream language had a better alternative available, either.” What APM lacked was help from the language: composing two or three sequential operations meant nesting callbacks by hand, and loops and try/catch did not compose across them.

2005: Events, BackgroundWorker and the SynchronizationContext

.NET Framework 2.0 shipped in 2005 and added a second pattern aimed at client applications: the Event-based Asynchronous Pattern (EAP). One method starts the operation (DownloadStringAsync, RunWorkerAsync), and an event signals completion. The Learn overview of the patterns lists this lineage explicitly: APM in 1.0, EAP in 2.0, and the Task-based Asynchronous Pattern (TAP) in .NET 4.

The star of the EAP era was BackgroundWorker. If you built desktop applications in those years, you probably have a RunWorkerCompleted handler in your history:

BackgroundWorker bw = new BackgroundWorker();
bw.WorkerReportsProgress = true;
bw.DoWork += delegate(object s, DoWorkEventArgs e)
{
    ((BackgroundWorker)s).ReportProgress(50); // raised on the UI thread in a UI app
    e.Result = "finished";
};
bw.RunWorkerCompleted += delegate(object s, RunWorkerCompletedEventArgs e)
{
    Console.WriteLine("Completed: " + e.Result);
};
bw.RunWorkerAsync();

The events fire on the right thread without a single Control.Invoke in your code. That magic came from the more important contribution of 2.0: SynchronizationContext. In .NET 1.0, ISynchronizeInvoke served Windows Forms. As Stephen Cleary describes in his 2011 MSDN Magazine article, it was not a good fit for ASP.NET asynchronous pages, so the designers replaced it with SynchronizationContext, which provides “a way to queue a unit of work to a context” rather than to one specific thread:

// Capture where we came from, do the work elsewhere, post the result back.
SynchronizationContext context = SynchronizationContext.Current ?? new SynchronizationContext();
ThreadPool.QueueUserWorkItem(delegate
{
    string result = "computed on a pool thread";
    context.Post(delegate { Console.WriteLine("Posted back: " + result); }, null);
});

EAP turned out to be a one-hit wonder. In Toub’s words, it “came and went effectively in a single release”. But the hit is still on the radio, because SynchronizationContext is exactly what await captures to this day.

One small detail makes me smile every time. The public surface of BackgroundWorker has not changed since 2005, but its plumbing has been rebuilt underneath it. The .NET Framework reference source starts the work through an APM delegate BeginInvoke. In today’s dotnet/runtime it calls Task.Factory.StartNew. Same facade, new plumbing. Nothing was thrown away. Each layer was absorbed by the next.

The pool learns from production

Two other things happened in this period. First, the pool limits. Duffy explained in 2007 that .NET 2.0 SP1 raised the default maximum from 25 to 250 worker threads per CPU. The reason was not to encourage blocking designs; it was to statistically reduce accidental pool deadlocks, which he called the most frequently reported threading problem from customers. The same post also contains a sentence worth reading twice: “A non-blocking, continuation-passing/event-driven architecture is generally a better approach”. From .NET Framework 4 on, the documented default no longer is a fixed number: it “depends on several factors, such as the size of the virtual address space”. Hold on to that continuation-passing sentence for the closing section.

Second, .NET Framework 3.5 added ReaderWriterLockSlim, which the base class library (BCL) team described as performing 2x to 5x better than the older ReaderWriterLock. Multi-core machines were becoming the norm, and the framework was already adapting its primitives to them.

2010: .NET 4 and the Task Parallel Library

By 2007 the direction was clear. Daan Leijen and Judd Hall opened their October 2007 MSDN Magazine article with the observation that “the speed increases of single processors have slowed down”, so performance had to come from running on multiple processors. The TPL was built by Microsoft Research, the CLR team and the Parallel Computing Platform team, appeared in community technology previews (CTPs) in 2007 and 2008, went through public betas, and shipped with .NET Framework 4 in 2010. The design is documented in the OOPSLA ‘09 paper, and it was shaped in the open: in Beta 1, Future<T> was renamed to Task<TResult> because, in the team’s words, “Many folks, that we spoke with, found the name Task clearer”. The future, it turned out, was called Task. The old name took its time leaving, though. The last Future file names in the dotnet/runtime libraries were only renamed in February 2026.

What did .NET 4 actually bring? Task and Task<TResult>, TaskFactory, ContinueWith, TaskCompletionSource<T>, Parallel.For, Parallel.ForEach and Parallel.Invoke, PLINQ (Parallel LINQ), the System.Collections.Concurrent namespace, CancellationToken, Lazy<T> and SemaphoreSlim. When I explain .NET 4, three pieces do most of the work. This is how they look, compiled against the .NET 4.0 reference assemblies:

// .NET Framework 4: a task, chained continuations, and the bridge from APM.
Task<string> chain = Task.Factory.StartNew(() => 21)
    .ContinueWith(t => t.Result * 2)
    .ContinueWith(t => "The answer is " + t.Result);

Task<int> read = Task<int>.Factory.FromAsync(fs.BeginRead, fs.EndRead, buffer, 0, buffer.Length, null);

// Data parallelism with thread-local state, merged once per worker.
long total = 0;
Parallel.For(0, 1000000,
    () => 0L,                                    // per-thread initial value
    (i, state, local) => local + (i % 7),        // body, no shared writes
    local => Interlocked.Add(ref total, local)); // merge once per thread

Look at what happens to the earlier eras. FromAsync turns every APM pair into a Task, so the 2002 pattern was not abandoned, it was adopted. TaskScheduler.FromCurrentSynchronizationContext brings the 2005 SynchronizationContext into the Task world. And cooperative cancellation through CancellationToken is a thread-free answer to a question that Thread.Abort once answered from the outside. When Thread.Abort became obsolete in .NET 5, the official guidance was to use a CancellationToken, which means a 2009 design decision became the way out of a 2002 API.

The real win was not speed, though. Toub’s phrase for it is that Task became the “lingua franca” for asynchronous operations: everyone who has one can produce a Task, everyone who consumes one can consume it, and nothing custom is needed to connect the two. Compared to QueueUserWorkItem, where a delegate goes in and nothing comes out, a Task gives you completion, exceptions, results and continuations in one object.

A note for precision: Task.Run, Task.Delay, Task.WhenAll and Task.WhenAny arrived in .NET 4.5, not in .NET 4. In .NET 4 the entry point was Task.Factory.StartNew, which Toub later described as highly configurable, with complexity as the flip side. Task.Run was introduced as the shortcut. I verified these boundaries by compiling against the 4.0 reference assemblies, where all four fail to resolve.

A pool that steals work and climbs hills

The TPL would not have been useful without a thread pool that could handle many small work items. Eric Eilebrecht described in 2009 what changed in CLR 4. Before, the pool’s queue was a linked list protected by a Monitor lock, which was fine for coarse items where “each work item represents the generation of an entire web page”. For .NET 4, the global queue became lock-free (it is virtually identical to the new ConcurrentQueue<T>), and each pool thread got a local queue. As the TaskScheduler documentation describes, a thread works its own queue from the newest item to preserve cache locality, while idle threads steal from the other end to reduce contention. Tasks created by pool threads benefit from this; top-level tasks from other threads still go through the global queue.

The second change was how the pool decides how many threads to run. Erika Fuentes described it in MSDN Magazine in September 2010: a hill-climbing controller treats the concurrency level as input and the throughput as output, deliberately varies the input as a wave, and looks for that wave in the output to separate signal from noise. Hellerstein, Morrison and Eilebrecht published the experience in the SIGMETRICS Performance Evaluation Review, noting that the .NET thread pool it was built for was present on approximately one billion computers. And this is no museum piece. PortableThreadPool.HillClimbing.cs still sits in dotnet/runtime, with its wave period and magnitude settings, and still helps decide how many worker threads your ASP.NET Core app runs today.

Even Microsoft’s own numbers from that time show how much headroom remained. Toub wrote about parallelism in .NET 4.5 that a continuation-chain micro-benchmark ran 400% faster just by moving from .NET 4 to 4.5. That is Microsoft’s number on Toub’s machine, and I list it separately from my own measurements below.

Measuring the Foundation

Enough history. What does the amortization Duffy described look like on a current machine? I wrote one small BenchmarkDotNet suite that runs 100 tiny work items four ways and waits for all of them. Each item writes one Math.Sqrt result and increments a counter. The baseline and the Task variant look like this:

[Benchmark(Baseline = true)]
public void DedicatedThreads()
{
    var threads = new Thread[WorkItems];
    for (int i = 0; i < WorkItems; i++)
    {
        int item = i;
        threads[i] = new Thread(() => DoWork(item));
        threads[i].Start();
    }

    foreach (var thread in threads)
    {
        thread.Join();
    }
}

[Benchmark]
public void TaskRun()
{
    var tasks = new Task[WorkItems];
    for (int i = 0; i < WorkItems; i++)
    {
        int item = i;
        tasks[i] = Task.Run(() => DoWork(item));
    }

    Task.WaitAll(tasks);
}

The other two variants use ThreadPool.QueueUserWorkItem with a CountdownEvent, and Parallel.For(0, WorkItems, DoWork). The caveats come first, because they matter. These are micro-benchmarks, run in a single BenchmarkDotNet session on a hybrid P/E-core laptop (Intel Core Ultra 7 155H, Windows 11). I used BenchmarkDotNet 0.15.8 on .NET Framework 4.8 (a net48 target running on the installed 4.8.1 runtime, CLR 4.8.10008), .NET 8.0.34, .NET 10.0.15 and .NET 11.0.0. Because Task.Run is a 4.5 API, the .NET Framework row represents the 4.5-and-later lineage of the TPL, not 4.0 itself. Treat the numbers as a direction, not a verdict.

Mean time for all 100 work items:

Method.NET Framework 4.8.NET 8.NET 10.NET 11
DedicatedThreads25,774 μs19,794 μs22,537 μs19,518 μs
ThreadPoolQueue191.4 μs42.4 μs61.8 μs61.3 μs
TaskRun220.0 μs49.6 μs56.6 μs54.1 μs
ParallelFor13.8 μs16.1 μs20.3 μs23.1 μs

Reading the numbers

The headline is the one Duffy predicted in 2006. Creating, starting and joining a dedicated thread costs about 200 μs here, which I get from the medians (roughly 186 to 266 μs per thread across the four runtimes). That is the same order of magnitude as his cycle counts: a few hundred thousand cycles at a few GHz is on the order of 100 μs. A pooled work item costs about 0.4 to 0.6 μs on .NET 8, 10 and 11, and about 1.9 to 2.2 μs on .NET Framework 4.8. So the pool and tasks make 100 work items roughly 120 to 470 times cheaper than 100 threads. The StdDev for DedicatedThreads (2.7 to 8.1 ms) shows how much the operating system’s thread creation varies from run to run.

The second observation is that every modern runtime runs the same code several times faster than .NET Framework 4.8. The same 100 work items through ThreadPool.QueueUserWorkItem run about 3.1x faster on .NET 10 and 11 than on .NET Framework 4.8, and about 4.5x faster on .NET 8. Task.Run shows 3.9x to 4.4x. Nobody had to change a line of this code.

Not every row moves in the same direction. A benchmark article that only shows the flattering rows is a brochure, and this is not one. Parallel.For is the fastest option on every runtime, but it is fastest of all on .NET Framework 4.8 (13.8 μs) and gets slower with each newer runtime, up to 23.1 μs on .NET 11 (about 1.7x). ThreadPoolQueue is faster on .NET 8 than on .NET 10 and 11, and Task.Run follows the same pattern (49.6 μs on .NET 8, 56.6 and 54.1 μs on .NET 10 and 11), with BenchmarkDotNet flagging its distributions on .NET 10 and 11 as bimodal. I have not profiled any of this. For the Parallel.For row, my guess is that with 100 trivial items the partitioning and the calling thread doing part of the work dominate, so it mostly measures library overhead rather than scheduling, but that is a hypothesis, not a measurement.

The run-to-run spread inside each job is small (a ThreadPoolQueue StdDev under 1 μs on .NET 8, 10 and 11), so I do not think it is plain noise. But each runtime was a separate process in one session on a laptop with performance and efficiency cores, so core placement and power state may differ between jobs, and I would want reruns before crediting the runtime. The allocation column tells a more consistent story: Parallel.For allocates 2.3 to 4.6 KB per run, against 12 to 19 KB for the queue and task variants. (The Allocated column counts managed heap only and says nothing about native thread stacks.)

If your work item takes 50 ms, none of these numbers matter. If it takes a few microseconds, you have just seen why the pool exists.

What Was Still Ours to Write

Look back at the 2007 sentence from Duffy: a “non-blocking, continuation-passing/event-driven architecture is generally a better approach”. He wrote it about workloads that would otherwise park hundreds of blocked threads in the pool, and it reads like a brief for what followed. By .NET 4 we had the right ingredients. Task was the shared currency, ContinueWith expressed “and then”, FromAsync and TaskScheduler.FromCurrentSynchronizationContext connected the older eras, and the pool scaled to fine-grained work.

What we did not have yet was a way to write a sequence of asynchronous steps that reads like a sequence. Even the little chain from the TPL section, which turns 21 into “The answer is 42”, hands t.Result from lambda to lambda. Remember that 42. It will come back. A realistic operation (read, parse, call a service, save, with a try/catch around some of it) turned into a chain of ContinueWith lambdas, where we managed the continuation context, the exception unwrapping and the cancellation by hand. It worked, and many production systems ran on exactly that for years. But it was the same lesson as with APM, one level up: the library gave us a very good building block, and the control flow was still ours to assemble.

Which leaves one question for the next step in this story. What if the compiler wrote the continuations for us? Same Task, same pool, same SynchronizationContext, but with if, foreach and try working across asynchronous calls. The answer is a language feature built on everything above, and it changed how all of us write .NET code.

Comments

VG Wort