C# Multithreading

Multithreading allows a C# program to have more than one thread of execution. Each thread can run a separate sequence of instructions, which is useful when an application must remain responsive, coordinate background work, or use multiple CPU cores for suitable workloads.

Every C# application starts with a main thread. Additional threads can be created with classes from the System.Threading namespace. However, creating Thread objects directly is a low-level approach. For most application code, Task, the thread pool, and async/await are usually easier to manage.

How C# Threads Execute Work

A thread is an independent execution path within a process. The operating system schedules runnable threads and decides when each thread gets CPU time. On a multicore processor, multiple threads may run at the same time. On a single core, the scheduler rapidly switches between threads, creating the appearance of concurrent execution.

Multithreading and parallelism are related but not identical. Multithreading means that a process has multiple threads. Parallelism means that work is actually executing at the same time, usually on different CPU cores.

When C# Multithreading Is Appropriate

Use multithreading when independent operations can make progress concurrently and when the added coordination cost is justified. Common cases include:

  • Keeping a desktop user interface responsive while background work runs.
  • Processing independent CPU-bound operations on multiple cores.
  • Running long-lived background workers.
  • Handling several independent tasks whose results can be combined later.

For network requests, database calls, and file I/O, asynchronous programming is generally preferable to creating one thread per operation. An asynchronous method can wait without blocking a thread, which scales better when many operations are in progress.

Creating a Thread with System.Threading.Thread

To define a thread in C#, import System.Threading and create a Thread object. Pass a method that has no parameters and returns void, or provide a compatible delegate.

</>
Copy
 Thread thread1 = new Thread(someMethod);

The statement above creates a thread named thread1. The method does not begin executing until Start() is called.

Starting a C# Thread with Start()

Call the Start() method on the thread instance to make the thread eligible for execution.

</>
Copy
 thread1.Start();

The exact moment at which the new thread runs is controlled by the operating-system scheduler. Calling Start() does not guarantee that the new thread will execute before the current thread continues.

C# Multithreading Example with Two Threads

In the following example, method1 and method2 are assigned to separate threads. Both threads are started from Main().

Program.cs

</>
Copy
using System;
using System.Threading;

namespace CSharpExamples {

    class Program {

        public static void method1() {
            for(int i=0;i<10;i++) {
                Console.WriteLine("method1");
            }
        }

        public static void method2() {
            for(int i=0;i<10;i++) {
                Console.WriteLine("method2");
            }
        }

        static void Main(string[] args) {
            Thread thread1 = new Thread(method1); 
            Thread thread2 = new Thread(method2); 
            thread1.Start(); 
            thread2.Start();
        }
    }
}

Output

PS D:\workspace\csharp\HelloWorld> dotnet run
method1
method1
method1
method1
method2
method2
method2
method2
method2
method2
method2
method2
method2
method2
method1
method1
method1
method1
method1
method1

The output shows that the two threads can make progress without one waiting for the other to finish. The order is not guaranteed and may be different on another run, computer, or .NET runtime.

Waiting for a Thread with Join()

The main thread can wait for a worker thread by calling Join(). This is useful when the application must not continue until the worker has completed.

</>
Copy
using System;
using System.Threading;

class Program
{
    static void PrintNumbers()
    {
        for (int i = 1; i <= 5; i++)
        {
            Console.WriteLine($"Worker: {i}");
            Thread.Sleep(200);
        }
    }

    static void Main()
    {
        Thread worker = new Thread(PrintNumbers);

        worker.Start();
        worker.Join();

        Console.WriteLine("Worker thread completed.");
    }
}

Without Join(), the main thread would continue immediately after starting the worker. With Join(), the final message is printed only after PrintNumbers() returns.

Passing Data to a C# Thread

A lambda expression is a simple way to pass values to the method executed by a thread.

</>
Copy
using System;
using System.Threading;

class Program
{
    static void PrintMessage(string message, int count)
    {
        for (int i = 0; i < count; i++)
        {
            Console.WriteLine(message);
        }
    }

    static void Main()
    {
        Thread worker = new Thread(() => PrintMessage("Running worker thread", 3));

        worker.Start();
        worker.Join();
    }
}

