Interlocked and Atomic Operations
Interlocked makes one read-modify-write on one variable atomic without a lock, which covers counters and not much else.
int updated = Interlocked.Increment(ref counter); // atomic read, add one, writeInterlocked is a static class in System.Threading. Each method performs one read-modify-write on one variable as a single step that cannot be interrupted. A step like that is called atomic. It is done with a processor instruction rather than by making other threads wait, so it is cheaper than a lock and far narrower in what it can do.
The four you will use
Increment(ref x)andDecrement(ref x)change the variable by one and return the new value.Add(ref x, amount)adds and returns the new value.Exchange(ref x, value)storesvalueand returns the old one.CompareExchange(ref x, value, comparand)storesvalueonly ifxcurrently equalscomparand, and returns whatxheld before, either way.
Compare-and-swap
CompareExchange is how you make an update whose new value depends on the current one. Read the variable, work out the new value, then swap it in only if nobody changed it meanwhile. If the returned value is not the one you read, somebody did, so start again from theirs. That loop is the compare-and-swap pattern, and Increment is simply the common case of it done in one instruction.
Where it stops
Increment, Decrement and Add cover int, long, uint and ulong. Exchange and CompareExchange cover those, the smaller integers, float, double, IntPtr and, through their generic overloads, reference types. One variable is the limit. Two fields that must change together need a lock, and so does any update you cannot write as a loop over a single slot.
Example
int processed = 0;
int bytesSeen = 0;
// Two tasks update the same two counters at the same time.
Task Handle(int[] sizes) => Task.Run(() =>
{
foreach (int size in sizes)
{
// One uninterruptible read-add-write.
Interlocked.Increment(ref processed);
// The same thing for an amount other than one.
Interlocked.Add(ref bytesSeen, size);
}
});
await Task.WhenAll(Handle([10, 20, 30]), Handle([40, 50, 60]));
// The longhand of that Increment call, written out. It is the same thing:
// int seen;
// do { seen = processed; }
// while (Interlocked.CompareExchange(ref processed, seen + 1, seen) != seen);
Console.WriteLine($"processed: {processed}");
Console.WriteLine($"bytes: {bytesSeen}");
// Output:
// processed: 6
// bytes: 210When to use it
- Counting requests served across many concurrent handlers without a lock in the hot path.
- Tracking the highest latency seen so far from many workers.
- Setting a one-time initialisation flag so only the first caller does the setup.
- Replacing a contended lock around a single integer after a benchmark shows it is the bottleneck.
More examples
Exchange as a run-once claim
Use this when some setup must run once and you do not want every later caller paying for a lock to discover that it already ran. The flag read and the flag write are one operation, so there is no window between them. Note what it does not give you: callers that get false are told the work was claimed, not that it has finished, so this is not a substitute for waiting on a result.
using System.Threading;
public sealed class Startup
{
private int _claimed; // 0 = nobody has taken it, 1 = taken
// Exchange stores 1 and hands back whatever was there before. Exactly one
// caller can ever see the old 0, so exactly one caller gets true.
public bool TryClaimInitialisation()
{
return Interlocked.Exchange(ref _claimed, 1) == 0;
}
}A running maximum, with a lock and without
The same rule written both ways, so you can read either in someone else's code. Reach for the compare-and-swap version only when measurement shows the lock is a real bottleneck, because the loop is easy to get subtly wrong and impossible to extend to a second field. Both methods are correct here; only one of them is obvious at a glance.
using System.Threading;
public sealed class LatencyTracker
{
private readonly Lock _gate = new();
private int _maxWithLock;
private int _maxWithCas;
// Version one: a lock. Longer at runtime, shorter to read, and correct.
public void ReportWithLock(int milliseconds)
{
lock (_gate)
{
if (milliseconds > _maxWithLock)
{
_maxWithLock = milliseconds;
}
}
}
// Version two: the same rule with no lock.
public void ReportWithCompareExchange(int milliseconds)
{
// Volatile.Read makes sure we start from the newest value written
// by any thread rather than a stale copy.
int seen = Volatile.Read(ref _maxWithCas);
while (milliseconds > seen)
{
int before = Interlocked.CompareExchange(ref _maxWithCas, milliseconds, seen);
if (before == seen)
{
return; // nobody changed it, our value went in
}
seen = before; // somebody did, try again from their value
}
}
}What Interlocked cannot cover
Worth reading before replacing a lock with Interlocked calls in existing code. Making each field atomic does not make the update atomic; it only narrows the window. Decide what a reader is allowed to see first: for a metrics counter a brief mismatch is usually fine, for a balance and its transaction log it is not.
using System.Threading;
public sealed class Meter
{
private int _requests;
private long _bytes;
public void Record(int bytes)
{
// Each call is atomic on its own. The pair of them is not.
Interlocked.Increment(ref _requests);
Interlocked.Add(ref _bytes, bytes);
// A reader between these two lines sees a request carrying no bytes.
// If that matters, both fields belong inside one lock instead.
}
}
Discussion