📝 Lesson 2.1: Threads, the Thread Pool, and Tasks
Modern machines have many CPU cores. To use them, your program runs work on multiple threads. This lesson builds the mental model — from raw threads, to the thread pool, to the Task abstraction you'll actually use.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Distinguish concurrency from parallelism, and a process from a thread
- Create a raw thread — and explain why you rarely should
- Explain what the thread pool is and why it exists
- Use
Task.Runto offload CPU-bound work - Choose between
Task.Run(CPU-bound) andasync/await(I/O-bound)
Estimated Time: 75 minutes
Project: Offload CPU-heavy work to background threads and combine the results.
In This Lesson
Concurrency vs. Parallelism
These related terms are worth pinning down precisely:
📖 Definitions
Concurrency: dealing with many tasks at once by interleaving them (they make progress in overlapping time periods — even on one core).
Parallelism: literally doing many things at the same instant, on multiple CPU cores.
Thread: an independent path of execution within a process. A process starts with one thread (running Main) and can spawn more.
An analogy: one chef juggling three dishes by switching between them is concurrency; three chefs each cooking a dish simultaneously is parallelism. Threads are the chefs; cores are the stoves.
(heap)"] T2 --> C T3 --> C style P fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style C fill:#fff3cd,stroke:#ffc107,stroke-width:2px
Crucially, threads in a process share the same memory (the heap). That's what makes them powerful — and dangerous, as we'll see in Lesson 2.2 on thread safety.
Raw Threads
The lowest-level tool is the Thread class. You give it a method to run, Start() it, and optionally Join() to wait for it:
using System.Threading;
Thread worker = new Thread(() =>
{
for (int i = 0; i < 3; i++)
{
Console.WriteLine($"Worker: {i}");
Thread.Sleep(100);
}
});
worker.Start(); // begins running on a new thread
Console.WriteLine("Main continues...");
worker.Join(); // block until 'worker' finishes
Console.WriteLine("Worker done.");
⚠️ Raw threads are expensive — rarely the right tool
Each Thread consumes about 1 MB of stack and takes real time for the OS to create and tear down. Spinning up a thread per task doesn't scale — creating thousands would cripple your app. Raw threads are appropriate only for long-running, dedicated background work. For everything else, use the thread pool (below).
💡 Foreground vs. background threads
By default a Thread is a foreground thread — the process stays alive until it finishes. Set worker.IsBackground = true to make it a background thread that won't keep the app running. Thread-pool threads (and tasks) are always background.
The Thread Pool
Creating and destroying threads is costly, but most tasks are short. The thread pool solves this: .NET keeps a managed pool of reusable worker threads. You hand it work; it runs that work on an available thread, then returns the thread to the pool for reuse.
✅ Why the pool wins
- No per-task thread cost — threads are reused, not recreated.
- Automatic sizing — the pool grows and shrinks based on load and core count.
- It's the default —
Task.Run,asynccontinuations, and parallel loops all use it.
You could queue work directly (ThreadPool.QueueUserWorkItem), but that API is low-level and gives you no easy way to get a result or know when it's done. That's exactly the gap Task fills.
Task.Run
A Task (from Intermediate async) is a high-level handle to work that's running or will run. Task.Run schedules a delegate to execute on the thread pool and hands you a Task to track it — the modern, preferred way to push CPU-bound work off the current thread:
// Offload a CPU-heavy computation to a pool thread
Task<long> work = Task.Run(() =>
{
long sum = 0;
for (int i = 0; i < 1_000_000; i++) sum += i;
return sum;
});
Console.WriteLine("Main thread stays responsive...");
long result = await work; // await the result (Lesson 4.3)
Console.WriteLine($"Sum = {result}");
Because Task.Run returns a Task<T>, everything you learned about await, Task.WhenAll, and exceptions applies. Run several CPU-bound jobs across cores at once:
Task<long>[] jobs =
{
Task.Run(() => Compute(0, 250_000)),
Task.Run(() => Compute(250_000, 500_000)),
Task.Run(() => Compute(500_000, 750_000)),
Task.Run(() => Compute(750_000, 1_000_000))
};
long[] partials = await Task.WhenAll(jobs); // run on multiple cores
Console.WriteLine($"Total = {partials.Sum()}");
static long Compute(int start, int end)
{
long sum = 0;
for (int i = start; i < end; i++) sum += i;
return sum;
}
💡 Task vs. Thread
A Thread is a low-level OS resource you manage directly. A Task is a higher-level abstraction over the thread pool that also carries a result, exceptions, continuations, and cancellation. In modern C#, reach for Task (and Task.Run) — drop to raw Thread only for special long-running cases.
CPU-bound vs. I/O-bound
This distinction decides which tool to use, and it's the single most important idea in the module.
| CPU-bound | I/O-bound | |
|---|---|---|
| Bottleneck | The processor is busy computing | Waiting on disk, network, database |
| Examples | Math, image processing, sorting | Web requests, file reads, DB queries |
| Right tool | Task.Run (use a thread) | async/await (no extra thread) |
⚠️ Don't wrap I/O in Task.Run
For I/O, use the naturally async API (await httpClient.GetAsync(...), await File.ReadAllTextAsync(...)). These free the thread while waiting — no thread is consumed during the wait. Wrapping an I/O call in Task.Run just wastes a pool thread to sit and block; it doesn't make anything faster.
💡 The rule: If the work keeps a CPU busy, give it a thread (Task.Run). If the work is waiting for something external, useasync/awaitand don't burn a thread on the wait. Threads are precious — spend them only on actual computation.
✅ They compose
Real apps mix both: await an I/O call to fetch data, then Task.Run a CPU-heavy transformation on it — all coordinated with await and Task.WhenAll.
Exercise & Quiz
🏋️ Exercise: Parallel Prime Counting
Objective: Offload CPU-bound work to the thread pool and combine results.
Instructions:
- Create a new project called
Threads. - Write
bool IsPrime(int n)andint CountPrimes(int start, int end)(a CPU-bound loop). - Count primes from 2 to 500,000 sequentially and time it (use
System.Diagnostics.Stopwatch). - Split the range into 4 chunks, run each with
Task.Run,await Task.WhenAll, and sum the results. Time this too. - Compare the two timings and observe the speedup on a multi-core machine.
Starter Code:
using System.Diagnostics;
// Sequential
var sw = Stopwatch.StartNew();
int total = CountPrimes(2, 500_000);
sw.Stop();
Console.WriteLine($"Sequential: {total} primes in {sw.ElapsedMilliseconds} ms");
// TODO: parallel with 4 Task.Run chunks + Task.WhenAll, then time and compare
static bool IsPrime(int n)
{
if (n < 2) return false;
for (int i = 2; (long)i * i <= n; i++)
if (n % i == 0) return false;
return true;
}
static int CountPrimes(int start, int end)
{
int count = 0;
for (int i = start; i < end; i++)
if (IsPrime(i)) count++;
return count;
}
💡 Hint
Create four tasks like Task.Run(() => CountPrimes(2, 125_000)) covering consecutive ranges up to 500,000, then int[] parts = await Task.WhenAll(...) and parts.Sum(). Wrap in a fresh Stopwatch to compare.
✅ Solution
using System.Diagnostics;
var sw = Stopwatch.StartNew();
int total = CountPrimes(2, 500_000);
sw.Stop();
Console.WriteLine($"Sequential: {total} primes in {sw.ElapsedMilliseconds} ms");
sw.Restart();
Task<int>[] tasks =
{
Task.Run(() => CountPrimes(2, 125_000)),
Task.Run(() => CountPrimes(125_000, 250_000)),
Task.Run(() => CountPrimes(250_000, 375_000)),
Task.Run(() => CountPrimes(375_000, 500_000))
};
int[] parts = await Task.WhenAll(tasks);
sw.Stop();
Console.WriteLine($"Parallel: {parts.Sum()} primes in {sw.ElapsedMilliseconds} ms");
static bool IsPrime(int n)
{
if (n < 2) return false;
for (int i = 2; (long)i * i <= n; i++)
if (n % i == 0) return false;
return true;
}
static int CountPrimes(int start, int end)
{
int count = 0;
for (int i = start; i < end; i++)
if (IsPrime(i)) count++;
return count;
}
On a multi-core machine the parallel version finishes noticeably faster (often 2–4×). Both report the same prime count.
🎯 Quick Quiz
Question 1: What's the difference between concurrency and parallelism?
Question 2: Why prefer the thread pool (via Task.Run) over creating raw threads per task?
Question 3: For an I/O-bound operation like a web request, you should…
Summary
🎉 Key Takeaways
- Concurrency interleaves tasks; parallelism runs them at once on multiple cores. Threads in a process share memory.
- Raw
Threads are expensive (≈1 MB each, slow to create) — reserve them for long-running dedicated work. - The thread pool reuses a managed set of worker threads; it's the default for tasks, async continuations, and parallel loops.
Task.Runschedules CPU-bound work on the pool and returns aTaskyou canawaitand combine withTask.WhenAll.- CPU-bound →
Task.Run; I/O-bound →async/await. Never wrap I/O inTask.Run.
📚 Additional Resources
🚀 What's Next?
Multiple threads sharing memory is powerful — and perilous. In Lesson 2.2: Thread Safety and Synchronization, you'll see how concurrent access corrupts data, and learn lock, Interlocked, and concurrent collections to prevent it.
🎉 Threads demystified!
You know how work runs across cores. Next, we make shared data safe.