Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C# & .NET (C# 14, .NET 10 LTS)  Module 16 – Threads, parallelism and synchronisation

Threads, the thread pool and async-local state

Start C# threads and thread-pool work, choose between Thread, the pool and Task.Run, and keep per-request state with ThreadLocal and AsyncLocal.

  • Intermediate
  • 15 minutes
  • Examples run with .NET 10.0.401
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Predict when a process with foreground and background threads ends, and what happens to its background threads
  • Explain how the thread pool reuses its threads, and why work that blocks holds them back
  • Choose between new Thread, ThreadPool.QueueUserWorkItem, Task.Run and LongRunning for a given job
  • Store per-thread state in ThreadLocal<T> and per-request state in AsyncLocal<T>, and predict what each holds after an await

Before you start

What you’ll learn · Before you start

What you will learn

  • Predict when a process with foreground and background threads ends, and what happens to its background threads
  • Explain how the thread pool reuses its threads, and why work that blocks holds them back
  • Choose between new Thread, ThreadPool.QueueUserWorkItem, Task.Run and LongRunning for a given job
  • Store per-thread state in ThreadLocal<T> and per-request state in AsyncLocal<T>, and predict what each holds after an await

Before you start

On this page

Starting 1,000 threads for 1,000 small jobs took 984.5 ms on the computer these examples ran on; the thread pool ran the same jobs in 6.7 ms, on 11 threads it already had. A thread is the most basic way to run code at the same time as other code, and the most expensive one.

You will see what keeps a process alive, how the pool shares its threads, how to choose between new Thread, the pool and Task.Run, and how to keep state that belongs to one thread, or to one request as it moves between threads.

A process lives as long as its foreground threads

new Thread(work) makes a thread that runs work beside the code that started it: Start begins it, and Join waits for it to end. Each thread has its own call stack, and the operating system decides when each one runs. One property decides whether a thread holds the program open. A foreground thread: A thread that keeps its process running: a .NET process ends only when its last foreground thread has ended. The main thread is one, and so is every thread made with new Thread until its IsBackground property is set to true. keeps the process running until it ends, and a background thread: A thread that does not keep its process running. When the last foreground thread ends, the runtime stops every background thread where it is, without running its finally blocks. Thread-pool threads are background threads. does not. exit.cs starts one of each and returns from Main at once:

exit.cs: a background and a foreground thread outlive Main C# · exit/exit.cs
// A process ends when its last foreground thread ends. Background threads are stopped where they are.
using var started = new ManualResetEventSlim();

var background = new Thread(() =>
{
    try
    {
        Console.WriteLine("background: started, waiting for work that never comes");
        started.Set();
        Thread.Sleep(Timeout.Infinite);
    }
    finally
    {
        Console.WriteLine("background: finally block"); // never printed: no exception is thrown in the thread
    }
})
{
    IsBackground = true,
};

var foreground = new Thread(() =>
{
    Thread.Sleep(200); // 200 ms of work
    Console.WriteLine("foreground: done; it was the last foreground thread");
});

background.Start();
started.Wait();
foreground.Start();
Console.WriteLine($"main: returns now (background.IsBackground {background.IsBackground}, foreground.IsBackground {foreground.IsBackground})");

Output

background: started, waiting for work that never comes
main: returns now (background.IsBackground True, foreground.IsBackground False)
foreground: done; it was the last foreground thread

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run exit.cs

Main returned first, yet the process printed one more line: it waited for its last foreground thread. Then the runtime stopped the background thread where it was, inside its endless sleep, and its finally block never printed. Microsoft’s guide to foreground and background threads gives the reason: no exception is thrown in a background thread that the runtime stops at shutdown. Step through the run:

Interactive · message by message Who keeps the process of exit.cs alive Step through the run. Main is on the left, the background thread in the middle, the foreground thread on the right.
Who keeps the process of exit.cs alive, step 5 of 5. Messages so far: Main thread to Background thread: Start(); Main thread to Foreground thread: Start().Main threadreturnedBackground threadstopped, no finallyForeground threadfinishedStart()Start()

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The background thread starts. Main starts the background thread and waits for its signal. The thread prints its first line, then sleeps with no end, inside a try block that has a finally.
  2. The foreground thread starts. Main starts the second thread. Its IsBackground is false, the default for new Thread, and it begins 200 ms of work.
  3. Main returns. Main prints its line and returns. The process keeps running, because one foreground thread has not finished.
  4. The last foreground thread ends. After 200 ms the foreground thread prints its line and ends. No foreground thread is left in the process.
  5. The runtime stops the rest. The runtime stops the background thread where it is, and the process exits with status 0. No exception is thrown in that thread, so its finally block never runs.

