The Asynchronous Programming lesson (Module 4) introduced async/await for waiting on input/output operations — an HTTP request, a file read — without blocking the thread while it waits. That solves a very specific problem (waiting), but it isn't the same as real parallelism: several CPU threads genuinely executing work at the same time, on different processor cores. This lesson, the last in Module 6, tells the two ideas apart, introduces Thread, Task.Run, and the Parallel library for CPU-intensive work, and exposes the central problem of sharing data across several threads: race conditions, solved with lock. It thereby closes out Advanced Topics before Module 7 finally connects all of BiblioTech's logic to a real interface.

Contents

  1. Asynchrony versus parallelism: two different problems
  2. Thread: .NET's basic thread
  3. Task.Run: parallelism with the task API
  4. Parallel.For and Parallel.ForEach: parallelizing a loop
  5. Race conditions: the problem of sharing state across threads
  6. lock: protecting a critical section
  7. Concurrent collections: a mention of ConcurrentDictionary
  8. Example: validating BiblioTech's catalog in parallel
  9. Closing out Module 6 and the link to Module 7

  1. Asynchrony versus parallelism: two different problems

It's common to confuse async/await with "doing several things at once," but they solve different, if related, problems:

Asynchrony (async/await, Module 4) Parallelism (this lesson)
Problem it solves Waiting on an I/O operation without blocking the thread while it waits Genuinely running CPU work at the same time, across several cores
What happens while waiting The thread is freed up to do other work; no thread is "busy waiting" Each parallel thread actively consumes a CPU core
Example already seen in the course await httpClient.GetAsync(...) (Module 5): waiting on the network Parallel.ForEach over thousands of Books (section 8): computing, not waiting
Operating system threads used Usually one (or very few), reused efficiently Several, potentially as many as the available CPU cores

LendBookAsync (Module 4) uses await Task.Delay(...) to simulate a wait — no CPU core is "working" during that wait, only standing by — whereas the example in section 8 of this lesson distributes real computation (verifying ISBN checksums across thousands of books) across several CPU cores working simultaneously. Both techniques can be combined in a real application, but it's worth being clear about which one solves which problem before reaching for either.

  1. Thread: .NET's basic thread

A Thread (from System.Threading) represents an operating-system thread of execution, managed directly:

using System.Threading;

Thread thread = new Thread(() =>
{
    Console.WriteLine($"Working on thread {Thread.CurrentThread.ManagedThreadId}");
});

thread.Start();
thread.Join(); // waits for the thread to finish before continuing

Console.WriteLine("Thread finished.");

thread.Start() launches the thread, which runs independently of the thread that created it; thread.Join() blocks the current thread until thread finishes — useful when the rest of the program needs to wait for that result before continuing. Thread is the lowest-level API for working with threads in .NET; in practice, modern application code rarely creates a Thread directly, preferring the higher-level abstractions from the following sections (Task.Run, Parallel), which manage a shared thread pool underneath far more efficiently than creating a new Thread for every task.

  1. Task.Run: parallelism with the task API

Task.Run (from the same Task already known from async/await, Module 4) schedules a piece of work to run on a thread-pool thread, with no need to manage a Thread manually:

using System.Threading.Tasks;

Task<int> task = Task.Run(() =>
{
    // CPU-intensive work, not an I/O wait
    int result = 0;
    for (int i = 0; i < 100_000_000; i++)
    {
        result += i % 7;
    }
    return result;
});

int value = await task; // waits for the result without blocking the thread doing the await
Console.WriteLine(value);

