5 min readBy Muhammad Shahid
C# Multithreading Primer for ASP.NET Core
C# multithreading tutorial in one pass — Thread vs ThreadPool vs Task, async is not multithreading, lock and Interlocked, and which primitive article to open next on ASP.NET Core.
Part of Async & Threading
Quick answers
- What is C# multithreading?
- Running work on more than one OS thread or ThreadPool worker at the same time — Task.Run, Parallel, lock, ConcurrentDictionary, Interlocked. Async/await is not multithreading: it frees a worker during I/O. Read Task vs Thread for that split.
- Do I need multithreading in an ASP.NET Core API?
- Usually for short in-memory gates (lock, Interlocked), outbound throttling (SemaphoreSlim), or background workers (Channel + BackgroundService). Request-path SQL and HTTP should be await, not new Thread per call.
- Where do I go after this primer?
- Async checklist for request-path I/O, then the threading primitives track on the async & threading hub — lock, Channel, ConcurrentDictionary, Interlocked each have their own URL.
C# multithreading means more than one thread doing work at the same time — Thread, ThreadPool, Task.Run, Parallel, lock, and concurrent collections. async/await is not multithreading. It is a compiler state machine that gives the ThreadPool worker back while SQL or HTTP waits. Most ASP.NET Core APIs need async I/O first; multithreading primitives second.
Real-world analogy
Think of a restaurant kitchen with a fixed number of cooks (ThreadPool workers):
- Async I/O — a cook starts a sauce, sets a timer, and helps another table while it reduces. The cook is not idle; they are not stuck at one pot.
- Multithreading — you deliberately put a second cook on chopping because one person cannot keep up with CPU work (hashing, image encode).
- Sync-over-async (
.Result) — every cook stands at one pot staring until it boils. The dining room queue grows while the kitchen looks “not busy.”
That is why idle CPU with 504s is usually a queueing problem, not “we need more threads.”
Async (I/O) Multithreading (CPU / gates)
Cook starts SQL ── timer ── free Two cooks chop in parallel
│ │
└── continues when SQL done └── both CPUs busy
New to this → stay here, then Task vs Thread. Merging a PR → wrong vs right. On-call / interview → primitive map · if an interviewer asks.
Terms used here: Thread = OS worker with its own stack. ThreadPool = shared workers Kestrel uses. Task = promise of completion — not a thread. Critical section = a few lines only one thread should run. I/O-bound = waiting on SQL/HTTP. CPU-bound = compute on cores.
Smallest example
// I/O — async, no extra thread during the wait
public async Task<int> CountOrdersAsync(CancellationToken ct) =>
await _db.Orders.CountAsync(ct);
// CPU — offload to the pool (cap this on APIs)
public Task<string> HashAsync(byte[] data, CancellationToken ct) =>
Task.Run(() => Convert.ToHexString(SHA256.HashData(data)), ct);
// In-memory gate — two threads cannot corrupt the counter
private readonly object _gate = new();
private int _published;
void MarkPublished()
{
lock (_gate) { _published++; }
}
Wrong vs right on ASP.NET Core
// Wrong — "multithreaded" SQL on the request path
var rows = Task.Run(() => _db.Orders.ToListAsync()).Result;
// Right — await end to end
var rows = await _db.Orders.ToListAsync(ct);
// Wrong — new Thread per HTTP request
new Thread(() => SendEmail()).Start();
// Right — bounded Channel + BackgroundService
await _notifyChannel.Writer.WriteAsync(orderId, ct);
Wrapping ToListAsync in Task.Run steals a second worker for the same wait. See Task.Run vs await.
Async vs multithreading (one table)
| Question | Async / await | Multithreading |
|---|---|---|
| Primary use | SQL, HTTP, blobs, disk | CPU work, in-memory gates, background queues |
| Holds a worker during I/O? | No — worker is freed | Often yes (Task.Run, lock body) |
| Creates an OS thread per call? | No | new Thread() does — avoid per request |
| Typical API mistake | .Result / .Wait() | Task.Run around EF “to go faster” |
Deep dive: Task vs Thread vs ThreadPool and async/await checklist.
Which article to open next
Use this map after the primer — each URL is one primitive or symptom.
| You need… | Open |
|---|---|
| Request-path async rules | async/await in ASP.NET Core |
Idle CPU, 504s, .Result in a dump | thread pool starvation |
| Short in-memory mutual exclusion | lock vs Monitor |
| Single counter or flag, no lock | Interlocked |
| Thread-safe dictionary, not a cache | ConcurrentDictionary |
Work after Ok(), checkout notify | Channel + BackgroundService |
| Cap outbound HttpClient calls | SemaphoreSlim |
Tenant/user after await | AsyncLocal vs ThreadLocal |
| Interview oral answers | async await interview questions |
Full topic map: async & threading hub.
Common mistakes
- Treating
asyncas “uses more threads” — it usually uses fewer during I/O. Task.Runon every controller action — Kestrel already runs on the pool.lockaround EFSaveChangesorHttpClient— use await and scoped services.ConcurrentDictionaryas a cache with no tenant id in the key — thread-safe is not tenant-safe.Task.Runafterreturn Ok()— use a Channel and a hosted worker.
If an interviewer asks
30-second answer: Multithreading is parallel workers and in-memory synchronization (lock, Interlocked, concurrent collections, Task.Run for CPU). Async is non-blocking I/O — free the worker during waits. On ASP.NET Core, default to async for SQL/HTTP; use threading primitives for gates, background work, and measured CPU offload — not to wrap ToListAsync.
Strong answer: Names the restaurant/kitchen analogy or the starvation symptom (idle CPU, rising queue), refuses .Result and Task.Run around EF on the request path, and points to one primitive article for the follow-up (“for outbound caps I’d use SemaphoreSlim…”).