A thread made with new Thread is a foreground thread unless you set IsBackground = true. The main thread is one too. Thread-pool threads, the subject of the next section, are always background threads.

A background thread for work that must finish

A background thread that writes a log or a file can be cut off between two writes when the program exits, and no catch or finally gets the chance to finish the job. Ask such a thread to stop, with a cancellation token, and Join it before Main returns.

Checkpoint

Check: Foreground and background threads

  1. Question 1 of 2 In exit.cs, Main returns while both of its threads still run. What ends the process, and when?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The foreground thread ending, 200 ms later; the runtime then stops the background thread

    Main is a foreground thread too, and the process waits for every foreground thread. When the last one ended, the runtime stopped the background thread where it was.

  2. Question 2 of 2 When the last foreground thread ends, what happens to a background thread that is inside a try block with a finally?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It is stopped where it is, and its finally block does not run

    exit.cs never printed "background: finally block". Work that must finish, such as a write to a file, belongs on a foreground thread or must be stopped and joined before Main returns.

The thread pool keeps a few threads and gives them job after job

Creating a thread costs a call stack and a request to the operating system. The thread pool: The set of worker threads that .NET keeps and reuses for short pieces of work: the work given to Task.Run, timer callbacks and the continuations of awaited tasks. More in “async and await from first principles” pays that cost once: it keeps a set of background threads, and ThreadPool.QueueUserWorkItem, or Task.Run, which uses the same pool, puts a job in its queue for the next free thread. pool.cs runs the same 1,000 jobs of about a microsecond each on 1,000 new threads, then on the pool:

pool.cs: 1,000 jobs on new threads and on the pool C# · pool/pool.cs
using System.Collections.Concurrent;
using System.Diagnostics;

// The same 1,000 small jobs, once on 1,000 new threads and once on the thread pool.
const int Jobs = 1000;
Console.WriteLine($"processors: {Environment.ProcessorCount}");
ThreadPool.GetMinThreads(out int minWorkers, out int minIo);
Console.WriteLine($"pool minimum: {minWorkers} worker threads, {minIo} I/O threads");

Work(); // run the job once first, so neither measurement pays for compiling it
var watch = Stopwatch.StartNew();
var threads = new Thread[Jobs];
for (int i = 0; i < Jobs; i++)
{
    threads[i] = new Thread(Work);
    threads[i].Start();
}

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

double threadMs = watch.Elapsed.TotalMilliseconds;

var poolThreads = new ConcurrentDictionary<int, bool>();
using var finished = new CountdownEvent(Jobs);
watch.Restart();
for (int i = 0; i < Jobs; i++)
{
    ThreadPool.QueueUserWorkItem(_ =>
    {
        Work();
        poolThreads[Environment.CurrentManagedThreadId] = true;
        finished.Signal();
    });
}

finished.Wait();
double poolMs = watch.Elapsed.TotalMilliseconds;

Console.WriteLine($"1000 new threads:     {threadMs:F1} ms");
Console.WriteLine($"1000 pool work items: {poolMs:F1} ms, run by {poolThreads.Count} pool threads");
Console.WriteLine($"pool threads now: {ThreadPool.ThreadCount}");

// A small job: about a microsecond of arithmetic.
static void Work()
{
    long sum = 0;
    for (int i = 1; i <= 1000; i++)
    {
        sum += i;
    }

    if (sum != 500500)
    {
        throw new InvalidOperationException("wrong sum");
    }
}

Output

processors: 18
pool minimum: 18 worker threads, 1 I/O threads
1000 new threads:     984.5 ms
1000 pool work items: 6.7 ms, run by 11 pool threads
pool threads now: 12

This output changes from run to run: the times and the number of pool threads change from run to run, with what else the computer is doing

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run pool.cs

The 1,000 threads took 984.5 ms. The pool finished in 6.7 ms with 11 threads, each of which ran job after job. Step through how a few threads empty a long queue:

