Threading
By the Syncfusion DevTutorials team · Last updated
Threads and processes, creating threads, the lifecycle, foreground and background threads, the thread pool, Task.Run, CPU vs I/O work and error handling.
Introduction to Threads
Imagine a small shop with one cashier.
Customers queue up, and the cashier serves them one at a time. If one customer takes ten minutes to count coins, everyone behind them waits.
Now the shop opens a second and a third till. Three cashiers serve three customers at the same time. The queue moves much faster.
In a program, each cashier is a thread.
In simple words: a thread is a worker inside your program that runs code. More threads means more code can run at the same time.
Every C# program starts with one thread, the main thread. It runs Main (or your top-level statements) from top to bottom. You can create more threads to do work alongside it.
Easy Way to Remember: a program is the shop. A thread is a cashier. More cashiers serve more customers at once, as long as there are enough tills (CPU cores).
What You Will Learn
- What a thread is, and how it differs from a process.
- How to create and start threads, and the thread lifecycle.
- Foreground and background threads, and the
Threadclass. - The thread pool and
Task.Run. - When to use the thread pool and when to create threads yourself.
- CPU-bound versus I/O-bound work.
- How to manage many workers and handle their exceptions.
- How to choose between threads, tasks, and async.
About the Run buttons in this lesson. The examples run in your browser on .NET in WebAssembly, which has only one thread. Creating a Thread there throws PlatformNotSupportedException, so those examples are shown as plain code to run on your own computer. Task.Run does work in the browser: the work waits its turn and runs on the same thread. The results are the same; only the timing is different from a multi-core PC.
Start With a Working Example
Press Run.
Three workers share the job
using System;
using System.Threading.Tasks;
Console.WriteLine("Main: handing out work");
Task<int> worker1 = Task.Run(() => SumRange(1, 1_000));
Task<int> worker2 = Task.Run(() => SumRange(1_001, 2_000));
Task<int> worker3 = Task.Run(() => SumRange(2_001, 3_000));
int[] parts = await Task.WhenAll(worker1, worker2, worker3);
Console.WriteLine($"Part totals: {string.Join(", ", parts)}");
Console.WriteLine($"Grand total: {parts[0] + parts[1] + parts[2]}");
static int SumRange(int from, int to)
{
int total = 0;
for (int i = from; i <= to; i++) total += i;
return total;
}
The big job (add up 1 to 3,000) is split into three smaller jobs. Task.Run hands each one to a worker thread. On a PC with several cores, the three parts really run at the same time.
Try This
Add a fourth worker for 3_001 to 4_000, and include it in WhenAll and the grand total. The new grand total is 8002000.
Process vs Thread
A process is a running program. When you open a browser, a text editor, and a music player, the computer runs three processes.
A thread lives inside a process. One process can have many threads, and they all share the process's memory.
| Process | Thread | |
|---|---|---|
| What it is | A running program | A worker inside a program |
| Memory | Its own, separate memory | Shares memory with the other threads in the same process |
| Cost to start | High (milliseconds) | Low (microseconds) |
| If it crashes | Other processes keep running | The whole process stops |
| Talking to others | Hard (files, pipes, network) | Easy (they share variables) |
| Example | Your browser | One tab loading a page while another plays video |
In simple words: a process is a house. Threads are the people living in it. They share the kitchen, so they can help each other, but they can also get in each other's way.
That last point is important. Because threads share memory, two threads can change the same variable at the same time. Lesson 5 shows how to keep that safe.
Information about the current process and thread
using System;
using System.Threading;
Console.WriteLine($"CPU cores available: {Environment.ProcessorCount > 0}");
Console.WriteLine($"This code runs on a thread: {Thread.CurrentThread.ManagedThreadId > 0}");
Console.WriteLine($"It is the main (foreground) thread: {!Thread.CurrentThread.IsBackground}");
Each thread has a ManagedThreadId, a number that identifies it. Environment.ProcessorCount tells you how many CPU cores the program can use, which is how many threads can truly run at the same moment.
Creating Threads
The classic way to create a thread is with the Thread class:
- Create a
Threadand give it the method to run. - Call
Start()to begin. - Call
Join()to wait for it to finish.
using System;
using System.Threading;
Thread worker = new Thread(PrintNumbers);
worker.Start(); // the worker begins running
for (int i = 1; i <= 3; i++)
Console.WriteLine($"Main: {i}");
worker.Join(); // wait until the worker is done
Console.WriteLine("Both finished");
static void PrintNumbers()
{
for (int i = 1; i <= 3; i++)
Console.WriteLine($" Worker: {i}");
}
One possible output:
Main: 1
Worker: 1
Main: 2
Worker: 2
Worker: 3
Main: 3
Both finished
The lines from the two threads are mixed, and the mix changes every time you run it. Both threads run at the same time, and the operating system decides who goes next. Only "Both finished" is always last, because Join waits for the worker.
To pass data into a thread, use a lambda:
string file = "report.pdf";
var t = new Thread(() => Console.WriteLine($"Printing {file}"));
t.Start();
t.Join();
new Thread(...) cannot run in this browser playground; it throws PlatformNotSupportedException. Copy these examples into a console app on your own computer to see real threads. In modern code, you rarely create threads yourself anyway: Task.Run (below) is the usual choice.
Thread Lifecycle
A thread moves through a few states during its life. You can read the current state with the ThreadState property.
| State | Meaning |
|---|---|
Unstarted | Created, but Start() has not been called yet |
Running | Running code (or ready to run) |
WaitSleepJoin | Paused: sleeping, waiting for a lock, or inside Join() |
Stopped | Finished. It cannot be started again |
Unstarted --Start()--> Running <--> WaitSleepJoin
|
method ends
v
Stopped
var t = new Thread(() => Thread.Sleep(200));
Console.WriteLine(t.ThreadState); // Unstarted
t.Start();
Thread.Sleep(50);
Console.WriteLine(t.ThreadState); // WaitSleepJoin (it is sleeping)
t.Join();
Console.WriteLine(t.ThreadState); // Stopped
A thread can only be started once. Calling Start() on a stopped thread throws ThreadStateException. To run the work again, create a new thread.
Foreground and Background Threads
Every thread is either a foreground or a background thread.
- A foreground thread keeps the program alive. The program does not exit until every foreground thread has finished.
- A background thread does not keep the program alive. When the last foreground thread ends, background threads are stopped immediately, even in the middle of their work.
Think of a cinema. The film (foreground) decides when the show ends. The cleaner (background) stops working as soon as the doors close, even if they have not finished.
| Foreground | Background | |
|---|---|---|
| Keeps the program running | Yes | No |
Default for new Thread(...) | Yes | No |
Thread-pool threads (Task.Run) | No | Yes |
| Set with | IsBackground = false | IsBackground = true |
| Good for | Work that must finish, like saving a file | Helpers that can stop anytime, like a clock or a spell checker |
var saver = new Thread(SaveFile) { IsBackground = false }; // must finish
var ticker = new Thread(ShowClock) { IsBackground = true }; // may stop anytime
saver.Start();
ticker.Start();
// When Main ends, the program waits for saver, but not for ticker.
Thread-pool threads are always background threads. On your own computer, this prints True:
bool isBackground = await Task.Run(() => Thread.CurrentThread.IsBackground);
Console.WriteLine($"Task.Run thread is background: {isBackground}");
Thread Class
The Thread class has several members you will use often.
| Member | What it does |
|---|---|
new Thread(method) | Creates a thread that will run method |
Start() | Begins running the thread |
Join() | Waits until the thread finishes |
IsBackground | Foreground or background |
Name | A name, useful in the debugger |
ThreadState | The current state |
IsAlive | true while the thread is running |
Thread.CurrentThread | The thread running this line of code |
Thread.Sleep(ms) | Pauses the current thread |
Priority | A hint to the OS: Lowest to Highest |
var worker = new Thread(DoWork)
{
Name = "Report builder",
IsBackground = true,
Priority = ThreadPriority.BelowNormal,
};
worker.Start();
Console.WriteLine($"{worker.Name} alive? {worker.IsAlive}");
worker.Join();
Console.WriteLine($"{worker.Name} alive? {worker.IsAlive}");
You can read Thread.CurrentThread anywhere, including in the browser:
Thread.CurrentThread
using System;
using System.Threading;
Thread me = Thread.CurrentThread;
me.Name = "Main";
Console.WriteLine($"Name: {me.Name}");
Console.WriteLine($"Alive: {me.IsAlive}");
Console.WriteLine($"Has an id: {me.ManagedThreadId > 0}");
Avoid Thread.Abort(), Suspend(), and Resume(). They are unsafe and throw PlatformNotSupportedException in modern .NET. To stop a thread, ask it politely with a CancellationToken (lesson 3 of Chapter 1).
Thread Pool
Creating a thread costs time and memory: about 1 MB of stack for each one. A program that creates a new thread for every small job wastes a lot of resources.
The thread pool is a group of ready-made threads that .NET keeps alive and reuses. You hand it a job, a free pool thread runs it, and then the thread goes back to the pool to wait for the next job.
Think of a taxi rank. Instead of buying a new car for every trip, you take the next free taxi, and it comes back to the rank afterwards.
The pool grows and shrinks automatically. You never create or destroy pool threads yourself.
The low-level way to use the pool is ThreadPool.QueueUserWorkItem:
ThreadPool.QueueUserWorkItem(_ => Console.WriteLine("Running on a pool thread"));
It has no easy way to get a result back or to know when the job is done. That is why modern code uses Task.Run instead.
Task.Run
Task.Run queues work on the thread pool and gives you back a Task to await. It combines the thread pool with everything you learned about tasks:
Task.Run(() => ...)returns aTask.Task.Run(() => value)returns aTask<T>with the result.awaitwaits for it without blocking.- Exceptions are stored in the task and rethrown at
await.
Task.Run with and without a result
using System;
using System.Threading.Tasks;
await Task.Run(() => Console.WriteLine("Work with no result"));
int primes = await Task.Run(() => CountPrimes(10_000));
Console.WriteLine($"Primes below 10,000: {primes}");
static int CountPrimes(int limit)
{
int count = 0;
for (int n = 2; n < limit; n++)
{
bool isPrime = true;
for (int d = 2; d * d <= n; d++)
if (n % d == 0) { isPrime = false; break; }
if (isPrime) count++;
}
return count;
}
In a desktop app, Task.Run keeps the window responsive: the slow calculation runs on a pool thread, and the UI thread is free to redraw and handle clicks.
Thread Pool vs Manual Threads
Thread pool (Task.Run) | Manual thread (new Thread) | |
|---|---|---|
| Start-up cost | Very low, threads are reused | Higher, a new thread each time |
| Get a result back | Easy: Task<T> and await | Hard: shared variables |
| Exceptions | Stored in the task, rethrown at await | Crash the whole program if not caught |
| Background or foreground | Always background | Foreground by default |
| Set name, priority | No | Yes |
| Best for | Short and medium jobs: the normal choice | Rare long-running jobs that need special settings |
Easy Way to Remember: use Task.Run unless you have a specific reason not to.
For work that runs for the whole life of the program, such as a loop that watches a queue, you can tell the pool to use a dedicated thread instead of borrowing one:
Task watcher = Task.Factory.StartNew(WatchQueue, TaskCreationOptions.LongRunning);
CPU-Bound vs I/O-Bound Operations
Slow work comes in two kinds, and they need different tools.
CPU-bound work is slow because of calculation. The processor is busy the whole time. Examples: resizing an image, compressing a file, counting primes, training a model.
I/O-bound work is slow because of waiting. The processor is mostly idle. Examples: downloading a file, reading from disk, querying a database, calling an API.
| CPU-bound | I/O-bound | |
|---|---|---|
| Slow because of | Calculation | Waiting for disk, network, or database |
| The CPU during the wait | 100% busy | Mostly idle |
| More cores help? | Yes | No |
| Right tool | Task.Run, Parallel | async/await with real async APIs |
| Example | Task.Run(() => ResizeImage(img)) | await client.GetStringAsync(url) |
Putting I/O on Task.Run wastes a pool thread that just sits and waits. Using async for CPU work does not make it any faster. Match the tool to the kind of work.
CPU work on Task.Run, waiting work with await
using System;
using System.Threading.Tasks;
// CPU-bound: hand it to a worker thread.
Task<long> calculation = Task.Run(() => Factorial(20));
// I/O-bound: just await it (Task.Delay stands in for a web request).
Task<string> download = DownloadAsync();
Console.WriteLine($"20! = {await calculation}");
Console.WriteLine($"Downloaded: {await download}");
static long Factorial(int n)
{
long result = 1;
for (int i = 2; i <= n; i++) result *= i;
return result;
}
static async Task<string> DownloadAsync()
{
await Task.Delay(200);
return "price-list.csv";
}
Managing Multiple Workers
When there is a lot of CPU work, split it into pieces, give each piece to a worker, then combine the results.
A good rule is one worker per CPU core. More workers than cores just take turns on the same cores.
Split a big job across one worker per core
using System;
using System.Linq;
using System.Threading.Tasks;
int[] numbers = Enumerable.Range(1, 100_000).ToArray();
int workers = Math.Max(1, Math.Min(4, Environment.ProcessorCount));
int chunk = (numbers.Length + workers - 1) / workers;
var tasks = new Task<long>[workers];
for (int w = 0; w < workers; w++)
{
int start = w * chunk;
int end = Math.Min(start + chunk, numbers.Length);
tasks[w] = Task.Run(() =>
{
long sum = 0;
for (int i = start; i < end; i++)
sum += (long)numbers[i] * numbers[i];
return sum;
});
}
long[] partSums = await Task.WhenAll(tasks);
Console.WriteLine($"Workers used: {(workers >= 1 ? "at least one" : "none")}");
Console.WriteLine($"Sum of squares 1..100,000 = {partSums.Sum()}");
Notice int start and int end are declared inside the loop. Each lambda then keeps its own copy. If the lambda used the loop variable w directly, every worker could see its final value and work on the wrong chunk.
Lesson 7 shows Parallel.For, which does all of this splitting for you.
Exception Handling in Threads
Exceptions behave very differently in manual threads and in tasks.
Manual thread: an exception that is not caught inside the thread crashes the whole program. The caller cannot catch it.
var t = new Thread(() => throw new InvalidOperationException("boom"));
try
{
t.Start();
t.Join();
}
catch (Exception) // never reached
{
}
// The unhandled exception on the worker thread ends the whole process.
So inside a manual thread, always wrap the body in try/catch:
var t = new Thread(() =>
{
try { DoRiskyWork(); }
catch (Exception ex) { Console.WriteLine($"Worker failed: {ex.Message}"); }
});
Task: the exception is stored in the task and rethrown at await. A normal try/catch in the caller works, and the program keeps running.
A failing worker inside Task.Run
using System;
using System.Threading.Tasks;
Task<int> risky = Task.Run(() => ParseAge("twelve"));
try
{
int age = await risky;
Console.WriteLine($"Age: {age}");
}
catch (FormatException ex)
{
Console.WriteLine($"The worker failed: {ex.Message}");
}
Console.WriteLine($"Task status: {risky.Status}");
Console.WriteLine("The program is still running");
static int ParseAge(string text) =>
int.TryParse(text, out int age) ? age : throw new FormatException($"'{text}' is not a number");
When several workers fail, await Task.WhenAll(...) throws the first exception, and the WhenAll task's .Exception.InnerExceptions holds all of them (see Chapter 1, lesson 2).
Choosing Between Threads, Tasks, and Async Operations
| Situation | Use | Example |
|---|---|---|
| Waiting for disk, network, or database | async / await | await File.ReadAllTextAsync(path) |
| Heavy calculation, keep the UI responsive | Task.Run | await Task.Run(() => Resize(img)) |
| Many calculations over a collection | Parallel.For / PLINQ (lesson 7) | Parallel.ForEach(images, Resize) |
| A worker that runs for the whole program | Task.Factory.StartNew with LongRunning, or new Thread | A queue watcher |
| Special thread settings (name, priority, STA) | new Thread | A COM component that needs STA |
Easy Way to Remember:
- Waiting? Use
async/await. - Calculating? Use
Task.RunorParallel. - Something unusual? Only then use
new Thread.
Common mistakes
| Mistake | Wrong | Correct | Why |
|---|---|---|---|
Forgetting Start() | new Thread(Work); | new Thread(Work).Start(); | A thread does nothing until it is started |
| Not waiting for a thread | t.Start(); then use its result | t.Start(); t.Join(); | The result is not ready yet |
| Starting a thread twice | t.Start(); t.Start(); | Create a new Thread | A stopped thread cannot restart |
| Uncaught error in a thread | new Thread(() => Risky()) | Wrap the body in try/catch | It crashes the whole program |
| New thread per small job | new Thread(...) in a loop | Task.Run(...) | Threads are costly; the pool reuses them |
Task.Run around async I/O | Task.Run(() => DownloadAsync()) | await DownloadAsync() | Wastes a pool thread that just waits |
| Lambda captures the loop variable | Task.Run(() => Use(i)) | int copy = i; Task.Run(() => Use(copy)) | Workers may see the wrong value |
| Background work cut off | IsBackground = true for a file save | Keep it foreground, or await it | Background threads stop when the app exits |
What to Remember
- A thread is a worker that runs code. A process is a running program, and it can contain many threads that share memory.
new Thread(method)creates a thread,Start()runs it,Join()waits for it. A thread can be started only once.- A thread moves from
UnstartedtoRunning, maybeWaitSleepJoin, and finallyStopped. - Foreground threads keep the program alive; background threads (including all pool threads) do not.
- The thread pool reuses threads.
Task.Runputs work on the pool and gives you a task to await. - Prefer
Task.Runovernew Threadunless you need special settings or a long-running worker. - CPU-bound work needs threads (
Task.Run,Parallel); I/O-bound work needsasync/await. - Split big CPU jobs into about one piece per core, then combine the results.
- An uncaught exception in a manual thread crashes the program; in a task it is rethrown at
await.
Next: Synchronization