Skip to main content

📝 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.Run to offload CPU-bound work
  • Choose between Task.Run (CPU-bound) and async/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.

graph TD P["Process (your app)"] --> T1["Thread 1 (Main)"] P --> T2["Thread 2"] P --> T3["Thread 3"] T1 --> C["Shared memory
(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.

graph LR Q["Work items queue"] --> POOL["Thread pool"] POOL --> W1["Worker A"] POOL --> W2["Worker B"] POOL --> W3["Worker C"] W1 -->|"reused"| POOL W2 -->|"reused"| POOL style Q fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style POOL fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

✅ 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 defaultTask.Run, async continuations, 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-boundI/O-bound
BottleneckThe processor is busy computingWaiting on disk, network, database
ExamplesMath, image processing, sortingWeb requests, file reads, DB queries
Right toolTask.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, use async/await and 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:

  1. Create a new project called Threads.
  2. Write bool IsPrime(int n) and int CountPrimes(int start, int end) (a CPU-bound loop).
  3. Count primes from 2 to 500,000 sequentially and time it (use System.Diagnostics.Stopwatch).
  4. Split the range into 4 chunks, run each with Task.Run, await Task.WhenAll, and sum the results. Time this too.
  5. 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.Run schedules CPU-bound work on the pool and returns a Task you can await and combine with Task.WhenAll.
  • CPU-bound → Task.Run; I/O-bound → async/await. Never wrap I/O in Task.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.