Interactive · step by step How the thread pool runs 1,000 small jobs Step through it. Your code and the queue are on the left, the pool's threads on the right.
How the thread pool runs 1,000 small jobs, step 6 of 6. Showing Your code, Work-item queue (jobs waiting), Pool thread 1 (job 3, same thread), Pool thread 2 (running job 2), Added thread (added for throughput). In focus: Added thread, Work-item queue.enqueueYour codeQueueUserWorkItemWork-item queuejobs waitingPool thread 1job 3, same threadPool thread 2running job 2Added threadadded for throughput

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Jobs go into one queue. QueueUserWorkItem puts each job in the pool's queue and returns at once. There is one thread pool per process, shared by all of its code.
  2. A free thread takes a job. A pool thread with nothing to do takes the job at the front of the queue and runs it. Pool threads are background threads.
  3. Another takes the next. A second pool thread takes the next job, so two jobs run at the same time on two processor cores.
  4. A finished thread is reused. Thread 1 finishes job 1 and goes back to the queue for job 3. No thread is created or destroyed between jobs, which is where the pool saves its time.
  5. Short jobs need few threads. Jobs of a microsecond each finish faster than new ones are needed, so a handful of threads empties the queue. pool.cs counted how many did.
  6. Blocked jobs hold their threads. Jobs that block keep their threads busy. The pool creates threads on demand up to its minimum, 18 workers here; past it, it adds or holds back threads to keep throughput up, and queued jobs wait.

The pool decides how many threads to keep. Up to its minimum, 18 worker threads on this 18-processor computer, as ThreadPool.GetMinThreads reported, it creates a thread as soon as work is waiting. Past the minimum, Microsoft’s description of the pool says it can create threads or wait for work to finish, to get the most work done per second. Short jobs need few threads. Jobs that block are the problem: a blocked pool thread is one thread fewer for everyone else, and queued work waits while the pool grows slowly past its minimum. Async pitfalls and thread-pool starvation measures that failure; here, keep the rule that pool threads are for work that runs, not for work that waits.

Key idea

The pool turns thread creation into thread reuse: a handful of background threads run thousands of short jobs, as long as no job holds its thread for long.

Checkpoint

Check: How the thread pool reuses its threads

  1. Question 1 of 2 pool.cs queued 1,000 small jobs to the thread pool. What did its run show?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: They ran on a handful of reused pool threads, more than 100 times faster than 1,000 new threads

    Creating a thread costs far more than running a microsecond of work. The pool keeps its threads and hands each one the next job from its queue.

  2. Question 2 of 2 Which statement about thread-pool threads is true?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: They are background threads, and a process has one pool

    choose.cs printed "background: True" for both pool threads it used.

Choose by how long the work blocks and what it must return

Four lines start work on another thread, and choose.cs prints which kind of thread each one got:

choose.cs: four ways to start work, and the thread each gets C# · choose/choose.cs
// Four ways to run code on another thread, and the thread each one gets.
var thread = new Thread(() => Report("new Thread"));
thread.Start();
thread.Join();

using var queued = new ManualResetEventSlim();
ThreadPool.QueueUserWorkItem(_ =>
{
    Report("QueueUserWorkItem");
    queued.Set();
});
queued.Wait();

int total = await Task.Run(() =>
{
    Report("Task.Run");
    return SumTo(1000);
});

await Task.Factory.StartNew(() => Report("LongRunning"), TaskCreationOptions.LongRunning);

Console.WriteLine($"Task.Run returned {total} to the code that awaited it");

static void Report(string how)
{
    Thread current = Thread.CurrentThread;
    Console.WriteLine($"{how,-18} pool thread: {current.IsThreadPoolThread,-5}  background: {current.IsBackground}");
}

static int SumTo(int n) => n * (n + 1) / 2;

Output

new Thread         pool thread: False  background: False
QueueUserWorkItem  pool thread: True   background: True
Task.Run           pool thread: True   background: True
LongRunning        pool thread: False  background: True
Task.Run returned 500500 to the code that awaited it

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run choose.cs

new Thread gave a foreground thread outside the pool, and QueueUserWorkItem and Task.Run both ran on pool threads. Task.Factory.StartNew with TaskCreationOptions.LongRunning got a background thread outside the pool: the option tells the scheduler that the task may need a thread of its own, so that it does not hold back the other work in the pool’s queue. Only the two tasks give something back. Task.Run handed its result, 500500, to the code that awaited it.

A task carries an exception back too, and a thread cannot:

