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

  1. What a thread is, and how it differs from a process.
  2. How to create and start threads, and the thread lifecycle.
  3. Foreground and background threads, and the Thread class.
  4. The thread pool and Task.Run.
  5. When to use the thread pool and when to create threads yourself.
  6. CPU-bound versus I/O-bound work.
  7. How to manage many workers and handle their exceptions.
  8. 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.

ProcessThread
What it isA running programA worker inside a program
MemoryIts own, separate memoryShares memory with the other threads in the same process
Cost to startHigh (milliseconds)Low (microseconds)
If it crashesOther processes keep runningThe whole process stops
Talking to othersHard (files, pipes, network)Easy (they share variables)
ExampleYour browserOne 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:

  1. Create a Thread and give it the method to run.
  2. Call Start() to begin.
  3. 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.

StateMeaning
UnstartedCreated, but Start() has not been called yet
RunningRunning code (or ready to run)
WaitSleepJoinPaused: sleeping, waiting for a lock, or inside Join()
StoppedFinished. 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.

ForegroundBackground
Keeps the program runningYesNo
Default for new Thread(...)YesNo
Thread-pool threads (Task.Run)NoYes
Set withIsBackground = falseIsBackground = true
Good forWork that must finish, like saving a fileHelpers 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.

MemberWhat it does
new Thread(method)Creates a thread that will run method
Start()Begins running the thread
Join()Waits until the thread finishes
IsBackgroundForeground or background
NameA name, useful in the debugger
ThreadStateThe current state
IsAlivetrue while the thread is running
Thread.CurrentThreadThe thread running this line of code
Thread.Sleep(ms)Pauses the current thread
PriorityA 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 a Task.
  • Task.Run(() => value) returns a Task<T> with the result.
  • await waits 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 costVery low, threads are reusedHigher, a new thread each time
Get a result backEasy: Task<T> and awaitHard: shared variables
ExceptionsStored in the task, rethrown at awaitCrash the whole program if not caught
Background or foregroundAlways backgroundForeground by default
Set name, priorityNoYes
Best forShort and medium jobs: the normal choiceRare 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-boundI/O-bound
Slow because ofCalculationWaiting for disk, network, or database
The CPU during the wait100% busyMostly idle
More cores help?YesNo
Right toolTask.Run, Parallelasync/await with real async APIs
ExampleTask.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

SituationUseExample
Waiting for disk, network, or databaseasync / awaitawait File.ReadAllTextAsync(path)
Heavy calculation, keep the UI responsiveTask.Runawait Task.Run(() => Resize(img))
Many calculations over a collectionParallel.For / PLINQ (lesson 7)Parallel.ForEach(images, Resize)
A worker that runs for the whole programTask.Factory.StartNew with LongRunning, or new ThreadA queue watcher
Special thread settings (name, priority, STA)new ThreadA COM component that needs STA

Easy Way to Remember:

  • Waiting? Use async/await.
  • Calculating? Use Task.Run or Parallel.
  • Something unusual? Only then use new Thread.

Common mistakes

MistakeWrongCorrectWhy
Forgetting Start()new Thread(Work);new Thread(Work).Start();A thread does nothing until it is started
Not waiting for a threadt.Start(); then use its resultt.Start(); t.Join();The result is not ready yet
Starting a thread twicet.Start(); t.Start();Create a new ThreadA stopped thread cannot restart
Uncaught error in a threadnew Thread(() => Risky())Wrap the body in try/catchIt crashes the whole program
New thread per small jobnew Thread(...) in a loopTask.Run(...)Threads are costly; the pool reuses them
Task.Run around async I/OTask.Run(() => DownloadAsync())await DownloadAsync()Wastes a pool thread that just waits
Lambda captures the loop variableTask.Run(() => Use(i))int copy = i; Task.Run(() => Use(copy))Workers may see the wrong value
Background work cut offIsBackground = true for a file saveKeep it foreground, or await itBackground 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 Unstarted to Running, maybe WaitSleepJoin, and finally Stopped.
  • Foreground threads keep the program alive; background threads (including all pool threads) do not.
  • The thread pool reuses threads. Task.Run puts work on the pool and gives you a task to await.
  • Prefer Task.Run over new Thread unless you need special settings or a long-running worker.
  • CPU-bound work needs threads (Task.Run, Parallel); I/O-bound work needs async/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