Task.Run returns a Task<T>, the same type you already know from async/await; the difference in intent matters: await Task.Delay(...) (Module 4) frees the thread while waiting for something external (there's no CPU work to do), whereas Task.Run(...) actively reserves a thread-pool thread to run real computation. Using Task.Run to "wrap" an I/O wait (like an HttpClient call) would be a design mistake: native asynchronous versions already exist (GetAsync, GetFromJsonAsync) that don't consume a whole thread just to wait.

  1. Parallel.For and Parallel.ForEach: parallelizing a loop

When the work consists of repeating the same, mutually independent operation over many elements of a collection, Parallel.ForEach (and its Parallel.For equivalent for numeric ranges) automatically distributes the iterations across several thread-pool threads, with no need for the programmer to create or coordinate threads manually:

using System.Threading.Tasks;

List<Book> catalog = GetFullCatalog(); // thousands of Books

Parallel.ForEach(catalog, book =>
{
    bool isValid = VerifyIsbnChecksum(book.Isbn); // CPU work per book
    Console.WriteLine($"{book.Isbn}: {(isValid ? "valid" : "invalid")}");
});

Parallel.ForEach internally decides how many threads to use (typically, roughly one per available CPU core) and in what order to distribute the elements — the execution order is not guaranteed, unlike a normal foreach. It's the right tool when each iteration is independent of the others (doesn't depend on the previous iteration's result); if the iterations depend on each other, parallelizing them with Parallel.ForEach would produce incorrect or inconsistent results.

foreach (sequential) Parallel.ForEach
Execution order Guaranteed, one after another Not guaranteed
CPU cores used One Several, in parallel
When it fits Few iterations, or iterations that depend on each other Many independent iterations, each with significant CPU work
Risk if mutable state is shared None (only one execution at a time) Race conditions if not protected (section 5)

  1. Race conditions: the problem of sharing state across threads

A race condition happens when several threads read and write the same shared variable at the same time, with no coordination, and the final result depends on the — non-deterministic — order in which the operating system interleaves their operations:

List<string> sharedResults = new List<string>();

Parallel.ForEach(catalog, book =>
{
    bool isValid = VerifyIsbnChecksum(book.Isbn);
    sharedResults.Add($"{book.Isbn}: {isValid}"); // DANGER: List<T> isn't safe for several threads at once
});

List<T>.Add(...) isn't designed for several threads to call it simultaneously: internally it manages an array and an element count, and if two threads run Add at exactly the same time, they can step on each other — losing an element, corrupting the internal array, or throwing a runtime exception intermittently and hard to reproduce. This kind of bug is especially treacherous because it doesn't always show up: it can work correctly on most runs and fail only occasionally, when the operating system's exact scheduling happens to line up two threads at just the right moment.

  1. lock: protecting a critical section

lock (a C# construct, over an object used as a "padlock") guarantees that only one thread at a time can execute the protected block of code, forcing the rest to wait their turn:

List<string> sharedResults = new List<string>();
object padlock = new object();

Parallel.ForEach(catalog, book =>
{
    bool isValid = VerifyIsbnChecksum(book.Isbn); // CPU work, outside the lock: needs no protection

    lock (padlock)
    {
        sharedResults.Add($"{book.Isbn}: {isValid}"); // critical section: one thread at a time
    }
});

The padlock object (any instance, dedicated exclusively to this purpose, with no other role) acts as a "witness": while one thread is inside the lock (padlock) block, any other thread that tries to enter a lock (padlock) block — the same padlock object — gets blocked, waiting its turn, until the first one finishes. Only the part that modifies shared state (sharedResults.Add) needs lock protection; the computation (VerifyIsbnChecksum) can keep running in parallel with no restriction, since it doesn't touch any shared data — protecting more code than necessary inside the lock wastes the parallelism, forcing threads to wait on each other for no real reason.

Tip about lock Why
Use an object dedicated exclusively to being a padlock (private readonly object _padlock = new object();) Avoids unexpected blocking if some other, external code also happened to lock on the same object by mistake
Protect only the section that actually modifies shared state Maximizes real parallelism; too broad a lock cancels out much of the benefit of parallelizing
Avoid slow work (I/O, waits) inside a lock While one thread waits inside the lock, all the others stay blocked for no reason

  1. Concurrent collections: a mention of ConcurrentDictionary

As an alternative to manually protecting a normal collection with lock, System.Collections.Concurrent offers collections already built internally so several threads can use them at once with no race conditions, such as ConcurrentDictionary<TKey, TValue> or ConcurrentBag<T>:

using System.Collections.Concurrent;

ConcurrentDictionary<string, bool> concurrentResults = new ConcurrentDictionary<string, bool>();

Parallel.ForEach(catalog, book =>
{
    bool isValid = VerifyIsbnChecksum(book.Isbn);
    concurrentResults[book.Isbn] = isValid; // safe with no explicit lock: already managed internally
});

ConcurrentDictionary manages its own internal synchronization, more efficiently than a normal Dictionary<TKey, TValue> protected with lock (in many cases, it lets several threads operate on different parts of the collection simultaneously instead of all blocking each other). It's an alternative worth keeping in mind when the shared state is, precisely, a collection; for other cases (a simple variable, a list with more complex accumulation logic as in section 8), lock remains the more direct and explicit tool.

  1. Example: validating BiblioTech's catalog in parallel

Putting all of the above together, you can parallelize a costly validation operation over the entire Library catalog — checking that each Book's ISBN has a valid format and checksum — accumulating the results safely into a shared list:

using System.Threading.Tasks;

static bool VerifyIsbnChecksum(string isbn)
{
    // Simplified for teaching purposes: sums the ISBN's digits (ignoring hyphens)
    // and checks that the result is a multiple of 10; a real ISBN-13 check follows
    // an alternating-weights algorithm (1 and 3) beyond the scope of this lesson.
    int sum = 0;
    foreach (char character in isbn)
    {
        if (char.IsDigit(character))
        {
            sum += character - '0';
        }
    }
    return sum % 10 == 0;
}
class ValidationResult
{
    public string Isbn { get; }
    public bool IsValid { get; }

    public ValidationResult(string isbn, bool isValid)
    {
        Isbn = isbn;
        IsValid = isValid;
    }
}
static List<ValidationResult> ValidateCatalogInParallel(Library library)
{
    List<ValidationResult> results = new List<ValidationResult>();
    object padlock = new object();

    List<Book> books = library.Catalog.OfType<Book>().ToList(); // OfType, from LINQ (Module 4)

    Parallel.ForEach(books, book =>
    {
        bool isValid = VerifyIsbnChecksum(book.Isbn); // CPU work, outside the lock

        lock (padlock)
        {
            results.Add(new ValidationResult(book.Isbn, isValid)); // critical section
        }
    });

    return results;
}
List<ValidationResult> results = ValidateCatalogInParallel(library);

foreach (ValidationResult result in results)
{
    string status = result.IsValid ? "valid" : "INVALID";
    Console.WriteLine($"{result.Isbn}: {status}");
}

ValidateCatalogInParallel distributes VerifyIsbnChecksum — the CPU work, independent for each Book — across several threads with Parallel.ForEach, and protects only the moment of adding each result to the shared results list with lock (padlock). With a catalog of thousands of books and a validation calculation more expensive than this teaching simplification, this distribution across CPU cores can noticeably cut the total time compared to validating the catalog one by one with a sequential foreach.

flowchart TD
    A["ValidateCatalogInParallel(library)"] --> B["Parallel.ForEach over each Book"]
    B --> C1["Thread 1: VerifyIsbnChecksum"]
    B --> C2["Thread 2: VerifyIsbnChecksum"]
    B --> C3["Thread N: VerifyIsbnChecksum"]
    C1 --> D["lock (padlock): results.Add(...)"]
    C2 --> D
    C3 --> D
    D --> E["Complete List of ValidationResult"]

  1. Closing out Module 6 and the link to Module 7

This lesson closes out Module 6 (Advanced Topics): reflection, attributes, dynamic, memory management, and multithreading are infrastructure and performance tools that operate underneath BiblioTech's business logic, without changing what Book, Member, or Loan represent as a domain. Module 7 (Building Applications) takes the next step: finally connecting all of that domain — including this section's parallel validation, or the asynchronous operations from Modules 4 and 5 — to a real interface, whether desktop (Windows Forms, WPF), web (ASP.NET Core, Blazor), or mobile (Xamarin, .NET MAUI). There, patterns like Parallel.ForEach will need to be combined carefully with each interface type's own constraints (for example, updating a visual control only from its UI thread, never directly from a parallel thread), a nuance that will be picked up in due course.

Common Mistakes and Tips

  • Confusing asynchrony with parallelism: async/await (Module 4) frees a thread while waiting on I/O; it doesn't distribute work across several CPU cores. Using Task.Run to "make asynchronous" an operation that already has a native async version (like HttpClient.GetAsync) wastes a thread-pool thread unnecessarily.
  • Modifying a shared collection from Parallel.ForEach with no protection: List<T>.Add and similar structures aren't safe for concurrent calls from several threads; the result is, at best, an intermittent exception and, at worst, silently corrupted data.
  • Protecting more code than necessary with lock: including the CPU work (VerifyIsbnChecksum) inside the lock, instead of just the write to the shared list, in effect serializes the whole operation and cancels out much of the benefit of parallelizing.
  • Using different objects as the padlock in different parts of the code that protect the same data: lock (padlock) only blocks against another lock on the same object; if two code blocks use different padlocks to protect the same list, they don't protect each other at all.
  • Tip: before parallelizing a loop with Parallel.ForEach, check that each iteration's work is genuinely CPU-expensive and truly independent of the rest; for small collections or trivial work, the overhead of coordinating several threads can actually make the parallel version slower than a simple sequential foreach.

Exercises

  1. Write a method long SumSequential(int count) that sums the numbers from 0 to count - 1 in a normal for loop, and compare it (with Stopwatch, already used in the course to measure timings) against distributing the sum with Parallel.For while protecting the shared accumulator with lock. Comment on which version you'd expect to be faster, and why.

  2. Take ValidateCatalogInParallel from this lesson, but replace the lock-protected List<ValidationResult> with a ConcurrentBag<ValidationResult> (from System.Collections.Concurrent), with no explicit lock needed.

  3. Deliberately trigger a race condition: use Parallel.ForEach over a list of 1000 numbers to increment a shared variable int counter with counter++ (with no lock or Interlocked), and run the program several times checking that the final result isn't always 1000. Then fix it with lock.

Solutions

using System.Diagnostics;

static long SumSequential(int count)
{
    long sum = 0;
    for (int i = 0; i < count; i++)
    {
        sum += i;
    }
    return sum;
}

static long SumParallel(int count)
{
    long sum = 0;
    object padlock = new object();

    Parallel.For(0, count, i =>
    {
        lock (padlock)
        {
            sum += i;
        }
    });

    return sum;
}

Stopwatch stopwatch = Stopwatch.StartNew();
long sequentialResult = SumSequential(50_000_000);
Console.WriteLine($"Sequential: {stopwatch.ElapsedMilliseconds} ms");

stopwatch.Restart();
long parallelResult = SumParallel(50_000_000);
Console.WriteLine($"Parallel with lock: {stopwatch.ElapsedMilliseconds} ms");

// In this particular case, the parallel version is probably NOT faster: the work inside
// each iteration (a simple addition) is too small compared to the cost of synchronizing
// the lock on every iteration; the lock effectively turns the sum back into a sequential one.
using System.Collections.Concurrent;

static ConcurrentBag<ValidationResult> ValidateCatalogInParallelConcurrentBag(Library library)
{
    ConcurrentBag<ValidationResult> results = new ConcurrentBag<ValidationResult>();
    List<Book> books = library.Catalog.OfType<Book>().ToList();

    Parallel.ForEach(books, book =>
    {
        bool isValid = VerifyIsbnChecksum(book.Isbn);
        results.Add(new ValidationResult(book.Isbn, isValid)); // safe with no explicit lock
    });

    return results;
}
List<int> numbers = Enumerable.Range(0, 1000).ToList(); // Enumerable.Range, from LINQ (Module 4)
int counter = 0;

Parallel.ForEach(numbers, _ =>
{
    counter++; // NO protection: race condition
});

Console.WriteLine(counter); // often different from 1000 across different runs

// Fix with lock:
int fixedCounter = 0;
object padlock = new object();

Parallel.ForEach(numbers, _ =>
{
    lock (padlock)
    {
        fixedCounter++;
    }
});

Console.WriteLine(fixedCounter); // always 1000

Conclusion

In this lesson you've told asynchrony (waiting on I/O without blocking, Module 4) apart from real parallelism (several CPU threads working at the same time), and used Thread, Task.Run, and Parallel.For/Parallel.ForEach to distribute CPU work across several cores. You've also seen the problem of race conditions when sharing mutable state across threads, how lock protects a critical section without sacrificing the parallelism of the rest of the work, and the alternative of concurrent collections like ConcurrentDictionary. The parallel-validation example over BiblioTech's catalog brings all these pieces together on real domain data from the course.

This closes out Module 6 (Advanced Topics) in full: reflection, attributes, dynamic, memory management, and multithreading. Module 7 (Building Applications) now picks up everything built across the six previous modules — BiblioTech's domain with LibraryItem, Book, Magazine, Member, Loan, and Library, its persistence in text, JSON, SQLite, and Entity Framework, its communication with external services, and this module's advanced techniques — and connects it, for the first time in the course, to a real user interface: desktop applications with Windows Forms and WPF, web applications with ASP.NET Core and Blazor, and mobile applications with Xamarin and .NET MAUI.

© Copyright 2026. All rights reserved