ZetCode

C# Interlocked

last modified October 4, 2026

C# Interlocked tutorial shows how to perform atomic operations on shared variables with the System.Threading.Interlocked class.

The Interlocked class provides static methods that read and modify a variable as one indivisible unit. Each call executes as a single atomic operation, so no other thread can observe a half-finished update or interleave its own update with it. Because no lock is taken, the calling thread never blocks and cannot deadlock, which makes the class a good fit for counters, flags, statistics, and lock-free algorithms. A lock is heavier and makes waiting threads block, but it can protect several variables that must change together. Interlocked works on one location at a time.

C# race condition

The first example shows what happens when multiple threads update a shared counter without synchronization.

Program.cs
using System.Threading.Tasks;

int counter = 0;

var tasks = new List<Task>();

for (int i = 0; i < 8; i++)
{
    tasks.Add(Task.Run(() =>
    {
        for (int j = 0; j < 500_000; j++)
        {
            counter++;
        }
    }));
}

await Task.WhenAll(tasks);

Console.WriteLine($"Expected: {8 * 500_000}");
Console.WriteLine($"Actual:   {counter}");

We start eight tasks. Each task increments the same counter variable half a million times, so the expected result is 4,000,000.

counter++;

The statement looks like a single operation, but it is not. The read of counter, the addition, and the write back are three separate steps. When two threads interleave them, both may read the same value, both add one to it, and the second write overwrites the first. The updates that were lost this way are never applied again.

$ dotnet run
Expected: 4000000
Actual:   692864

The value printed for Actual is different on every run and is almost always smaller than the expected one. This kind of bug is called a race condition, and it must be fixed with synchronization.

C# Interlocked.Increment and Decrement

The Interlocked.Increment method adds one to a variable and returns the new value. The whole read-modify-write sequence happens as a single atomic operation, so the counter cannot lose updates.

Program.cs
using System.Threading;
using System.Threading.Tasks;

int counter = 0;
long hits = 0;

var tasks = new List<Task>();

for (int i = 0; i < 8; i++)
{
    tasks.Add(Task.Run(() =>
    {
        for (int j = 0; j < 500_000; j++)
        {
            Interlocked.Increment(ref counter);
            Interlocked.Increment(ref hits);
        }
    }));
}

await Task.WhenAll(tasks);

Interlocked.Decrement(ref counter);

Console.WriteLine($"Counter: {counter}");
Console.WriteLine($"Hits:    {hits}");

The counter variable has the int type and hits has the long type. Both are accepted, because Interlocked.Increment provides an overload for int and one for long.

Interlocked.Increment(ref counter);

The argument is passed by ref, so the method receives the location of the variable and not a copy of its value. The result is exactly the same as the unsynchronized increment, but no update is lost.

Interlocked.Decrement(ref counter);

The Decrement method subtracts one atomically and has the same int and long overloads. We call it once after all the tasks have finished, so the final counter is one less than the number of increments.

$ dotnet run
Counter: 3999999
Hits:    4000000

The output is now deterministic. Every increment is applied, and the counter reaches the expected value.

C# Interlocked.Add

Interlocked.Add adds an arbitrary value instead of one. It replaces the += operator, which has the same read-modify-write problem as ++.

Program.cs
using System.Threading;
using System.Threading.Tasks;

long total = 0;

var tasks = new List<Task>();

for (int i = 0; i < 4; i++)
{
    tasks.Add(Task.Run(() =>
    {
        for (int j = 0; j < 100_000; j++)
        {
            Interlocked.Add(ref total, 5);
        }
    }));
}

await Task.WhenAll(tasks);

Console.WriteLine($"Total: {total}");

Four tasks add the value 5 one hundred thousand times each, so the expected total is 2,000,000.

Interlocked.Add(ref total, 5);

The method returns the new value, which we ignore here. It has an overload for int and an Interlocked.Add(ref long, long) overload for 64-bit values, so the long accumulator above is incremented atomically as well.

$ dotnet run
Total: 2000000

C# Interlocked.Exchange

Interlocked.Exchange stores a new value into a location and returns the value that was there before. The swap is atomic, so another thread either sees the old value or the new one, never a mixture.

Program.cs
using System.Threading;

int flag = 0;

int previous = Interlocked.Exchange(ref flag, 1);

Console.WriteLine($"Previous: {previous}");
Console.WriteLine($"Current:  {flag}");

The returned value is the state of the variable before the call. This is useful for a one-shot transition, for example when only the thread that changes a flag from 0 to 1 should perform some work.

$ dotnet run
Previous: 0
Current:  1

Besides the numeric overloads, there is a generic form, Interlocked.Exchange<T>(ref T location, T value), where T is a reference type. It can swap a whole object reference atomically, which is the standard way to publish a new immutable snapshot to readers.

Program.cs
using System.Threading;

var store = new StateStore();

IMyState previous = Interlocked.Exchange(ref store.Current, new MyState(2));

Console.WriteLine($"Previous: {previous.Number}");
Console.WriteLine($"Current:  {store.Current.Number}");

interface IMyState
{
    int Number { get; }
}

sealed class MyState(int number) : IMyState
{
    public int Number { get; } = number;
}

sealed class StateStore
{
    public IMyState Current = new MyState(1);
}

The StateStore.Current field holds a reference to an immutable state object. Readers only ever see a fully constructed MyState instance, because the reference is replaced in one atomic step. A writer that builds a new snapshot in a local variable and then swaps it in cannot leave readers with a half-updated object; this is why immutable snapshots and Exchange work well together.

$ dotnet run
Previous: 1
Current:  2

