Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C# & .NET (C# 14, .NET 10 LTS)  Module 16 – Threads, parallelism and synchronisation

Locks, Interlocked and the .NET memory model

Fix C# data races with lock and the .NET 9 Lock type, count with Interlocked, and see what volatile and the .NET memory model promise.

  • Advanced
  • 20 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 7 of 20 minutes free
Free: value++ is three steps, and another thread can come between them · A lock runs the steps one thread at a time, and Interlocked makes one step atomic. With a pass: 3 in-depth parts and 3 interview answers, about 13 minutes.

What you will learn

  • Explain why value++ loses updates when threads share a field, and reproduce it
  • Fix a data race with the lock statement on a System.Threading.Lock, or with Interlocked
  • Reproduce a deadlock between two locks, detect it with TryEnter and remove its cause
  • Explain what volatile, Volatile.Read and Lazy<T> guarantee under the .NET memory model

Before you start

What you’ll learn · Before you start

What you will learn

  • Explain why value++ loses updates when threads share a field, and reproduce it
  • Fix a data race with the lock statement on a System.Threading.Lock, or with Interlocked
  • Reproduce a deadlock between two locks, detect it with TryEnter and remove its cause
  • Explain what volatile, Volatile.Read and Lazy<T> guarantee under the .NET memory model

Before you start

On this page

Four threads each added 1 to the same counter a million times, and the counter ended at 1,328,193 instead of 4,000,000: 2,671,807 additions vanished without an error. value++ reads the field, adds 1 and writes it back, and another thread can run between those steps.

You will reproduce that race, fix it with locks and with Interlocked, and see what the .NET memory model lets the compiler and the processor do with reads and writes that nothing protects.

value++ is three steps, and another thread can come between them

race.cs starts four threads at the same moment with a barrier, and each adds 1 to one counter a million times: with no lock, inside a lock, and with Interlocked.Increment.

race.cs: one counter, four threads, three ways C# · race/race.cs
using System.Diagnostics;

// Four threads each add 1 to the same counter a million times, in three ways.
const int Threads = 4;
const int PerThread = 1_000_000;
Console.WriteLine($"{Threads} threads x {PerThread:N0} additions: {Threads * PerThread:N0} expected");

Run("value++, no lock", c => c.AddUnsafe());
Run("lock on a Lock", c => c.AddLocked());
Run("Interlocked.Increment", c => c.AddAtomic());

void Run(string name, Action<Counter> add)
{
    var counter = new Counter();
    using var start = new Barrier(Threads + 1);
    var workers = new Thread[Threads];
    for (int t = 0; t < Threads; t++)
    {
        workers[t] = new Thread(() =>
        {
            start.SignalAndWait(); // every thread starts at the same moment
            for (int i = 0; i < PerThread; i++)
            {
                add(counter);
            }
        });
        workers[t].Start();
    }

    start.SignalAndWait();
    var watch = Stopwatch.StartNew();
    foreach (Thread worker in workers)
    {
        worker.Join();
    }

    int lost = Threads * PerThread - counter.Value;
    Console.WriteLine($"{name,-22} counted {counter.Value,9:N0}, lost {lost,9:N0}, in {watch.ElapsedMilliseconds} ms");
}

sealed class Counter
{
    private readonly Lock gate = new();
    private int value;

    public int Value => Volatile.Read(ref value);

    public void AddUnsafe() => value++; // read, add, write: three steps another thread can come between

    public void AddLocked()
    {
        lock (gate)
        {
            value++;
        }
    }

    public void AddAtomic() => Interlocked.Increment(ref value);
}

Output

4 threads x 1,000,000 additions: 4,000,000 expected
value++, no lock       counted 1,328,193, lost 2,671,807, in 7 ms
lock on a Lock         counted 4,000,000, lost         0, in 74 ms
Interlocked.Increment  counted 4,000,000, lost         0, in 90 ms

This output changes from run to run: how many additions the race loses, and the times, change from run to run

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run race.cs

The version with no lock finished in 7 ms and lost 2,671,807 additions. That is a race condition: A bug whose result depends on which of two or more threads reaches a piece of code first, so runs of the same program give different results. Two threads running value++ on one field is the classic case: an update can be lost.: the result depends on which thread reaches the code first. Step through one lost update:

Interactive · message by message One lost update in race.cs Step through two threads adding 1 to the same counter. Thread A is on the left, the counter in the middle, thread B on the right.
One lost update in race.cs, step 6 of 6. Messages so far: The counter to Thread A: read 41; The counter to Thread B: read 41; Thread A to The counter: write 42; Thread B to The counter: write 42.Thread Ahas 41, adds 1The counter43Thread Bhas 41, adds 1read 41read 41write 42write 42

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The counter holds 41. Both threads are about to run value++ on the same field, which holds 41. Each ++ is three steps: read the value, add 1, write it back.
  2. A reads 41. Thread A reads 41 into a register of its own processor core.
  3. B reads 41 too. Before A has written anything back, thread B, on another core, reads the same 41.
  4. A writes 42. Thread A writes 42. One addition is done.
  5. B writes 42. Thread B writes its own result, also 42, over A's. Two additions ran, and the counter went up by one: A's update is lost, and nothing reports it.
  6. With a lock or Interlocked. A lock lets only one thread run the three steps at a time, and Interlocked.Increment does all three as one atomic operation. Either way the counter ends at 43, as the two fixed lines of race.cs did with 4,000,000.

