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
- Asynchrony versus parallelism: two different problems
Thread: .NET's basic threadTask.Run: parallelism with the task APIParallel.ForandParallel.ForEach: parallelizing a loop- Race conditions: the problem of sharing state across threads
lock: protecting a critical section- Concurrent collections: a mention of
ConcurrentDictionary - Example: validating BiblioTech's catalog in parallel
- Closing out Module 6 and the link to Module 7
- 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.
Thread: .NET's basic thread
Thread: .NET's basic threadA 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.
Task.Run: parallelism with the task API
Task.Run: parallelism with the task APITask.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.
Parallel.For and Parallel.ForEach: parallelizing a loop
Parallel.For and Parallel.ForEach: parallelizing a loopWhen 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) |
- 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.
lock: protecting a critical section
lock: protecting a critical sectionlock (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 |
- Concurrent collections: a mention of
ConcurrentDictionary
ConcurrentDictionaryAs 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.
- 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"]
- 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. UsingTask.Runto "make asynchronous" an operation that already has a nativeasyncversion (likeHttpClient.GetAsync) wastes a thread-pool thread unnecessarily. - Modifying a shared collection from
Parallel.ForEachwith no protection:List<T>.Addand 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 thelock, 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 anotherlockon 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 sequentialforeach.
Exercises
-
Write a method
long SumSequential(int count)that sums the numbers from0tocount - 1in a normalforloop, and compare it (withStopwatch, already used in the course to measure timings) against distributing the sum withParallel.Forwhile protecting the shared accumulator withlock. Comment on which version you'd expect to be faster, and why. -
Take
ValidateCatalogInParallelfrom this lesson, but replace thelock-protectedList<ValidationResult>with aConcurrentBag<ValidationResult>(fromSystem.Collections.Concurrent), with no explicitlockneeded. -
Deliberately trigger a race condition: use
Parallel.ForEachover a list of 1000 numbers to increment a shared variableint counterwithcounter++(with nolockorInterlocked), and run the program several times checking that the final result isn't always1000. Then fix it withlock.
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.
C# Programming Course
Module 1: Introduction to C#
- Introduction to C#
- Setting Up the Development Environment
- Hello World Program
- Basic Syntax and Structure
- Variables and Data Types
- Arrays and Strings
Module 2: Control Structures
Module 3: Object-Oriented Programming
- Classes and Objects
- Methods
- Constructors and Destructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- Structs and Records: Value Types and Reference Types
Module 4: Advanced C# Concepts
- Interfaces
- Delegates and Events
- Pattern Matching and Modern C# Features
- Generics
- Collections
- LINQ (Language Integrated Query)
- Asynchronous Programming
Module 5: Working with Data
- File I/O
- Serialization
- Database Connectivity
- Entity Framework
- Working with JSON and Consuming REST APIs
Module 6: Advanced Topics
- Reflection
- Attributes
- Dynamic Programming
- Memory Management and Garbage Collection
- Multithreading and Parallel Programming
Module 7: Building Applications
Module 8: Best Practices and Design Patterns
- Coding Standards and Best Practices
- Design Patterns
- Dependency Injection and Inversion of Control
- Unit Testing
- Code Review and Refactoring