The same generic form of Exchange is available as Interlocked.CompareExchange<T>, which we look at next.

C# Interlocked.CompareExchange

Interlocked.CompareExchange is the compare-and-swap operation. It stores the new value only when the current value equals the comparand, and it returns the value that was in the location at the moment of the call. A returned value that is not equal to the comparand means another thread changed the variable first and the store did not happen.

Program.cs
using System.Threading;

int state = 0;

int original = Interlocked.CompareExchange(ref state, 1, 0);
Console.WriteLine($"Original: {original}, State: {state}");

int second = Interlocked.CompareExchange(ref state, 2, 0);
Console.WriteLine($"Original: {second}, State: {state}");

The first call finds state equal to the comparand 0, so it stores 1 and returns 0. The second call still uses 0 as the comparand, but the variable now holds 1, so the store is skipped and the current value 1 is returned.

This is the classic set-if-default pattern: initialize a value only when it has not been initialized yet, and let exactly one thread win.

int original = Interlocked.CompareExchange(ref state, 1, 0);

if (original == 0)
{
    // this thread performed the initialization
}

Because the check and the assignment happen as one atomic operation, two threads can never both believe they won the race.

$ dotnet run
Original: 0, State: 1
Original: 1, State: 1

A compare-and-swap call that fails is not an error. The usual approach is to retry in a loop, reading the current value again and attempting the swap until it succeeds. The following lock-free maximum uses that pattern: each task repeatedly tries to publish its own id, and only the largest id survives.

Program.cs
using System.Threading;
using System.Threading.Tasks;

int max = 0;

var tasks = Enumerable.Range(1, 8).Select(id => Task.Run(() =>
{
    for (int i = 0; i < 500_000; i++)
    {
        int current = Volatile.Read(ref max);

        while (id > current)
        {
            int seen = Interlocked.CompareExchange(ref max, id, current);

            if (seen == current)
            {
                break;
            }

            current = seen;
        }
    }
})).ToArray();

await Task.WhenAll(tasks);

Console.WriteLine($"Max: {max}");

Each iteration reads the current maximum and enters the retry loop only when the task's own id is larger. The CompareExchange call succeeds when max still holds the value we read; otherwise it returns what the variable holds now, and the loop retries with that newer value. The loop is re-entered until the swap succeeds or until the id is no longer greater than the current maximum, so it always terminates.

$ dotnet run
Max: 8

The result is deterministic: the ids range from 1 to 8, and task 8 keeps retrying until its own value is published, so the maximum is always 8.

C# Interlocked.Read

Reading a long with the = operator is not guaranteed to be atomic on every platform. On a 32-bit process a 64-bit read is performed as two 32-bit reads, and a writer running at the same time can change the variable between them. The reader then sees a value in which the high half comes from the new value and the low half from the old one. This is called a torn read.

Interlocked.Read(ref long) performs the whole 64-bit read as one atomic operation, so a torn value can never be observed.

Program.cs
using System.Threading;
using System.Threading.Tasks;

long total = 0;

var tasks = Enumerable.Range(0, 4).Select(_ => Task.Run(() =>
{
    for (int i = 0; i < 100_000; i++)
    {
        Interlocked.Add(ref total, 10);
    }
})).ToArray();

await Task.WhenAll(tasks);

long snapshot = Interlocked.Read(ref total);

Console.WriteLine($"Total: {snapshot}");

The tasks add 10 four hundred thousand times in total, and Interlocked.Read takes a clean snapshot of the result.

long snapshot = Interlocked.Read(ref total);

On a 64-bit runtime the method is implemented as a volatile read, because aligned 64-bit reads are already atomic there. On a 32-bit runtime it uses a lock to make the read indivisible. Writing a long atomically is handled by Interlocked.Exchange(ref long, long), which has the same guarantee for the whole 64-bit value.

$ dotnet run
Total: 4000000

C# Interlocked vs lock

Both tools provide synchronization, but they solve different problems. Interlocked is limited to single-variable updates, while lock can wrap any number of statements into a critical section.

The following example transfers money between two fields. The total must never change, so both updates have to happen inside one critical section. A single Interlocked call cannot express this invariant.

Program.cs
using System.Threading;
using System.Threading.Tasks;

var account = new Account();

var tasks = Enumerable.Range(0, 8).Select(_ => Task.Run(() =>
{
    for (int i = 0; i < 500_000; i++)
    {
        account.Move(1);
    }
})).ToArray();

await Task.WhenAll(tasks);

Console.WriteLine($"Checking: {account.Checking}");
Console.WriteLine($"Savings:  {account.Savings}");
Console.WriteLine($"Total:    {account.Checking + account.Savings}");

sealed class Account
{
    private readonly Lock _sync = new();
    private long _checking = 5_000_000;
    private long _savings;

    public long Checking => Interlocked.Read(ref _checking);
    public long Savings => Interlocked.Read(ref _savings);

    public void Move(long amount)
    {
        lock (_sync)
        {
            _checking -= amount;
            _savings += amount;
        }
    }
}

The individual reads use Interlocked.Read, but the transfer itself uses a lock, because _checking and _savings must be updated as a pair. If each field were updated with Interlocked separately, another thread could observe the state between the two calls, when the money has left one field but has not yet arrived in the other.

$ dotnet run
Checking: 1000000
Savings:  4000000
Total:    5000000

The total is always 5,000,000, no matter how the four million transfers interleave.

Source

Interlocked Class - Microsoft Learn

Interlocked.Increment Method - Microsoft Learn

Interlocked.CompareExchange Method - Microsoft Learn

The lock statement - Microsoft Learn

In this article we have worked with the Interlocked class in C#.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all C# tutorials.