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

  1. What a race condition is, and why it happens.
  2. What a critical section is.
  3. The lock statement.
  4. Interlocked, for fast and safe counters.
  5. What a deadlock is, and how to avoid one.
  6. What "thread-safe" means.
  7. 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:

  1. Read the current value from memory.
  2. Add one to it.
  3. Write the new value back.

If two threads do this at the same time, the steps can be mixed together:

StepThread AThread Bcount in memory
1reads 4141
2reads 4141
3writes 4242
4writes 4242

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 List or Dictionary,
  • 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, a string, or a Type. 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.

MethodWhat 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}");
lockInterlocked
ProtectsAny amount of code and dataOne number
SpeedFastFastest
Multiple fields togetherYesNo
Good forLists, balances with history, check-then-actCounters, 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:

RuleHow it helps
Always take locks in the same orderIf every thread takes A before B, no thread can hold B while waiting for A
Hold one lock at a time when possibleNo second lock means no circle of waiting
Keep critical sections shortShorter critical sections mean fewer lock conflicts
Never call unknown code (events, callbacks) while holding a lockThat code might try to take another lock you don't know about
Use Monitor.TryEnter with a timeoutGive 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 codeThread-safe?
Local variables inside a methodYes, 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 threadsNo
static fields changed by several threadsNo, unless locked
ConcurrentDictionary, ConcurrentQueue (lesson 6)Yes
Interlocked operationsYes

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.

StrategyIdeaExample
Don't shareEach thread works on its own data, then combine at the endEach worker sums its own chunk (lesson 4)
Immutable dataData that never changes needs no protectionrecord Point(int X, int Y)
Concurrent collectionsUse a collection that handles locking for youConcurrentDictionary (lesson 6)
InterlockedSafe updates to one numberCounters, totals
lockOne thread at a time in a critical sectionBalance plus history
SemaphoreSlimAt most N threads at a time, works with awaitLimit to 3 downloads
ReaderWriterLockSlimAllow many readers or one writerA 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

MistakeWrongCorrectWhy
Unprotected countercount++;Interlocked.Increment(ref count);++ reads, adds, writes: updates get lost
Locking on a public objectlock (this)lock (_gate)Other code can lock the same object
Different lock objectsTwo methods lock different objectsLock the same _gate everywhereDifferent locks do not stop each other
Check and act in separate stepsif (seats > 0) then later seats--Both inside one lockAnother thread can act in between
Locks taken in different ordersA then B, and B then AAlways A then BOpposite orders cause deadlocks
await inside locklock (g) { await SaveAsync(); }await semaphore.WaitAsync();Does not compile; locks belong to a thread
Long work inside a lockDownloading inside lockDownload first, lock only to storeEveryone else waits the whole time
Shared List<T>list.Add(x) from many threadsLock it, or use a concurrent collectionList<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.
  • Interlocked makes simple number updates atomic and is faster than lock.
  • 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, then lock.

Next: Concurrent Collections