The lambda captures the values supplied to PrintMessage(). Avoid modifying captured variables from several threads unless access is synchronized.

Thread.Sleep() and Thread State

Thread.Sleep(milliseconds) pauses the current thread for at least the requested duration. It blocks that thread, so it should not be used as a general replacement for asynchronous delays.

</>
Copy
Thread.Sleep(1000); // Blocks the current thread for about one second

A thread commonly moves through states such as unstarted, running or runnable, waiting, and stopped. Application code should normally coordinate completion with Join(), tasks, cancellation tokens, or synchronization primitives rather than depending on thread-state checks.

Protecting Shared Data with lock

When multiple threads read and write the same data, their operations can overlap and produce a race condition. The lock statement allows only one thread at a time to enter a protected section.

</>
Copy
using System;
using System.Threading;

class Program
{
    private static readonly object counterLock = new object();
    private static int counter = 0;

    static void IncrementCounter()
    {
        for (int i = 0; i < 100_000; i++)
        {
            lock (counterLock)
            {
                counter++;
            }
        }
    }

    static void Main()
    {
        Thread first = new Thread(IncrementCounter);
        Thread second = new Thread(IncrementCounter);

        first.Start();
        second.Start();

        first.Join();
        second.Join();

        Console.WriteLine(counter);
    }
}
200000

The lock protects the read-modify-write operation performed by counter++. Keep locked sections small, avoid blocking I/O while holding a lock, and use a dedicated private object as the lock target.

Thread, ThreadPool, Task, and async/await

APIBest suited forMain consideration
ThreadLow-level control over a dedicated threadMore expensive and more complex to manage directly
ThreadPoolShort background operations using reusable worker threadsDo not block pool threads for long periods
TaskComposing, scheduling, and waiting for asynchronous or parallel workUsually preferred over manually creating threads
async/awaitNon-blocking I/O such as HTTP, database, and file operationsDoes not automatically create a new thread

For CPU-bound work that can run independently, Task.Run() can schedule work on the thread pool. For I/O-bound work, call the asynchronous API directly and await it instead of wrapping it in Task.Run().

</>
Copy
using System;
using System.Threading.Tasks;

class Program
{
    static int CalculateSum()
    {
        int sum = 0;

        for (int i = 1; i <= 1_000_000; i++)
        {
            sum += i;
        }

        return sum;
    }

    static async Task Main()
    {
        int result = await Task.Run(CalculateSum);
        Console.WriteLine(result);
    }
}

Common C# Multithreading Problems

  • Race conditions: Multiple threads update shared state without synchronization.
  • Deadlocks: Threads wait indefinitely for locks held by one another.
  • Thread starvation: A thread cannot get enough CPU time or thread-pool resources.
  • Excessive context switching: Too many active threads spend more time being scheduled than doing useful work.
  • Unobserved failures: Exceptions on background work are not handled or communicated to the controlling code.

Design concurrent code around clear ownership of data. Prefer immutable data, message passing, concurrent collections, and higher-level task-based APIs where practical.

C# Multithreading FAQs

Does calling Thread.Start() run the method immediately?

No. It makes the thread eligible to run, but the operating-system scheduler decides when it receives CPU time.

Why does the output order change between runs?

Thread scheduling is nondeterministic. Unless the program uses synchronization, one thread may run before, after, or between operations performed by another thread.

Should I use Thread or Task in new C# code?

Use Task for most application-level concurrency because it supports composition, cancellation, exception propagation, and asynchronous workflows. Use Thread when a dedicated thread and low-level thread control are specifically required.

Is async/await the same as multithreading?

No. async/await enables non-blocking asynchronous control flow. An asynchronous operation may complete without occupying another thread while it waits.

How do I stop a C# thread safely?

Use cooperative cancellation. Signal that cancellation is requested, let the worker check the signal, clean up its resources, and return normally. Avoid forcibly terminating a thread because it can leave shared state inconsistent.

C# Multithreading Summary

In this C# Tutorial, we learned how to create and start threads, wait with Join(), pass data to a thread, synchronize shared state with lock, and choose between Thread, Task, and async/await.