crash.cs: the same exception on a task and on a thread C# · crash/crash.cs
// The same exception, thrown by work on a task and by work on a thread of its own.
try
{
    await Task.Run(() => ImportRow(17));
}
catch (FormatException e)
{
    Console.WriteLine($"Task.Run: the task kept the exception, and the await rethrew it: {e.Message}");
}

var worker = new Thread(() => ImportRow(42));
worker.Start();
worker.Join();
Console.WriteLine("never printed: the unhandled exception on the thread ended the process");

static void ImportRow(int row) => throw new FormatException($"row {row} has no amount");

Output (exit status 134)

Task.Run: the task kept the exception, and the await rethrew it: row 17 has no amount

Printed as an error (standard error)

Unhandled exception. System.FormatException: row 42 has no amount
   at Program.<<Main>$>g__ImportRow|0_2(Int32 row) in ./crash.cs:line 16
   at Program.<>c.<<Main>$>b__0_1() in ./crash.cs:line 11
   at System.Threading.Thread.StartCallback()

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run crash.cs

The task stored the FormatException, and the await rethrew it where a catch could handle it. The same exception on a plain thread had no task to go to: the process ended with exit status 134, before the line after Join could print. Microsoft’s guide to exceptions in managed threads states the rule: most unhandled exceptions, in any thread, end the application. Answer three questions about a job of your own to see which way fits it:

Interactive · answer and compare Where should this work run? Answer the three questions about your work. Each answer rules out the ways that do not fit, and says why.
What does the work do?
Does the caller need the work's result or its exception?
Does the work need a foreground thread, a priority or a single-threaded apartment?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five ways to run work off the caller's path, against cost, results, blocking and thread settings
Way to run it Cost to start Result and errors Work that blocks for long Foreground, priority, STA
new Thread Runs on: a thread of its own Poor 1,000 new threads took 984.5 ms in pool.cs Poor no result, and an unhandled exception ended the process (crash.cs) Good blocking it stops no other work Good IsBackground, Priority and the apartment are yours to set
ThreadPool.QueueUserWorkItem Runs on: a reused pool thread Good 1,000 jobs ran in 6.7 ms on 11 reused threads Poor no task to await; you signal the end yourself Poor a blocked pool thread is one thread fewer for every other job Poor always background, normal priority, multithreaded apartment
Task.Run Runs on: a reused pool thread Good the same reused pool threads Good the task keeps the result and the exception for the await Poor the same shared pool as QueueUserWorkItem Poor a pool thread with the pool's settings
StartNew with LongRunning Runs on: a thread of its own Mixed a new thread for each task Good a Task, like Task.Run Good a thread outside the pool, as choose.cs printed Mixed a background thread; no priority or apartment to choose
await an async I/O method Runs on: no thread while it waits Good no thread is held while the I/O is in flight Good the task carries the result and the exception Mixed only for waits that have an async API; CPU work still needs a thread Poor the rest of the method runs wherever the await resumes it
How the answers decide
  • “What does the work do?” Computes for milliseconds: rules out new Thread and StartNew with LongRunning. A thread of its own costs far more than short work: 1,000 threads took 984.5 ms, the same jobs on the pool 6.7 ms.
  • “What does the work do?” Computes or blocks for seconds, or for ever: rules out ThreadPool.QueueUserWorkItem and Task.Run. A pool thread that blocks for seconds is lost to every other job, and past its minimum the pool adds threads only as throughput needs them.
  • “What does the work do?” Waits for I/O that has an async API: rules out new Thread, ThreadPool.QueueUserWorkItem and StartNew with LongRunning and adds a caveat to Task.Run. An async I/O method holds no thread while it waits; putting the wait on a thread only blocks that thread.
  • “Does the caller need the work's result or its exception?” Yes: rules out new Thread and ThreadPool.QueueUserWorkItem. Only a Task carries the result and the exception back to the code that awaits it.
  • “Does the work need a foreground thread, a priority or a single-threaded apartment?” Yes: rules out ThreadPool.QueueUserWorkItem, Task.Run, StartNew with LongRunning and await an async I/O method. Pool threads and LongRunning threads are background threads with the default settings; only new Thread lets you choose.

In short: await an async method when the work waits for I/O; use Task.Run for short CPU work whose result you need; give work that blocks for long, or runs for the life of the program, a thread of its own with new Thread or LongRunning. Only new Thread gives you a foreground thread, a priority or a single-threaded apartment, the cases Microsoft lists for creating your own threads.

Checkpoint