Both threads read 41 before either wrote, so both wrote 42. Nothing failed and nothing reported the loss, and its size changes from run to run, so a test on one thread never sees it. The two fixed versions counted all 4,000,000, and took tens of milliseconds where the race took 7: being correct costs time.

Checkpoint

Check: Why value++ loses updates

  1. Question 1 of 2 A counter holds 41. Two threads each run value++ on it once, with no lock. Which final values are possible?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • 42
    • 43

    Each ++ reads, adds and writes. If both reads come before both writes, one addition is lost, as 2,671,807 were in race.cs.

  2. Question 2 of 2 Why did value++ with no lock count far fewer than 4,000,000 in race.cs?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Another thread could read the same old value between one thread's read and its write

    value++ is three steps: read, add, write. Without a lock nothing stops another thread from running between them.

A lock runs the steps one thread at a time, and Interlocked makes one step atomic

The lock statement lets one thread at a time run its block. On .NET 9 and later, Microsoft’s guidance is to lock a dedicated System.Threading.Lock: the compiler turns lock (gate) on a Lock into using (gate.EnterScope()), and on any other object into Monitor.Enter and Monitor.Exit in a try and finally, so the lock is released even when the block throws. Never lock this, a Type or a string, which other code can lock too. Locks are held by threads, so an await inside one does not compile; async code uses SemaphoreSlim instead, the tool of the lesson on throttling.

Interlocked turns one operation on one variable into a single atomic operation: An operation that other threads see either not at all or completely, never half done. The methods of Interlocked, such as Increment and CompareExchange, are atomic operations on one variable; value++ is not.: Increment, Add, Exchange, and CompareExchange, which writes a new value only if the variable still holds the value you expect. Locks protect any number of steps and fields together; Interlocked protects exactly one.

The third piece is the .NET memory model: The rules for which reads and writes of shared memory one thread may see from another, and in which order. Ordinary reads and writes may be reordered or kept in registers; locks, Interlocked and Volatile.Read and Volatile.Write limit that.. Without a lock, the compiler and the processor may keep a value in a register, merge reads or reorder them, as long as one thread cannot tell. volatile and the Volatile.Read and Volatile.Write methods limit that reordering, but never make ++ atomic, while a lock and every Interlocked method bring the ordering they need. The .NET memory model is why “it worked on my machine” proves nothing about code that shares data without them. Side by side, with what race.cs counted:

Tool Protects value++ from four threads
lock on a Lock any steps and fields, one thread at a time 4,000,000 in race.cs
Interlocked.Increment one operation on one variable 4,000,000 in race.cs
volatile, Volatile.Read the order of reads and writes still loses updates
nothing nothing 1,328,193 in race.cs

The parts below go deeper. A deadlock: A state in which pieces of work each wait for another to go first, so none of them can go on. Two threads that each hold a lock the other needs are one case; a thread blocked on a task whose continuation needs that same thread is another. More in “SynchronizationContext, ConfigureAwait and deadlocks” is two threads that each hold a lock the other waits for. Lock.TryEnter with a time limit gives up instead of waiting for ever. Lazy<T> builds a shared object once, on first use. Double-checked locking tests a field before and after taking a lock.

Key idea

A lock runs several steps on one thread at a time and Interlocked makes one step atomic; volatile only orders reads and writes, so it never fixes a lost update.

Checkpoint

Check: lock, Lock and Interlocked

  1. Question 1 of 2 gate is a private readonly System.Threading.Lock. What does lock (gate) { … } compile to?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: using (gate.EnterScope()) { … }

    The C# 13 lock statement recognises the .NET 9 Lock type. Cast the same object to object and it falls back to Monitor, which the compiler warns about.

  2. Question 2 of 2 Every request adds 1 to a shared counter that a dashboard reads. What is the simplest correct choice?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Interlocked.Increment(ref count)

    For a single counter, Interlocked is both correct and cheaper to read in code than a lock.

Deadlocks from lock order, reproduced and fixed Pro: opens with a Pro, Premium or Ultimate pass

Two threads that take the same two locks in opposite orders can wait for each other for ever. This part reproduces that deadlock on purpose, turns the hang into an answer with a time limit, removes its cause, and shows why an await never goes inside a lock.

In this part:

  • Two transfers, two locks, and a wait that never ends
  • One order for every pair of locks

