Synchronization in C#
By the Syncfusion DevTutorials team · Last updated
Keep shared data safe: race conditions, critical sections, the lock statement, Interlocked, deadlocks, thread safety and synchronization strategies.
Introduction to Synchronization
Think about a shared whiteboard in an office, with a counter: "Cups of coffee made today: 41".
Asha makes a coffee. She reads 41, walks to the marker, and writes 42. At the same moment Ravi also makes a coffee. He also read 41, so he also writes 42.
Two coffees were made, but the board went up by only one. A count was lost.
Threads have the same problem. They share memory. When two threads change the same piece of data at the same time, they can overwrite each other's changes.
Synchronization is a set of rules that prevents this: only one thread is allowed to touch the data at a time. Think of it like only one person can hold a marker at a time.
In simple words: synchronization makes threads take turns when they access shared data.
Easy Way to Remember: shared data needs a talking stick. Only the thread holding the stick may change the data.
What You Will Learn
- What a race condition is, and why it happens.
- What a critical section is.
- The
lockstatement. Interlocked, for fast and safe counters.- What a deadlock is, and how to avoid one.
- What "thread-safe" means.
- How to choose a synchronization strategy.
About the Run buttons in this lesson. The browser playground runs code with only one thread, so real race conditions cannot happen. To show what goes wrong, some examples use await Task.Yield() to pause in the middle of an operation. This pause creates the same problem that two real threads would cause on a real computer. Examples that need real threads, such as deadlocks, are shown as code without a Run button.
Start With a Working Example
Press Run.
Lost updates, then a lock fixes them
using System;
using System.Linq;
using System.Threading.Tasks;
int unsafeCount = 0;
int safeCount = 0;
object gate = new object();
await Task.WhenAll(Enumerable.Range(0, 100).Select(_ => AddUnsafeAsync()));
await Task.WhenAll(Enumerable.Range(0, 100).Select(_ => AddSafeAsync()));
Console.WriteLine($"Without lock: {unsafeCount} (expected 100)");
Console.WriteLine($"With lock: {safeCount} (expected 100)");
async Task AddUnsafeAsync()
{
int read = unsafeCount; // 1. read
await Task.Yield(); // 2. someone else runs here
unsafeCount = read + 1; // 3. write back an old value + 1
}
async Task AddSafeAsync()
{
await Task.Yield();
lock (gate) // only one at a time inside
{
safeCount = safeCount + 1;
}
}
The "without lock" number changes each time you run this on a real computer. It is almost never 100. The lock version is always correct.
Try This
Move await Task.Yield(); in AddUnsafeAsync to the first line, before the read. Now each read and write happen one right after the other, and the count appears correct in the browser. On a real computer with multiple cores and real threads, it can still go wrong. This shows that "it worked when I tried it" does not mean the code is actually safe.
Race Conditions
A race condition happens when the result depends on which thread runs first.
count++ looks like one step, but the computer actually does it in three separate steps:
- Read the current value from memory.
- Add one to it.
- Write the new value back.
If two threads do this at the same time, the steps can be mixed together:
| Step | Thread A | Thread B | count in memory |
|---|---|---|---|
| 1 | reads 41 | 41 | |
| 2 | reads 41 | 41 | |
| 3 | writes 42 | 42 | |
| 4 | writes 42 | 42 |
Two increments happened, but the value only increased by one. One update was lost.
Read, then write: one update disappears
using System;
using System.Threading.Tasks;
int count = 41;
Task a = IncrementAsync("A");
Task b = IncrementAsync("B");
await Task.WhenAll(a, b);
Console.WriteLine($"Final count: {count} (two increments should give 43)");
async Task IncrementAsync(string name)
{
int read = count;
Console.WriteLine($"Thread {name} read {read}");
await Task.Yield();
count = read + 1;
Console.WriteLine($"Thread {name} wrote {count}");
}
Race conditions are hard to find because they depend on timing. A program can pass all tests and still lose data once a week on a busy server.
Critical Sections
A critical section is a piece of code that accesses shared data. This code must never run from two threads at the same time.
In the coffee example, "read the board, add one, write the board" is the critical section. All three steps must happen together, as one unit, with no one else in between.
Examples of critical sections:
- updating a shared counter or total,
- adding to or removing from a shared
ListorDictionary, - checking a value and then acting on it ("if the seat is free, book it"),
- updating several related fields that must stay consistent together (balance and transaction history).
Easy Way to Remember: a critical section is a one-person room. Lock the door when you enter, unlock it when you leave.
Keep critical sections small. Every other thread that needs to enter has to wait. Long critical sections make a multi-threaded program run like a single-threaded one.
lock Statement
The lock statement creates a critical section. Only one thread at a time can run inside a lock block for that lock object. Other threads wait at the door until it is free.
private readonly object _gate = new object();
lock (_gate)
{
// only one thread at a time runs this
}
Rules for the lock object:
- Use a private, readonly object made just for locking.
- Every piece of code that touches the same data must use the same lock object.
- Never lock on
this, astring, or aType. Other code might also lock on the same thing, and that could cause problems.
A bank account that is safe to share
using System;
using System.Linq;
using System.Threading.Tasks;
var account = new BankAccount(1000);
Task[] deposits = Enumerable.Range(0, 50).Select(_ => Task.Run(() => account.Deposit(10))).ToArray();
Task[] withdrawals = Enumerable.Range(0, 50).Select(_ => Task.Run(() => account.TryWithdraw(10))).ToArray();
await Task.WhenAll(deposits.Concat(withdrawals));
Console.WriteLine($"Balance: {account.Balance}");
Console.WriteLine($"History entries: {account.HistoryCount}");
class BankAccount
{
private readonly object _gate = new object();
private decimal _balance;
private int _history;
public BankAccount(decimal opening) => _balance = opening;
public decimal Balance { get { lock (_gate) return _balance; } }
public int HistoryCount { get { lock (_gate) return _history; } }
public void Deposit(decimal amount)
{
lock (_gate)
{
_balance += amount;
_history++;
}
}
public bool TryWithdraw(decimal amount)
{
lock (_gate)
{
if (_balance < amount) return false; // check...
_balance -= amount; // ...and act, together
_history++;
return true;
}
}
}
TryWithdraw checks the balance and takes the money inside the same lock block. If the check and the withdrawal were in separate locks, two threads could both see enough money and both withdraw from the account.
You cannot use await inside a lock block; the compiler will reject it. A lock belongs to a specific thread, and after an await, the method may continue running on a different thread. For async code that needs synchronization, use SemaphoreSlim with await gate.WaitAsync() (shown in Chapter 1, lesson 2).
lock is shorthand for Monitor.Enter and Monitor.Exit with a try/finally block. This means the lock is always released, even if an error occurs inside the lock block.
Interlocked
For a single number, a full lock is more power than you need. The Interlocked class does simple number updates as one unbreakable step, called an atomic operation. Nothing can interrupt it in the middle.
| Method | What it does atomically |
|---|---|
Interlocked.Increment(ref x) | x++, returns the new value |
Interlocked.Decrement(ref x) | x-- |
Interlocked.Add(ref x, n) | x += n |
Interlocked.Exchange(ref x, v) | Sets x = v, returns the old value |
Interlocked.CompareExchange(ref x, v, expected) | Sets x = v only if x == expected |
A page-view counter with Interlocked
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
int views = 0;
long bytesSent = 0;
await Task.WhenAll(Enumerable.Range(0, 1000).Select(_ => Task.Run(() =>
{
Interlocked.Increment(ref views);
Interlocked.Add(ref bytesSent, 512);
})));
Console.WriteLine($"Page views: {views}");
Console.WriteLine($"Bytes sent: {bytesSent}");
int oldValue = Interlocked.Exchange(ref views, 0);
Console.WriteLine($"Reset the counter. It was {oldValue}, now {views}");
lock | Interlocked | |
|---|---|---|
| Protects | Any amount of code and data | One number |
| Speed | Fast | Fastest |
| Multiple fields together | Yes | No |
| Good for | Lists, balances with history, check-then-act | Counters, totals, flags |
Deadlocks
A deadlock happens when two threads each hold a lock that the other one needs, and both wait forever.
Imagine two people in a narrow corridor. Each waits for the other to move backwards. Neither moves. They stand there forever.
object lockA = new object();
object lockB = new object();
// Thread 1
lock (lockA)
{
Thread.Sleep(100);
lock (lockB) { } // waits for Thread 2 to release B
}
// Thread 2
lock (lockB)
{
Thread.Sleep(100);
lock (lockA) { } // waits for Thread 1 to release A
}
// Thread 1 holds A and waits for B. Thread 2 holds B and waits for A. Forever.
The program does not crash. It just freezes. This makes deadlocks hard to notice and difficult to debug.
How to avoid deadlocks:
| Rule | How it helps |
|---|---|
| Always take locks in the same order | If every thread takes A before B, no thread can hold B while waiting for A |
| Hold one lock at a time when possible | No second lock means no circle of waiting |
| Keep critical sections short | Shorter critical sections mean fewer lock conflicts |
| Never call unknown code (events, callbacks) while holding a lock | That code might try to take another lock you don't know about |
Use Monitor.TryEnter with a timeout | Give up and try again instead of waiting forever |
| Do not wait for async code inside a lock | .Result inside a lock is a common deadlock pattern |
The fix for the example is simple: both threads take lockA first, then lockB.
// Thread 2, fixed: same order as Thread 1
lock (lockA)
{
lock (lockB) { }
}
Thread Safety
Code is thread-safe when it works correctly even if many threads use it at the same time.
| Kind of code | Thread-safe? |
|---|---|
| Local variables inside a method | Yes, each method call has its own |
Immutable data (string, record with init, readonly fields set once) | Yes, nobody can change it |
List<T>, Dictionary<K,V> shared between threads | No |
static fields changed by several threads | No, unless locked |
ConcurrentDictionary, ConcurrentQueue (lesson 6) | Yes |
Interlocked operations | Yes |
The safest shared data is data that never changes. If nothing can change it, there is nothing to protect.
A plain List loses items; a locked one does not
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
var unsafeList = new List<int>();
var safeList = new List<int>();
object gate = new object();
await Task.WhenAll(Enumerable.Range(0, 200).Select(async i =>
{
var copy = unsafeList.ToList(); // read the list...
await Task.Yield(); // ...another worker runs here...
copy.Add(i);
unsafeList.Clear(); // ...then write back an old version
unsafeList.AddRange(copy);
}));
await Task.WhenAll(Enumerable.Range(0, 200).Select(async i =>
{
await Task.Yield();
lock (gate) safeList.Add(i);
}));
Console.WriteLine($"Unsafe list has all 200 items: {unsafeList.Count == 200}");
Console.WriteLine($"Locked list has all 200 items: {safeList.Count == 200}");
On a real computer, adding to a List<T> from multiple threads at the same time can throw an error or corrupt the list's internal structure. Always protect shared collections with a lock, or use the concurrent collections shown in lesson 6.
Synchronization Strategies
There are several ways to keep shared data safe. Try the strategies at the top of this list first. Each step down adds more complexity.
| Strategy | Idea | Example |
|---|---|---|
| Don't share | Each thread works on its own data, then combine at the end | Each worker sums its own chunk (lesson 4) |
| Immutable data | Data that never changes needs no protection | record Point(int X, int Y) |
| Concurrent collections | Use a collection that handles locking for you | ConcurrentDictionary (lesson 6) |
Interlocked | Safe updates to one number | Counters, totals |
lock | One thread at a time in a critical section | Balance plus history |
SemaphoreSlim | At most N threads at a time, works with await | Limit to 3 downloads |
ReaderWriterLockSlim | Allow many readers or one writer | A cache that is read often and updated rarely |
The first strategy is often the best. Here each worker keeps its own total, and only the final combine step touches shared data:
Don't share: each worker has its own total
using System;
using System.Linq;
using System.Threading.Tasks;
int[] orders = Enumerable.Range(1, 1000).ToArray();
Task<int>[] workers = Enumerable.Range(0, 4).Select(w => Task.Run(() =>
{
int myTotal = 0; // private to this worker: no lock needed
for (int i = w; i < orders.Length; i += 4)
myTotal += orders[i];
return myTotal;
})).ToArray();
int[] totals = await Task.WhenAll(workers);
Console.WriteLine($"Worker totals: {string.Join(", ", totals)}");
Console.WriteLine($"Grand total: {totals.Sum()}");
No locks, no races, and no waiting. The fastest synchronization is the one you never need to use.
Before adding a lock, ask: "Does this data really need to be shared?" Very often the answer is no. Splitting the data is both safer and faster.
Common mistakes
| Mistake | Wrong | Correct | Why |
|---|---|---|---|
| Unprotected counter | count++; | Interlocked.Increment(ref count); | ++ reads, adds, writes: updates get lost |
| Locking on a public object | lock (this) | lock (_gate) | Other code can lock the same object |
| Different lock objects | Two methods lock different objects | Lock the same _gate everywhere | Different locks do not stop each other |
| Check and act in separate steps | if (seats > 0) then later seats-- | Both inside one lock | Another thread can act in between |
| Locks taken in different orders | A then B, and B then A | Always A then B | Opposite orders cause deadlocks |
await inside lock | lock (g) { await SaveAsync(); } | await semaphore.WaitAsync(); | Does not compile; locks belong to a thread |
| Long work inside a lock | Downloading inside lock | Download first, lock only to store | Everyone else waits the whole time |
Shared List<T> | list.Add(x) from many threads | Lock it, or use a concurrent collection | List<T> is not thread-safe |
What to Remember
- Threads share memory, so two threads can change the same data at the same time.
- A race condition is a bug that depends on timing;
count++from several threads loses updates. - A critical section is code that touches shared data and must run one thread at a time.
lock (_gate) { ... }makes a critical section. Use a private readonly object and keep the block short.Interlockedmakes simple number updates atomic and is faster thanlock.- A deadlock is two threads waiting for each other forever; take locks in the same order to avoid it.
- Thread-safe code works correctly when used by many threads at once.
- Best strategies first: don't share, use immutable data, use concurrent collections, then
Interlocked, thenlock.
Next: Concurrent Collections