Check: Choosing Thread, the pool, Task.Run or LongRunning

  1. Question 1 of 2 A request handler waits 2 s for a database query whose driver has an async method. How should it wait?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Await the async method: no thread is held while the query runs

    Threads are for code that runs. A wait for a database, a file or the network belongs in an await.

  2. Question 2 of 2 In crash.cs, the delegate of a new Thread throws a FormatException that nothing catches. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The process ends: an unhandled exception on any thread ends it

    Task.Run kept its exception for the await; the thread had nowhere to put it. Catch inside a thread's delegate, or run the work as a task.

ThreadLocal gives every thread its own value

Some state belongs to one thread: a buffer it reuses, a count it adds to without a lock, a random-number generator. A static field marked [ThreadStatic] holds one value per thread, and ThreadLocal<T> does the same with a factory that gives each thread its first value. locals.cs reads both on the main thread, then lets three threads add 1,000 to each:

locals.cs: [ThreadStatic] and ThreadLocal<int> on four threads C# · locals/locals.cs
// Per-thread state: a [ThreadStatic] field and a ThreadLocal<int>, both read first on the main thread.
Console.WriteLine($"main thread: [ThreadStatic] starts at {Counters.PerThread}, ThreadLocal starts at {Counters.Local.Value}");

var lines = new string[4];
var workers = new Thread[3];
for (int t = 1; t <= 3; t++)
{
    int id = t;
    workers[t - 1] = new Thread(() =>
    {
        int startStatic = Counters.PerThread;
        int startLocal = Counters.Local.Value;
        for (int i = 0; i < 1000; i++)
        {
            Counters.PerThread++;
            Counters.Local.Value++;
        }

        lines[id] = $"thread {id}: [ThreadStatic] {startStatic} -> {Counters.PerThread}, ThreadLocal {startLocal} -> {Counters.Local.Value}";
    });
    workers[t - 1].Start();
}

foreach (Thread worker in workers)
{
    worker.Join();
}

foreach (string line in lines[1..])
{
    Console.WriteLine(line);
}

int[] values = [.. Counters.Local.Values.Order()];
Console.WriteLine($"ThreadLocal values of all four threads: {string.Join(", ", values)} (sum {values.Sum()})");

static class Counters
{
    // One field per thread, but this initializer runs once, on the thread that first touches the class.
    [ThreadStatic]
    public static int PerThread = 100;

    // The factory runs once on every thread that reads Value; trackAllValues keeps every thread's value.
    public static readonly ThreadLocal<int> Local = new(() => 100, trackAllValues: true);
}

Output

main thread: [ThreadStatic] starts at 100, ThreadLocal starts at 100
thread 1: [ThreadStatic] 0 -> 1000, ThreadLocal 100 -> 1100
thread 2: [ThreadStatic] 0 -> 1000, ThreadLocal 100 -> 1100
thread 3: [ThreadStatic] 0 -> 1000, ThreadLocal 100 -> 1100
ThreadLocal values of all four threads: 100, 1100, 1100, 1100 (sum 3400)

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run locals.cs

Every thread counted to 1,000 on its own, with no lock and no lost update, because no other thread saw its value. The first values differ. The [ThreadStatic] field began at 100 on the main thread and at 0 on the three workers: its initializer ran once, in the class’s static constructor, on the thread that touched the class first, which Microsoft’s reference for ThreadStaticAttribute warns about. ThreadLocal’s factory ran once on each of the four threads, and with trackAllValues: true its Values list holds all four values: 100 and three times 1,100.

An initializer on a [ThreadStatic] field

[ThreadStatic] static int retries = 3; gives 3 to one thread and 0 to every other. Leave the field without an initializer and set it on each thread, or use new ThreadLocal<int>(() => 3).

Thread-local state has a limit in async code: a method that awaits can resume on another thread, with that thread’s value.

Checkpoint

Check: Per-thread state with ThreadLocal and ThreadStatic

  1. One question [ThreadStatic] static int retries = 3; is read first by the main thread, then by a new thread. What does the new thread read?

    Choose one answer.

    Show the answer: One question

    Answer: 0, because the initializer ran once, on the main thread

    A static field's initializer runs once for the type. Give each thread its start value with new ThreadLocal<int>(() => 3).

AsyncLocal follows the work across awaits and threads

