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.
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:
// 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:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- 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.
- 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.
- Main returns. Main prints its line and returns. The process keeps running, because one foreground thread has not finished.
- The last foreground thread ends. After 200 ms the foreground thread prints its line and ends. No foreground thread is left in the process.
- 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
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:
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- Another takes the next. A second pool thread takes the next job, so two jobs run at the same time on two processor cores.
- 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.
- 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.
- 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
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:
// 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:
// 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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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
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:
// 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
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:
// 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.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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.
// 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.
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.
-
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?
-
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.
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
finallyblocks. - 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.Runfor short CPU work, and give work that blocks for long a thread of its own withnew ThreadorLongRunning. - 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 andTask.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
- Threads and threading (Microsoft Learn)
- Foreground and background threads (Microsoft Learn)
- The managed thread pool (Microsoft Learn)
- Exceptions in managed threads (Microsoft Learn)
- TaskCreationOptions enum (Microsoft Learn)
- ThreadStaticAttribute class (Microsoft Learn)
- ThreadLocal<T> class (Microsoft Learn)
- AsyncLocal<T> class (Microsoft Learn)
- ExecutionContext.SuppressFlow method (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress