C# Thread Synchronization
By the end of this lesson you will be able to protect shared data from race conditions using lock and Monitor, recognize and fix a deadlock, and explain why these tools cannot coordinate threads across separate processes.
Objectives
- Explain why concurrent access to shared data must be controlled, using a race condition on a shared counter and a ticket-booking system.
- Define a critical section and identify one in code.
- Use
lockcorrectly and describe what the compiler generates for it. - Use
Monitordirectly (Enter,Exit,TryEnter,Wait,Pulse,PulseAll) and relate it tolock. - Recognize why
lock/Monitorcannot coordinate threads across separate processes, and name the tools (Mutex,Semaphore,SemaphoreSlim,ReaderWriterLockSlim, andSpinLock) reserved for a future session. - Recognize a deadlock caused by two locks acquired in opposite order, and remove it with a consistent acquisition order.
Prerequisites
- Comfort creating a
Thread, passing it a delegate (including as a lambda expression), and usingStart(),Join(),Sleep(), and tracing a thread through itsThreadState. - A working C# console project (.NET SDK) and an editor able to run it.
- Comfort with
try/finallyblocks.
Why Synchronization Is Needed
When two or more threads read and write the same data at the same time, the result depends on the exact order the operating system happened to schedule them: a race condition, where whichever thread writes last silently overwrites the other's work.
Consider a shared ticket counter starting at 10, with Thread A booking 3 tickets and Thread B booking 2, at nearly the same time.
sequenceDiagram
participant TA as Thread A
participant V as AvailableTickets = 10
participant TB as Thread B
TA->>V: Read (10)
TB->>V: Read (10)
TA->>TA: Compute 10 - 3 = 7
TB->>TB: Compute 10 - 2 = 8
TA->>V: Write 7
TB->>V: Write 8 (overwrites A)Both threads read 10 before either writes back. The expected final value is 5, but the actual value is 8, since Thread B's write overwrote Thread A's, and both threads believe their booking succeeded. The bug is invisible in the code and only shows up under an unlucky scheduling order, which is why the output changes between runs. The same problem can affect any shared variable, method, or file.
Critical Section
A critical section is the code that touches a shared resource and must not run on more than one thread at once; in the ticket example it is the block that checks AvailableTickets and updates it. Synchronization protects it: once a thread enters, every other thread that wants in must wait until it leaves. It serves two purposes: atomicity, running the section as one indivisible unit so no thread sees a half-finished update (the ticket example), and ordering, making threads run steps in a specific relative order rather than only protecting data.
The lock Keyword
private static readonly object lockObject = new object();
static void SomeMethod()
{
lock (lockObject)
{
// critical section: only one thread executes this at a time
}
}lockObject is a dedicated object created only to be locked on: private so outside code cannot lock on or replace it, and readonly so it can never be reassigned, which would break the guarantee. A static method needs a static lock object so every call shares the one lock; an instance method can use a private readonly instance field.
A thread reaching lock (lockObject) tries to acquire an exclusive lock: if free, it enters immediately; if held, it waits in a queue until that thread releases it. Which waiting thread goes next is decided by the scheduler, not by the code.
lock and Monitor are the two exclusive-locking tools this lesson covers. A few more names exist for other synchronization needs: Mutex, Semaphore, SemaphoreSlim, ReaderWriterLockSlim, and SpinLock, covered in a future session (see "Where lock and Monitor Reach Their Limit" below).
What lock Compiles To
flowchart TD
A["lock (lockObject) { body }"] --> B["object temp = lockObject;\nbool lockTaken = false;"]
B --> C["try: Monitor.Enter(temp, ref lockTaken)"]
C --> D["run body"]
D --> E["finally block runs unconditionally"]
E --> F{"lockTaken == true?"}
F -->|Yes| G["Monitor.Exit(temp)"]
F -->|No| H["skip Exit: lock was never acquired"]The compiler stores the lock object in a temporary variable and a bool lockTaken (starting false), then wraps the body in try { Monitor.Enter(temp, ref lockTaken); body }. The ref here passes lockTaken by reference, so Monitor.Enter can write back into the caller's own variable instead of into a throwaway local copy; without ref, Monitor.Enter could set its own lockTaken to true internally but the caller would never see that change. Monitor.Enter sets lockTaken to true only for the thread that receives the lock. The finally block calls Monitor.Exit only if lockTaken is true, and since finally always runs, a crashing thread can never leave the lock held forever.
lock therefore gives correct, exception-safe acquire and release automatically; a timeout or signaling between threads needs Monitor directly.
The Monitor Class
Monitor is a static class that synchronizes access to an object. Its basic members are the ones lock already uses:
Monitor.Enter(object): acquires an exclusive lock; starts the critical section.Monitor.Exit(object): releases the lock; ends the critical section. CallingExitis only meaningful after a successfulEnteron the same object, which is why code trackslockTaken(or relies on the compiler-generated check inlock) to know whether it should callExitat all.
Enter/Exit do not wrap themselves in try/finally, so code calling them directly must write that by hand, the same way the compiler does for lock.
Three more Monitor operations that lock cannot express:
TryEnter: acquires the lock with a timeout (milliseconds orTimeSpan), returningtrueif acquired in time orfalseotherwise; a thread that times out simply moves on instead of waiting forever.Wait(object): releases the lock the current thread holds and parks it in a waiting queue until another thread wakes it.Pulse(object): signals one waiting thread that it may try to reacquire the lock; it does not itself release the lock, so the caller must still callWait(or exit the block) for the signaled thread to actually run.PulseAll(object): the same signal asPulse, but sent to every thread in the waiting queue instead of just the next one in line, so all of them re-compete for the lock rather than only one.
stateDiagram-v2
[*] --> ReadyQueue: Monitor.Enter
ReadyQueue --> CriticalSection: Scheduler grants the lock
CriticalSection --> WaitingQueue: Monitor.Wait (after Pulse)
WaitingQueue --> ReadyQueue: Pulsed thread re-competes for the lock
CriticalSection --> [*]: Monitor.ExitWait, Pulse, and PulseAll together let threads hand control back and forth; Exit, by contrast, means the thread is fully done with the lock.
Where lock and Monitor Reach Their Limit
lock and Monitor only coordinate threads inside the same process. A thread your own code creates with new Thread(...) is an internal thread; the thread that runs Main, provided by the operating system the moment the process starts, is an external thread. The same kind of external thread exists in every other running copy of the program, one per process, and none of them can see or be blocked by a lock written into a different process's copy of the code. If you build a console program and launch its compiled executable directly two or three times, each launch is its own process, with its own memory, its own counter, and its own lock object; a lock written into one process's copy of the code has no way to see, let alone block, a thread running inside a different process's copy, because there is no object either side could share to lock on.
This means goals like "only one running copy of this program at a time" or "at most two client threads across all running copies" cannot be built with lock/Monitor alone. Five named tools exist for problems like this and related ones: Mutex, Semaphore, SemaphoreSlim, ReaderWriterLockSlim, and SpinLock. Their constructors, methods, and exact behavior belong to a session beyond this lesson, so none of that detail is covered here. One of the tasks below demonstrates the limit itself, multiple processes that lock cannot reach, without needing any of those five tools.
Deadlock
A deadlock happens when a thread holds one lock while waiting for a second, and another thread holds that second lock while waiting for the first. Neither can proceed; this is a circular wait.
sequenceDiagram
participant TA as Thread A
participant L1 as Lock 1
participant L2 as Lock 2
participant TB as Thread B
TA->>L1: Acquire (success)
TB->>L2: Acquire (success)
TA->>L2: Try acquire (blocked by B)
TB->>L1: Try acquire (blocked by A)
Note over TA,TB: Both wait foreverThis happens specifically when two threads acquire the same two locks in opposite order. Recognizing that shape, two locks, opposite order, is enough to know a deadlock can form. What criteria the .NET runtime or the operating system actually uses to pick a "victim thread" and force it to give one lock back is a separate, more advanced topic left for a future session, and is not explained here. The deadlock task below builds directly on the recognizable shape above: it reproduces the opposite-order deadlock in code, then removes it with a consistent lock-acquisition order across every thread, a general fix that applies to this whole class of bug.
Passing Arguments to a Thread With a Lambda
Thread's constructor accepts a ThreadStart delegate, a method that takes no parameters and returns void. A method like BookTicket(string name, int wantedTickets) needs two parameters, so it cannot be passed to new Thread(...) directly, the same restriction worked around elsewhere with ParameterizedThreadStart and a helper class.
A lambda expression gives a second way around it. () => show.BookTicket("Thread 1", 1) is itself a method that takes no parameters, matching ThreadStart exactly, whose one statement happens to call BookTicket with two fixed arguments. The () => declares "a method taking no parameters"; everything after it is the statement that method runs once the thread starts. The lambda captures show, "Thread 1", and 1 from the surrounding method, so those values are still there for the new thread to use later, even though the surrounding method created them before the thread started running.
Thread t1 = new Thread(() => show.BookTicket("Thread 1", 1));Read as: create a thread whose entry point is a lambda; when started, that lambda calls show.BookTicket with the literal arguments "Thread 1" and 1. Every task from here on that starts a thread on a method taking arguments uses this pattern instead of ParameterizedThreadStart.
Lab Tasks
Each task below keeps its own console project, numbered to match the task: SyncLab1 for Task 1, SyncLab2 for Task 2, SyncLab3 for Task 3, SyncLab4 for Task 4, and SyncLab6 for Task 6 (Task 5 creates no new project; it reuses SyncLab1). Where a step says to rewrite or reuse code "from Task N", create the new project first and paste in the exact code named from Task N's project as a starting point, then apply the change described in that step on top of the copy. SyncLab1 is never touched again once Task 1 finishes; Task 5 rebuilds and runs it exactly as Task 1 left it, so nothing done in Tasks 2 through 4 should change it.
Task 1: Reproduce a Race Condition on a Shared Counter
Step 1. Create a console project named SyncLab1 (dotnet new console -n SyncLab1).
Step 2. Replace Program.cs:
using System;
using System.Threading;
class Program
{
static int counter = 0;
static void Main()
{
// Call the method sequentially first, on the main thread alone
IncrementCounter();
IncrementCounter();
IncrementCounter();
Console.WriteLine($"Sequential result: {counter}");
// Reset, then call the identical method concurrently
counter = 0;
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
Thread t3 = new Thread(IncrementCounter);
t1.Start(); t2.Start(); t3.Start();
t1.Join(); t2.Join(); t3.Join();
Console.WriteLine($"Concurrent result: {counter}");
}
static void IncrementCounter()
{
for (int i = 0; i < 10; i++) counter++;
}
}IncrementCounter increments the shared counter 10 times. Main first calls it three times directly, one call finishing before the next starts, all on the main thread, and prints the result. It then resets counter to 0 and calls the identical method from three separate threads running at the same time; the three Join() calls make the main thread wait for all three children before printing the second result.
Step 3. Run the program five times, noting both printed values after each run.
Expected output. The sequential result always prints Sequential result: 30 (10 + 10 + 10), since one thread runs the three calls one after another with nothing to interleave. The concurrent result is not reliably 30: counter++ reads the current value, adds one, and writes it back as three separate steps, so two threads can read the same value before either writes it back, and an increment is silently lost. Across five runs the concurrent result is usually less than 30 and changes from run to run; exactly which value comes out depends on the unlucky scheduling order for that particular run, so record what your own five runs actually print rather than expecting a fixed number.
Task 2: Protect a Critical Section With lock, and Ticket Booking
Step 1. Create a new project, SyncLab2 (dotnet new console -n SyncLab2), and paste in Task 1's counter field, IncrementCounter, and Main unchanged. From here, work only in SyncLab2; SyncLab1 stays exactly as Task 1 left it for Task 5. In SyncLab2, fix the concurrent path by locking the increment (the sequential calls already gave 30 every time and need no change):
private static readonly object counterLock = new object();
static int counter = 0;
static void IncrementCounter()
{
for (int i = 0; i < 10; i++)
{
lock (counterLock) { counter++; }
}
}Step 2. Run it five times, noting both printed values after each run.
Expected output. Concurrent result: 30 every time, matching the sequential result with no variation, since only one thread at a time can run counter++.
Step 3. Add the ticket-booking scenario. This is a separate demonstration from the counter above: in SyncLab2, remove the counter field, IncrementCounter, and the Main from Steps 1-2, since the Main below replaces them entirely and the two demonstrations do not run together in the same Main. Nothing is lost: Task 3 pastes the locked IncrementCounter fresh into its own project. Add the BookMyShow class:
class BookMyShow
{
private static readonly object lockObject = new object();
public int AvailableTickets = 3;
public void BookTicket(string name, int wantedTickets)
{
lock (lockObject)
{
if (wantedTickets <= AvailableTickets)
{
Console.WriteLine($"{wantedTickets} ticket(s) booked by {name}.");
AvailableTickets -= wantedTickets;
}
else
{
Console.WriteLine($"{name}: not enough tickets available.");
}
}
}
}AvailableTickets is a public field, set directly to 3 where it is declared rather than through a constructor, tracking how many tickets are left. BookTicket is the whole critical section: the if branch is the successful path, enough tickets remain, so it prints a confirmation and deducts wantedTickets; the else branch is the rejection path, printed when there are not enough left. The check and the update happen inside the same lock, so no thread can slip in between them, which is what caused the earlier inconsistency. Replace SyncLab2's Main with the version below, calling BookTicket from three threads, one per booking size, against a pool of 3 tickets total:
static void Main()
{
BookMyShow show = new BookMyShow();
Thread t1 = new Thread(() => show.BookTicket("Thread 1", 1));
Thread t2 = new Thread(() => show.BookTicket("Thread 2", 2));
Thread t3 = new Thread(() => show.BookTicket("Thread 3", 3));
t1.Start(); t2.Start(); t3.Start();
t1.Join(); t2.Join(); t3.Join();
}Thread 1 wants 1 ticket, Thread 2 wants 2, and Thread 3 wants 3; together they want 6 tickets against a pool of 3, so at least one must fail.
Expected output. The total actually booked never exceeds 3, but which threads succeed depends purely on which thread's lock request the scheduler lets through first, not on the code. If Thread 3 happens to go first, it takes all 3 tickets and the other two both fail:
3 ticket(s) booked by Thread 3.
Thread 1: not enough tickets available.
Thread 2: not enough tickets available.If Thread 3 goes last instead, Threads 1 and 2 exactly use up the pool (1 + 2 = 3) and Thread 3 fails:
1 ticket(s) booked by Thread 1.
2 ticket(s) booked by Thread 2.
Thread 3: not enough tickets available.Run it five times to see both patterns turn up across different runs; what never changes is that the booked-ticket counts never sum to more than 3.
Task 3: Use the Monitor Class Directly
Step 1. Create a new project, SyncLab3 (dotnet new console -n SyncLab3), and paste in the counter field, IncrementCounter, and Main exactly as they stood in Task 2 Step 1, before the ticket-booking Main replaced them in SyncLab2. In SyncLab3, rewrite IncrementCounter using Monitor.Enter/Monitor.Exit with a try/finally and lockTaken, acquiring and releasing the lock once per iteration, exactly like Task 2's lock (counterLock) { counter++; }, not once for the whole 10-iteration loop (that would hold the lock the entire time and change what is being measured):
private static readonly object counterLock = new object();
static int counter = 0;
static void IncrementCounter()
{
for (int i = 0; i < 10; i++)
{
bool lockTaken = false;
try
{
Monitor.Enter(counterLock, ref lockTaken);
counter++;
}
finally
{
if (lockTaken) Monitor.Exit(counterLock);
}
}
}It should still print 30 every time, proving lock and explicit Monitor calls behave identically.
Step 2. Add a TryEnter demo: three threads each try to enter one critical section with a one-second timeout, and a thread that gets in holds it for five 100 ms sleeps (about 500 ms):
private static readonly object lockObject = new object();
static void TryEnterDemo(string name)
{
Console.WriteLine($"{name} trying to enter the critical section.");
bool lockTaken = false;
try
{
lockTaken = Monitor.TryEnter(lockObject, TimeSpan.FromSeconds(1));
if (lockTaken)
{
Console.WriteLine($"{name} entered the critical section.");
for (int i = 0; i < 5; i++)
{
Thread.Sleep(100);
}
}
else
{
Console.WriteLine($"{name}: lock was not acquired within the timeout.");
}
}
finally
{
if (lockTaken)
{
Monitor.Exit(lockObject);
Console.WriteLine($"{name} exiting the critical section.");
}
}
}Every thread announces the attempt before calling TryEnter, whether or not it succeeds. A thread that acquires the lock announces entry, holds it through the five-iteration sleep loop, then announces it is exiting; a thread that cannot acquire the lock within one second skips the loop entirely and reports that the lock was not acquired. Replace SyncLab3's Main with the version below, starting three threads calling TryEnterDemo with names "Thread 1", "Thread 2", "Thread 3" (nothing later in the lesson reuses SyncLab3's counter code again):
static void Main()
{
Thread t1 = new Thread(() => TryEnterDemo("Thread 1"));
Thread t2 = new Thread(() => TryEnterDemo("Thread 2"));
Thread t3 = new Thread(() => TryEnterDemo("Thread 3"));
t1.Start(); t2.Start(); t3.Start();
t1.Join(); t2.Join(); t3.Join();
}Expected output. Each ~500 ms hold fits comfortably inside a one-second wait, so the first two threads to reach TryEnter usually both get in one after the other; a third thread arriving after both of those holds have already run typically has no time left in its own one-second wait and reports the timeout instead. A likely run, with Thread 1 and Thread 2 both getting in before Thread 3's window runs out:
Thread 1 trying to enter the critical section.
Thread 2 trying to enter the critical section.
Thread 1 entered the critical section.
Thread 3 trying to enter the critical section.
Thread 1 exiting the critical section.
Thread 2 entered the critical section.
Thread 3: lock was not acquired within the timeout.
Thread 2 exiting the critical section.Which thread ends up locked out varies between runs, since it depends on the exact order the scheduler lets the three TryEnter calls proceed.
Task 4: Coordinate Two Threads With Monitor.Wait and Monitor.Pulse
Step 1. Create a new project, SyncLab4 (dotnet new console -n SyncLab4). Build two methods that must alternate strictly: even numbers and odd numbers, printed in order from 0 through 10.
using System;
using System.Threading;
class Program
{
private static readonly object lockObject = new object();
private const int Limit = 10;
static void Main()
{
Thread evenThread = new Thread(() => PrintSequence(0));
Thread oddThread = new Thread(() => PrintSequence(1));
evenThread.Start();
Thread.Sleep(100); // let 0 print first
oddThread.Start();
evenThread.Join();
oddThread.Join();
Console.WriteLine("Done.");
}
static void PrintSequence(int start)
{
lock (lockObject)
{
for (int i = start; i <= Limit; i += 2)
{
Console.WriteLine(i);
bool isLast = i + 2 > Limit;
Monitor.Pulse(lockObject);
if (!isLast) Monitor.Wait(lockObject);
}
}
}
}Both threads run PrintSequence, one starting at 0 and one at 1. The initial Sleep(100) lets the even thread print 0 first. After printing, each thread calls Pulse to let the other try for the lock, then Wait, releasing the lock and parking itself until pulsed back. isLast matters: on the final iteration a thread must not Wait, since nothing will ever pulse it again. Both Join() calls make the main thread wait for all 11 numbers before printing "Done."
Step 2. Run the program.
Expected output.
0
1
2
3
4
5
6
7
8
9
10
Done.The numbers appear strictly in order despite coming from two separate threads, because Wait/Pulse forces them to hand control back and forth one number at a time.
Task 5: Observe Why lock and Monitor Cannot Reach Across Processes
Step 1. Build Task 1's SyncLab1 project, exactly as Task 1 left it (not SyncLab2, SyncLab3, or SyncLab4, which were rewritten for later tasks), for a standalone executable: from inside SyncLab1, run dotnet build -c Release, then locate the compiled binary under bin/Release/net<version>/ (SyncLab1 on Linux/macOS, SyncLab1.exe on Windows). Find the real value of <version> by checking the <TargetFramework> value in SyncLab1.csproj (for example net8.0), or just list the folder directly.
Step 2. Open three terminals and, at close to the same time, run that compiled binary directly in each one: ./SyncLab1 on Linux/macOS (run chmod +x SyncLab1 first if it refuses to execute) or SyncLab1.exe on Windows. Use the compiled binary, not dotnet run: dotnet run rebuilds the project and locks its own build-output directory each time, so three dotnet run invocations started from the same project folder can end up serialized on that shared lock instead of truly running side by side.
Expected output. Three separate SyncLab1 processes start, each with its own counter beginning at 0 and its own lock object in its own memory. A process listing shows three independent processes running at once, and each prints its own Sequential result and Concurrent result lines on its own schedule, unaffected by what the other two are doing. lock protected the three threads inside one process from each other in Task 2, but it never even sees these other processes: nothing here stops a hundred copies of the same program from running side by side.
Step 3. No code for this step. In one or two sentences, state which single object lock and Monitor need two threads to share in order to coordinate them, and explain why that requirement can never be met by two threads belonging to two different processes launched the way Step 2 did. (This is exactly the gap Mutex, Semaphore, and SemaphoreSlim are named for in the concept sections above; their actual use is left for a later session.)
Expected answer. lock and Monitor both need the two threads to synchronize on the exact same object reference in memory, the way every thread in Task 2 shared the one lock object. Two threads belonging to two different processes launched the way Step 2 did can never share that reference, because each process gets its own separate memory space the moment it starts; nothing created inside one process's memory can be pointed at from another process's memory. With no single object reference for both sides to hold, there is nothing left for either process's lock or Monitor call to synchronize on.
Task 6: Deadlock (Reproduce and Fix)
The rest of this task applies the deadlock definition from the concept sections above hands-on: reproducing the opposite-order deadlock in code, then removing it with a consistent lock-acquisition order.
Step 1. Create a new project, SyncLab6 (dotnet new console -n SyncLab6). Reproduce a deadlock with two locks taken in opposite order:
using System;
using System.Threading;
class Program
{
private static readonly object lockA = new object();
private static readonly object lockB = new object();
static void Main()
{
Thread t1 = new Thread(FirstThenSecond);
Thread t2 = new Thread(SecondThenFirst);
t1.Start(); t2.Start();
t1.Join(); t2.Join();
Console.WriteLine("Both threads finished.");
}
static void FirstThenSecond()
{
lock (lockA)
{
Console.WriteLine("Thread 1 acquired Lock A, waiting for Lock B...");
Thread.Sleep(500);
lock (lockB) { Console.WriteLine("Thread 1 acquired Lock B."); }
}
}
static void SecondThenFirst()
{
lock (lockB)
{
Console.WriteLine("Thread 2 acquired Lock B, waiting for Lock A...");
Thread.Sleep(500);
lock (lockA) { Console.WriteLine("Thread 2 acquired Lock A."); }
}
}
}FirstThenSecond locks lockA then, after a sleep, tries lockB; SecondThenFirst does the opposite. After both sleeps elapse, each thread waits for the lock the other already holds.
Expected output. The two "acquired ... waiting for ..." lines print, then the program hangs forever; Both threads finished. never prints. Stop it manually (Ctrl+C or the IDE stop button).
Step 2. Fix it by making both methods acquire lockA before lockB:
static void SecondThenFirst()
{
lock (lockA)
{
Console.WriteLine("Thread 2 acquired Lock A, waiting for Lock B...");
Thread.Sleep(500);
lock (lockB) { Console.WriteLine("Thread 2 acquired Lock B."); }
}
}Expected output. The program always completes and prints Both threads finished. Whoever acquires lockA first is guaranteed to get lockB next, since the other thread cannot hold lockB without first acquiring lockA; the circular wait can no longer form.
Summary
- A race condition happens when threads read and write shared data without protection; the outcome depends on unpredictable scheduling and differs between runs.
- A critical section is the code touching shared data that must run atomically, one thread at a time.
lock (obj) { ... }compiles to atry/finallyaroundMonitor.Enter/Monitor.Exit, so a lock is always released even if the protected code throws.MonitoraddsTryEnter(acquire with a timeout) andWait/Pulse/PulseAll(a signaling protocol between threads) beyond whatlockalone can express.lock/Monitoronly coordinate threads inside one process; a separate process running the same program is invisible to them, since each process has its own lock object in its own memory.Mutex,Semaphore,SemaphoreSlim,ReaderWriterLockSlim, andSpinLockare named tools for problems that cross that boundary, or that need to admit more than one but fewer than all threads, but this lesson stops at naming them.- A deadlock happens when two threads each hold a lock the other needs, acquired in opposite order; a consistent lock-acquisition order across every thread prevents it.