Updated on
Asynchronous programming is about not holding a thread while waiting. Multithreading is about using more threads to do more work at the same time. They solve different problems and are routinely confused because both make a program feel faster.
The practical rule follows from that. Waiting on a network call, a database, or a disk read is I/O: use async and await, and the thread goes back to the pool while the wait happens. Work that keeps a CPU busy is compute; that is what extra threads are for.
Asynchronous Programming
Asynchronous programming is a technique where a set of statements runs independently of the main program flow.
We use asynchronous programming when we have a blocking operation in the program and we want to continue with the execution of the program without waiting for the result. This allows us to implement tasks that can run at the same time.
In C#, asynchronous programming is achieved through the use of the async and await keywords. You can learn more about this in our Asynchronous Programming with Async and Await in ASP.NET Core article.
Multithreading
In computer science, a thread is a single continuous flow of control within a program. Multithreading is a technique where the processor uses multiple threads to execute multiple processes concurrently.
We mainly use multithreading when we want to maximize the multi-core processors to have multiple workers working independently.
In C#, the System.Threading namespace contains multiple classes and interfaces for achieving multithreading in the application. To learn more about how to run code in a new thread, you can read our How to Run Code in a New Thread in C# article.
Asynchronous Programming vs Multithreading
They are not alternatives. One is about waiting efficiently, the other about working in parallel, and a program often needs both.
Asynchronous code suspends at an await and releases its thread until the operation it is waiting for completes. Nothing is running during that time: no thread is held, no CPU is used, and the continuation resumes on whatever thread is available afterwards. This is why a single thread can serve thousands of concurrent requests that are mostly waiting.
Multithreading runs several pieces of code at once on several threads. A thread that calls Thread.Sleep or blocks on a lock stays occupied and unavailable for anything else, which is exactly the cost async avoids.
The overlap is that a Task can represent either. Task.Run puts work on a thread pool thread; that is multithreading. await httpClient.GetAsync() holds no thread at all; that is asynchrony. Microsoft’s C# documentation adds the corollary: “Async methods don’t require multithreading because an async method doesn’t run on its own thread.”
Both examples below print the ids of the threads they run on, which is the quickest way to see that difference at runtime. If the question is about the two types rather than the two models, we cover tasks compared with threads separately.
Async Code Example
Let’s see multiple asynchronous operations in action:
public static async Task FirstAsync()
{
Console.WriteLine("First Async Method on Thread with Id: " + Environment.CurrentManagedThreadId);
await Task.Delay(1000);
Console.WriteLine("First Async Method Continuation on Thread with Id: " + Environment.CurrentManagedThreadId);
}
public static async Task SecondAsync()
{
Console.WriteLine("Second Async Method on Thread with Id: " + Environment.CurrentManagedThreadId);
await Task.Delay(1000);
Console.WriteLine("Second Async Method Continuation on Thread with Id: " + Environment.CurrentManagedThreadId);
}
public static async Task ThirdAsync()
{
Console.WriteLine("Third Async Method on Thread with Id: " + Environment.CurrentManagedThreadId);
await Task.Delay(1000);
Console.WriteLine("Third Async Method Continuation on Thread with Id: " + Environment.CurrentManagedThreadId);
}
In each of these asynchronous methods, we write the thread id this method uses when starting the execution. Then, we simulate some work by adding a one-second delay, and finally, we print another message to the console.
Environment.CurrentManagedThreadId is the recommended way to read the current thread id since .NET 6. It returns the same number as Thread.CurrentThread.ManagedThreadId without materialising the Thread object first.
Now, let’s add another method that will execute these methods:
public static async Task ExecuteAsyncFunctions()
{
var firstAsync = FirstAsync();
var secondAsync = SecondAsync();
var thirdAsync = ThirdAsync();
await Task.WhenAll(firstAsync, secondAsync, thirdAsync);
}
Finally, we are going to modify the Main method:
static async Task Main(string[] args)
{
await ExecuteAsyncFunctions();
}
Let’s run our app and inspect the approximate output:
First Async Method on Thread with Id: 2 Second Async Method on Thread with Id: 2 Third Async Method on Thread with Id: 2 Third Async Method Continuation on Thread with Id: 5 Second Async Method Continuation on Thread with Id: 7 First Async Method Continuation on Thread with Id: 8
We can see that all the operations are starting on the same thread with a number 2. But they are continuing their execution on different threads (5, 7, 8). The exact ids and the order of the continuation lines change from run to run and from machine to machine; the pattern of one starting thread and several continuation threads is what stays the same.
So, why is this happening?
It’s happening because once the thread hits the awaiting operation in FirstAsync, the thread is freed from that method and returned to the thread pool. Once the operation is completed and the method has to continue, a thread is assigned to it from the thread pool. The same process is repeated for the SecondAsync and ThirdAsync as well.
Multithreading Code Example
Now let’s try to implement the same in a multithreaded environment:
public class Multithreading
{
public void FirstMethod()
{
Console.WriteLine("First Multithreading Method on Thread with Id: " + Environment.CurrentManagedThreadId);
Thread.Sleep(1000);
Console.WriteLine("First Multithreading Method Continuation on Thread with Id: " + Environment.CurrentManagedThreadId);
}
public void SecondMethod()
{
Console.WriteLine("Second Multithreading Method on Thread with Id: " + Environment.CurrentManagedThreadId);
Thread.Sleep(1000);
Console.WriteLine("Second Multithreading Method Continuation on Thread with Id: " + Environment.CurrentManagedThreadId);
}
public void ThirdMethod()
{
Console.WriteLine("Third Multithreading Method on Thread with Id: " + Environment.CurrentManagedThreadId);
Thread.Sleep(1000);
Console.WriteLine("Third Multithreading Method Continuation on Thread with Id: " + Environment.CurrentManagedThreadId);
}
}
The one-line difference between the two examples carries the whole idea. The async methods await Task.Delay(1000) and hold no thread while they wait; these methods call Thread.Sleep(1000), which parks the thread they are on and keeps it out of circulation for the full second. Our article on Thread.Sleep compared with Task.Delay goes through that distinction on its own.
Also, we need to execute these methods:
public void ExecuteMultithreading()
{
Thread t1 = new Thread(FirstMethod);
Thread t2 = new Thread(SecondMethod);
Thread t3 = new Thread(ThirdMethod);
t1.Start();
t2.Start();
t3.Start();
t1.Join();
t2.Join();
t3.Join();
}
Each Join() call blocks the calling thread until that worker finishes. Without them, the method would return the moment the three threads were started, and whether we saw their output at all would depend on process shutdown timing. The calls also demonstrate the cost the article is describing: the caller now holds its own thread doing nothing but waiting.
Finally, we have to modify the Main method:
static async Task Main(string[] args)
{
await ExecuteAsyncFunctions();
Console.WriteLine();
Multithreading multithreading = new Multithreading();
multithreading.ExecuteMultithreading();
}
Let’s see the approximate output to clearly understand how they are different from each other:
First Async Method on Thread with Id: 2 Second Async Method on Thread with Id: 2 Third Async Method on Thread with Id: 2 Third Async Method Continuation on Thread with Id: 5 Second Async Method Continuation on Thread with Id: 7 First Async Method Continuation on Thread with Id: 8 First Multithreading Method on Thread with Id: 10 Second Multithreading Method on Thread with Id: 11 Third Multithreading Method on Thread with Id: 12 Second Multithreading Method Continuation on Thread with Id: 11 Third Multithreading Method Continuation on Thread with Id: 12 First Multithreading Method Continuation on Thread with Id: 10
The first six lines are the async example from the previous section running again, because Main runs both demonstrations in sequence. The multithreading output is everything below the blank line.
We can clearly see the execution of multithreaded methods on different threads as expected. But also, they are keeping the same threads for the continuation compared to the async methods.
From this example, we can see the main difference – Multithreading is a programming technique for executing operations running on multiple threads (also called workers) where we use different threads and block them until the job is done. Asynchronous programming is the concurrent execution of multiple tasks (here the assigned thread is returned back to a thread pool once the await keyword is reached in the method).
Does async/await Create a New Thread?
No. Marking a method async and awaiting inside it creates no thread. Microsoft’s C# documentation is unambiguous: “The async and await keywords don’t cause extra threads to be created.”
The output in the previous section shows what actually happens. All three async methods start on thread 2, and after their awaits complete, the continuations run on threads 5, 7, and 8. Those are thread pool threads that already existed and were idle. The await released thread 2, and the pool assigned whichever thread was free when the work finished.
The multithreading example is the contrast. Three threads are created explicitly and each keeps its own thread all the way through, including while it sleeps.
There is one case where an async method does occupy a thread: Task.Run inside it. That call is a deliberate request to run work on the pool, and it is the right one for CPU-bound work and the wrong one for I/O.
The continuation thread depends on the host. A console application and an ASP.NET Core request have no synchronization context, so the continuation runs on a pool thread as we saw above; a UI framework captures one, and the continuation returns to the UI thread instead. When we do reach for the pool deliberately, the two entry points differ more than they look: our comparison of Task.Run and Task.Factory.StartNew covers which one to prefer.
When Should We Use Async and When Multithreading?
Ask what the code is waiting for.
If it is waiting on something outside the process (an HTTP call, a database query, a file, a message queue), use async and await all the way down. The thread is released for the duration, which is why a web application handles far more concurrent requests this way than by adding threads.
If it is CPU work (resizing images, parsing a large file, running a simulation), threads are what help, and Task.Run or Parallel.ForEach is how to reach them. Making a CPU-bound method async changes nothing about how long it takes.
Mixed work uses both, and the split is usually clean: fetch asynchronously, process in parallel, write asynchronously.
The one combination to avoid is blocking on asynchronous work: .Result or .Wait() on a task, which holds a thread for the whole wait and can deadlock outright.
| Asynchronous programming | Multithreading | |
|---|---|---|
| The problem it solves | A thread sitting idle while waiting | Not enough work happening at once |
| What it costs | Almost nothing while waiting (no thread is held) | One thread per worker, each with its own stack |
| Typical work | Network calls, database queries, file I/O | Image processing, parsing, computation |
| Written with | async, await, Task | Thread, Task.Run, Parallel, the thread pool |
| Threads used | Possibly one, possibly none while awaiting | One per concurrent worker |
| Scales by | Doing more with the threads we have | Adding threads, bounded by cores |
| Where it fails | CPU-bound work (await on a busy loop helps nothing) | I/O waits (a blocked thread is a wasted thread) |
| Continuation thread | Whichever pool thread is free, unless a SynchronizationContext is captured, in which case the continuation returns to it | The same thread throughout |
| In ASP.NET Core | The default for every request path | Reserved for genuine CPU work |
That last point is worth a closer look, because it is the mistake that turns a correct async method into a hung application. Our article on the difference between await and Task.Wait walks through what each one does to the calling thread.
Conclusion
In this article, we have discussed the key differences between asynchronous programming and multithreading. Both techniques are very useful in improving the scalability and performance of the application.
Asynchrony keeps threads free while we wait, multithreading adds threads so more work happens at once, and most real applications use both. For the broader picture of how the async model developed and the other shapes it takes, our guide to asynchronous programming patterns in .NET is the next step.
Tested with .NET 10.0.10.
