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.
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.
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
++.
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.
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.
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.
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.
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.
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.
- Use
Interlockedwhen a single field or reference is updated and the update is one of the built-in operations: increment, decrement, add, exchange, or compare-exchange. - Use
lock, or theSystem.Threading.Locktype, when several fields must change together so that an invariant holds between them, or when the critical section contains more than one statement. - Use
lockwhen a thread must wait for a condition to become true. Waiting and signaling withMonitor.WaitandMonitor.Pulsehave no equivalent inInterlocked. - Prefer
Interlockedin hot paths. It does not block, which avoids the context switches and the risk of deadlock that a lock introduces.
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.
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
List all C# tutorials.