A request id for log lines must stay with the request, not with whichever thread runs it at the moment. AsyncLocal<T> holds such a value. It belongs to the asynchronous flow of the work, and it travels in the execution context: The ambient state that .NET carries along a flow of work, including the values of AsyncLocal<T>. It is captured where work is queued or an await waits, and restored where that work runs or resumes. that .NET carries from an await to the code that resumes after it, and from Task.Run to the work it queues. flow.cs compares it with a ThreadLocal, then follows it through a call, two tasks and two requests:

flow.cs: an AsyncLocal and a ThreadLocal across awaits, calls and Task.Run C# · flow/flow.cs
// An AsyncLocal<T> value follows the async flow of work; a ThreadLocal<T> value stays on its thread.
var requestId = new AsyncLocal<string?>();
var perThread = new ThreadLocal<string?>();

requestId.Value = "req-7";
perThread.Value = "req-7";
int before = Environment.CurrentManagedThreadId;
await Task.Delay(50);
bool sameThread = Environment.CurrentManagedThreadId == before;
Console.WriteLine($"1. after an await: AsyncLocal {requestId.Value}, ThreadLocal {perThread.Value ?? "(none)"}, same thread: {sameThread}");

await ChildAsync();
Console.WriteLine($"3. back in the caller: {requestId.Value} (the child's change did not flow back)");

string? seenByTask = await Task.Run(() => requestId.Value);
Console.WriteLine($"4. inside Task.Run: {seenByTask}");

Task<string?> unrelated;
using (ExecutionContext.SuppressFlow())
{
    unrelated = Task.Run(() => requestId.Value);
}

Console.WriteLine($"5. Task.Run with the flow suppressed: {await unrelated ?? "(none)"}");

string[] results = await Task.WhenAll(HandleAsync("A"), HandleAsync("B"));
foreach (string result in results)
{
    Console.WriteLine(result);
}

async Task ChildAsync()
{
    Console.WriteLine($"2. the child sees {requestId.Value}, then sets req-7/child");
    requestId.Value = "req-7/child";
    await Task.Yield();
}

// Two requests running at the same time, each with its own id.
async Task<string> HandleAsync(string id)
{
    requestId.Value = id;
    await Task.Delay(20);
    string? inner = await Task.Run(() => requestId.Value);
    await Task.Delay(20);
    return $"6. request {id}: {requestId.Value} after two awaits, {inner} inside its Task.Run";
}

Output