1 diagram · 3 runnable examples (C#, Python) · about 5 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

The .NET memory model: reordering, volatile and safe publication Pro: opens with a Pro, Premium or Ultimate pass

Without a lock, the compiler and the processor may cache, merge and reorder ordinary reads and writes, as long as one thread cannot tell. This part reproduces a loop that never sees its stop flag, sets out the rules of the .NET memory model, and measures four ways to build a shared object once.

In this part:

  • A stop flag the loop never sees
  • What the runtime promises: acquire, release and full fences
  • Building a shared object once: Lazy and double-checked locking, measured

2 runnable examples (C#) · about 4 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

A lab: transfers between accounts without races or deadlocks Pro: opens with a Pro, Premium or Ultimate pass

A transfer changes two accounts, so it needs two locks, and two locks bring back the deadlock of the first part. This lab gives you a transfer method that loses money under load, and four checks to make pass.

In this part:

  • The four checks, and the one that fails
  • Two locks in one order, and the checks that catch each wrong fix

1 runnable example (C#) · about 3 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Practise

The self-check covers the first two sections: why value++ races, and which tool fixes which problem. It starts where your answers so far were least sure:

Practice that adapts to you

Practise this lesson

7 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 7 value++ on a field shared by threads runs as which steps?

    Choose one answer.

    Show the answer: Question 1 of 7

    Answer: Read the value, add 1, write it back

    Between the read and the write another thread may run its own three steps, and one result overwrites the other.

  2. Question 2 of 7 Four threads add 1 to a shared int a million times each, with no lock. What can the final value be?

    Choose one answer.

    Show the answer: Question 2 of 7

    Answer: Anything up to 4,000,000; race.cs ended at 1,328,193

    A race gives a different wrong answer on every run, which is why it is hard to find in testing.

  3. Question 3 of 7 A unit test calls a counter's Add method 1,000 times on one thread and checks for 1,000. Does a pass show the counter is safe to share between threads?

    Choose one answer.

    Show the answer: Question 3 of 7

    Answer: No: a race needs two threads between one read and its write, which one thread never creates

    Thread safety is tested with threads that start together and work on the same data, many times.

  4. Question 4 of 7 On .NET 9 and later, what should a class lock on to protect its own fields?

    Choose one answer.

    Show the answer: Question 4 of 7

    Answer: A private readonly field of type System.Threading.Lock

    Lock on an object that no other code can reach, and keep one lock per piece of shared state.

  5. Question 5 of 7 Many threads update a running total and a count together, to compute an average. Which tool keeps the two consistent?

    Choose one answer.

    Show the answer: Question 5 of 7

    Answer: A lock around both updates, and around the code that reads them

    A lock is the tool for several steps that must look like one; Interlocked is the tool for one variable.

  6. Question 6 of 7 A counter field is marked volatile, and four threads run counter++ on it. Does it count correctly?

    Choose one answer.

    Show the answer: Question 6 of 7

    Answer: No: volatile does not make the read, add and write one atomic step

    Use Interlocked.Increment, or a lock, for a shared counter.

  7. Question 7 of 7 A method must keep other callers out of a cache entry while it awaits an HTTP call. What does it use?

    Choose one answer.

    Show the answer: Question 7 of 7

    Answer: A SemaphoreSlim with one place, entered with WaitAsync and released in finally

    Locks are held by threads; async code holds a SemaphoreSlim instead, which the lessons on throttling use.

Interview questions

Fresher · Why is count++ not thread-safe in C#?

count++ on a field is three steps: read the value, add 1, write the result back. Two threads can both read the same value before either writes, and then both write the same result, so one addition is lost without any error. How many are lost changes with timing, so the bug shows under load and rarely in tests. Interlocked.Increment(ref count) makes the three steps one atomic operation; a lock around the update lets one thread at a time run it, which you need when several fields change together. Marking the field volatile does not help: it affects the order of reads and writes, not whether ++ is atomic.

  1. Mid to senior

    A payment service hangs a few times a week: two threads are stuck, and every later request waits behind them. Transfer locks the payer's account, then the payee's. Explain the hang and fix it so that it cannot happen again.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A teammate fixed a lost-update bug by marking a counter field volatile. The nightly test with eight threads still counts too few. Explain why volatile did not help, and what each correct fix costs.

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    At start-up a service logs that it loaded its pricing settings 8 times, and some requests used other settings than others. The property is written as if (_settings == null) _settings = Load(). Explain it and replace it.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • value++ is a read, an add and a write, so two threads can read the same value and lose an update: race.cs lost 2,671,807 of 4,000,000 additions.
  • A lock on a dedicated System.Threading.Lock runs its block on one thread at a time, and Interlocked makes one operation on one variable atomic.
  • Under the .NET memory model ordinary reads and writes may be reordered or kept in registers, and volatile limits that without making ++ atomic.

Terms in this lesson

.NET memory model
The rules for which reads and writes of shared memory one thread may see from another, and in which order. Ordinary reads and writes may be reordered or kept in registers; locks, Interlocked and Volatile.Read and Volatile.Write limit that.
atomic operation
An operation that other threads see either not at all or completely, never half done. The methods of Interlocked, such as Increment and CompareExchange, are atomic operations on one variable; value++ is not.
race condition
A bug whose result depends on which of two or more threads reaches a piece of code first, so runs of the same program give different results. Two threads running value++ on one field is the classic case: an update can be lost.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.