1. after an await: AsyncLocal req-7, ThreadLocal (none), same thread: False
2. the child sees req-7, then sets req-7/child
3. back in the caller: req-7 (the child's change did not flow back)
4. inside Task.Run: req-7
5. Task.Run with the flow suppressed: (none)
6. request A: A after two awaits, A inside its Task.Run
6. request B: B after two awaits, B inside its Task.Run

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run flow.cs

After the first await, Main resumed on another thread: the ThreadLocal was empty there, while the AsyncLocal still read req-7. Step through lines 1 to 5, which show the rule of the flow: values go down into the calls and the work that a flow starts, and a change made inside an async method never comes back up to its caller.

Interactive · message by message Where the request id of flow.cs goes Step through the run. Main is on the left, the async child method in the middle, the Task.Run work on the right.
Where the request id of flow.cs goes, step 6 of 6. Messages so far: Main to ChildAsync: ChildAsync(); ChildAsync to Main: returns; Main to Task.Run work: Task.Run; Main to Task.Run work: suppressed.Mainstill req-7ChildAsyncreq-7/childTask.Run workreads (none)ChildAsync()returnsTask.Runsuppressed

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Main sets the id. Main sets the AsyncLocal to req-7 and a ThreadLocal to the same text. Both values belong to the main thread for now.
  2. An await. Main awaits a 50 ms delay and resumes on a pool thread. The AsyncLocal still reads req-7, because the async flow carries it; the ThreadLocal of the new thread is empty.
  3. A call flows down. Main calls ChildAsync. The child starts with the caller's values, so it reads req-7, then sets the id to req-7/child.
  4. A change does not flow back. When the call returns, Main reads req-7 again. A change made inside an async method stays in that method and in what it calls.
  5. Task.Run takes the id along. Task.Run captures the values of the code that queues the work, so the work on the pool thread reads req-7.
  6. Unless the flow is suppressed. Inside ExecutionContext.SuppressFlow(), Task.Run captures nothing, and the work reads no id at all: a way to keep a request's id out of work that does not belong to it.

The two requests of line 6 each set their own id, ran at the same time, and never saw each other’s. That is the shape of a correlation id in a service: set it once where a request comes in, and read it in every log line below. Work started inside ExecutionContext.SuppressFlow() inherits nothing, which keeps a request’s id out of a background refresh that the request happens to start.

Key idea

ThreadLocal belongs to a thread and AsyncLocal to a flow of work: after an await, only the AsyncLocal value is certain to be the one you set.

Checkpoint

Check: Per-request state with AsyncLocal

  1. Question 1 of 2 A console app sets an AsyncLocal and a ThreadLocal to "req-7", then awaits Task.Delay(50). What do they hold after the await?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: AsyncLocal "req-7"; ThreadLocal nothing, because the method resumed on another thread

    AsyncLocal belongs to the flow of the work; ThreadLocal belongs to a thread, and the flow changed threads.

  2. Question 2 of 2 An async helper sets Correlation.Id, an AsyncLocal, to "retry-2". After awaiting the helper, what does its caller read?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The caller's own earlier value: a change made inside an async method does not flow back out of it

    To report a value from a helper, return it. AsyncLocal is for ambient values that flow down into calls.

Practise: a request id that follows each request

correlation.cs keeps a request id for log lines in a plain static field. Two of its four checks fail: two requests that run at the same time overwrite each other’s id, and work outside any request still sees the last one.

correlation.cs: four checks of a request id C# · practice/correlation.cs
// Four checks of a request id for log lines. Correlation keeps the id in a plain static field: change it so all
// four pass. The id must follow each request through awaits and Task.Run, and never reach other work.
int passed = 0;

// 1. The id set when a request starts is still there after an await.
Correlation.Current = "order-1";
await Task.Delay(10);
Check(1, "the id survives an await", Correlation.Current == "order-1");

// 2. Work the request starts with Task.Run sees the request's id.
string? inTask = await Task.Run(() => Correlation.Current);
Check(2, "Task.Run sees the request's id", inTask == "order-1");
Correlation.Current = null;

// 3. Two requests that run at the same time keep their own ids.
Task<string?> a = RequestAsync("order-2", wait: 40);
Task<string?> b = RequestAsync("order-3", wait: 10);
string?[] seen = await Task.WhenAll(a, b);
Check(3, "two requests at once keep their own ids", seen[0] == "order-2" && seen[1] == "order-3");

// 4. Work that no request started sees no id.
string? outside = await Task.Run(() => Correlation.Current);
Check(4, "work outside any request sees no id", outside is null);

Console.WriteLine($"{passed} of 4 checks pass");
return passed == 4 ? 0 : 1;

static async Task<string?> RequestAsync(string id, int wait)
{
    Correlation.Current = id;
    await Task.Delay(wait);
    return Correlation.Current;
}

void Check(int number, string what, bool ok)
{
    Console.WriteLine($"check {number}: {what}: {(ok ? "pass" : "FAIL")}");
    if (ok)
    {
        passed++;
    }
}

static class Correlation
{
    // Change only this class.
    private static string? current;

    public static string? Current
    {
        get => current;
        set => current = value;
    }
}

Output (exit status 1)

check 1: the id survives an await: pass
check 2: Task.Run sees the request's id: pass
check 3: two requests at once keep their own ids: FAIL
check 4: work outside any request sees no id: FAIL
2 of 4 checks pass

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run correlation.cs

Make correlation.cs pass

Copy correlation.cs and change only the Correlation class: keep the id in a private static readonly AsyncLocal<string?> and read and write its Value. Run dotnet run correlation.cs until it prints 4 of 4 checks pass. Then try [ThreadStatic] instead, and see which checks it breaks.

Then answer the questions on the five ideas, from what keeps a process alive to what an AsyncLocal holds after an await. They begin with your weakest idea, judged by your answers in this browser, and a wrong pick says what it missed:

Practice that adapts to you

Practise this lesson

10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 What is IsBackground for a thread made with new Thread(...) before you change it?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: false: new threads are foreground threads

    Set IsBackground = true for a thread that must not keep the process alive.

  2. Question 2 of 10 A background thread flushes log lines to a file every second. Users report that the last lines before every exit are missing. What is the cause, and the fix?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: The runtime stops the background thread at exit without running its finally; signal it to stop and Join it before Main returns

    Background threads suit work that may be cut off at any moment. Work that must finish needs an orderly stop.

  3. Question 3 of 10 pool.cs printed the pool's minimum number of worker threads on the 18-processor computer it ran on. What was it?

    Type a number.

    Show the answer: Question 3 of 10

    Answer: 18 threads

    The minimum matched the processor count: "pool minimum: 18 worker threads". Up to it, the pool creates threads on demand; past it, it may wait.

  4. Question 4 of 10 A service queues 100 jobs to the pool, each blocked for 5 s on a synchronous network call, on a computer whose pool minimum is 18. What happens to other work queued at the same time?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: It waits: blocked jobs hold their threads, and past the minimum the pool adds threads only as throughput needs them

    Long blocking work belongs on a thread of its own, or better, in an await of an async API.

  5. Question 5 of 10 A worker loop reads a hardware port with a blocking call for as long as the program runs. Which start fits it best?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: A thread of its own: new Thread, or a task started with LongRunning

    Microsoft lists tasks that block for long periods as a reason to create your own threads.

  6. Question 6 of 10 What does Task.Run give you that new Thread does not?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: A task that carries the work's result or exception to whoever awaits it

    A task is the way back from the work to the code that waits for it.

  7. Question 7 of 10 In locals.cs, each of 3 worker threads adds 1,000 to a ThreadLocal<int> whose factory returns 100, and Main read the value once. What is the sum of all four threads' values?

    Type a number.

    Show the answer: Question 7 of 10

    Answer: 3400

    Main kept 100, and each worker went from 100 to 1,100: 100 + 3 × 1,100 = 3,400, the sum locals.cs printed.

  8. Question 8 of 10 What does ThreadLocal<T>.Values return?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: The value of every thread that used it, when it was created with trackAllValues: true

    Without trackAllValues: true, reading Values throws, because the values are not kept.

  9. Question 9 of 10 Main sets an AsyncLocal to "req-7" and starts Task.Run(() => id.Value). What does the work read?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: "req-7": Task.Run takes the caller's values along

    Work queued with Task.Run captures the ExecutionContext of the code that queued it.

  10. Question 10 of 10 A request starts a cache refresh that should run for every user, but its log lines show the request's user id. Which change keeps the id out of the refresh?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: Start the refresh inside using (ExecutionContext.SuppressFlow()) { … }

    Work that does not belong to the request should not inherit its ambient values.

Interview questions

Fresher · What is the difference between a foreground and a background thread in .NET?

A foreground thread keeps the process alive: a .NET process ends only when its last foreground thread has ended, and the main thread is one. A background thread does not. When the last foreground thread ends, the runtime stops every background thread where it is, without throwing an exception in it, so its catch and finally blocks never run. A thread made with new Thread is a foreground thread until you set IsBackground to true, and thread-pool threads are always background threads. Use a background thread for work that may be dropped at any moment, such as a heartbeat. Work that must finish, such as flushing a file, needs a foreground thread, or a stop request and a Join before Main returns. An unhandled exception ends the process from either kind.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A queue worker starts a new Thread for every message it receives. At 2,000 messages a minute its memory keeps rising and messages wait longer and longer. What is going on, and how do you move it to the thread pool without breaking the messages that block?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    Log lines from one user's checkout show another user's request id. The id is kept in a [ThreadStatic] field that is set where each request starts, and the handlers are async. Explain the bug and fix it.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A process ends when its last foreground thread ends; background threads, pool threads among them, are then stopped where they are, without running their finally blocks.
  • The thread pool runs many short jobs on a few reused background threads: 1,000 small jobs took 6.7 ms on 11 pool threads, and 984.5 ms on 1,000 new threads.
  • Await I/O, use Task.Run for short CPU work, and give work that blocks for long a thread of its own with new Thread or LongRunning.
  • A task carries its result and its exception to the await, while an unhandled exception on a plain thread ends the process.
  • ThreadLocal<T> gives each thread its own value, and a [ThreadStatic] field’s initializer runs on one thread only.
  • AsyncLocal<T> flows with the work into awaits, calls and Task.Run, and a change made inside an async method never flows back to its caller.

Terms in this lesson

background thread
A thread that does not keep its process running. When the last foreground thread ends, the runtime stops every background thread where it is, without running its finally blocks. Thread-pool threads are background threads.
execution context
The ambient state that .NET carries along a flow of work, including the values of AsyncLocal<T>. It is captured where work is queued or an await waits, and restored where that work runs or resumes.
foreground thread
A thread that keeps its process running: a .NET process ends only when its last foreground thread has ended. The main thread is one, and so is every thread made with new Thread until its IsBackground property is set to true.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.