C# Synchronization: lock, Interlocked, SemaphoreSlim and Concurrent Collections
Why sold++ never reaches a million: race conditions and the check-then-act race. The rules of lock and why you can't await inside it, atomic counters and CompareExchange with Interlocked, the SemaphoreSlim pattern for an async cache, ConcurrentDictionary factories that run more than once and the Lazy<T> fix.
Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.
Guess First
We're counting the books sold on a campaign day. A million sales, each bumping the counter by one, done in parallel. Which number shows up on screen?
int sold = 0;
Parallel.For(0, 1_000_000, _ => sold++);
Console.WriteLine($"Sold: {sold}");
Race Conditions: Why sold++ Isn't Atomic
The output on this machine:
Sold: 181345
Not even a fifth of a million. I ran the same code three more times and got a different wrong number each time; underneath I added two lines that do the same work with lock and Interlocked:
for (int run = 1; run <= 3; run++)
{
int sold = 0;
Parallel.For(0, 1_000_000, _ => sold++);
Console.WriteLine($"sold++ -> {sold}");
}
int locked = 0;
object gate = new();
Parallel.For(0, 1_000_000, _ =>
{
lock (gate)
{
locked++;
}
});
Console.WriteLine($"lock -> {locked}");
int atomic = 0;
Parallel.For(0, 1_000_000, _ => Interlocked.Increment(ref atomic));
Console.WriteLine($"Interlocked -> {atomic}");
sold++ -> 117707
sold++ -> 134029
sold++ -> 268237
lock -> 1000000
Interlocked -> 1000000
sold++ is one line but not one operation: read the value from memory, add one, write the result back. If two threads both read 41, both write 42 and one sale is lost. That's a race condition: the result depends on the order in which the threads happen to run. In the previous post we dodged it by writing results to separate slots or by using thread-local subtotals. When you can't avoid shared state, you have three basic tools: lock, Interlocked, and SemaphoreSlim for async code. And then there are the concurrent collections, which use these tools internally on your behalf.

lock
lock lets only one thread into a block of code at a time. The others wait at the door. Its real value is making several steps behave as one unit. Think of a stock check: look whether there's enough stock, and if there is, decrement it. If another thread slips in between the two steps, you sell the same last book to two customers:
Console.WriteLine(Sell(new UnsafeShelf(stock: 100)));
Console.WriteLine(Sell(new SafeShelf(stock: 100)));
static string Sell(IShelf shelf)
{
int sold = 0;
// 1,000 customers try to buy one copy each
Parallel.For(0, 1_000, _ =>
{
if (shelf.TryReserve(1))
Interlocked.Increment(ref sold);
});
return $"{shelf.GetType().Name}: sold {sold}, stock left {shelf.Stock}";
}
interface IShelf
{
int Stock { get; }
bool TryReserve(int quantity);
}
class UnsafeShelf : IShelf
{
public UnsafeShelf(int stock) => Stock = stock;
public int Stock { get; private set; }
public bool TryReserve(int quantity)
{
if (Stock < quantity) // check...
return false;
Thread.SpinWait(100); // a little work between check and act
Stock -= quantity; // ...then act
return true;
}
}
class SafeShelf : IShelf
{
private readonly object _gate = new();
private int _stock;
public SafeShelf(int stock) => _stock = stock;
public int Stock
{
get { lock (_gate) return _stock; }
}
public bool TryReserve(int quantity)
{
lock (_gate)
{
if (_stock < quantity)
return false;
Thread.SpinWait(100);
_stock -= quantity;
return true;
}
}
}
UnsafeShelf: sold 123, stock left -2
SafeShelf: sold 100, stock left 0
123 books were sold from a stock of 100 and the stock went negative; and because some decrements were lost too, the negative number doesn't even match the oversold amount. This is a check-then-act race: the check was correct, but by the time of the action it was no longer true. In SafeShelf the check and the decrement sit inside the same lock, so no thread can get in between.
The compiler turns a lock (x) { ... } block into a call to Monitor.Enter and a call to Monitor.Exit inside a finally; the lock is released even if the block throws. Make a few rules a habit. The lock object should be a private readonly field; if you lock something reachable from outside, other code can take the same lock. Keep the block short; don't do I/O, long computations or call a callback that comes from outside inside it. Don't forget the read side either: the SafeShelf.Stock property takes the lock too, because lock is what guarantees that a written value is seen consistently from another thread.
One rule comes from the compiler: you can't write await inside a lock.
var cache = new PriceCache();
await cache.RefreshAsync();
class PriceCache
{
private readonly object _gate = new();
private Dictionary<string, decimal> _prices = new();
public async Task RefreshAsync()
{
lock (_gate)
{
_prices = await LoadPricesAsync();
}
}
private static async Task<Dictionary<string, decimal>> LoadPricesAsync()
{
await Task.Delay(100);
return new Dictionary<string, decimal> { ["978-0132350884"] = 37.90m };
}
}
Program.cs(13,23): error CS1996: Cannot await in the body of a lock statement
The reason: a Monitor lock belongs to the thread that took it. The code after an await may continue on a different thread, which would then try to release a lock it never took. If you need a lock in async code, you have to move to SemaphoreSlim. C# 13 and .NET 9 brought a dedicated System.Threading.Lock type for lock; we'll cover it in the series' .NET 9–10 post, but it isn't async either.
Interlocked
When the shared state is a single number, a lock is overkill. The Interlocked class offers small operations that the processor carries out as a single atomic instruction:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
long revenueInCents = 0;
int highestPriceInCents = 0;
int orderNumber = 1000;
int importStarted = 0;
int[] pricesInCents = Enumerable.Range(0, 100_000).Select(i => 1_000 + i % 5_000).ToArray();
Parallel.For(0, pricesInCents.Length, i =>
{
Interlocked.Add(ref revenueInCents, pricesInCents[i]);
UpdateMax(ref highestPriceInCents, pricesInCents[i]);
});
int[] orderNumbers = new int[5];
Parallel.For(0, orderNumbers.Length, i => orderNumbers[i] = Interlocked.Increment(ref orderNumber));
// only the first caller gets 0 back and runs the import
int[] winners = new int[8];
Parallel.For(0, winners.Length, i => winners[i] = Interlocked.Exchange(ref importStarted, 1) == 0 ? 1 : 0);
Console.WriteLine($"Revenue: {revenueInCents / 100m:F2}");
Console.WriteLine($"Highest price: {highestPriceInCents / 100m:F2}");
Console.WriteLine($"Order numbers: {string.Join(", ", orderNumbers.OrderBy(n => n))}");
Console.WriteLine($"Import started by {winners.Sum()} caller(s)");
static void UpdateMax(ref int target, int candidate)
{
int current = Volatile.Read(ref target);
while (candidate > current)
{
// write only if nobody changed the value since we read it
int seen = Interlocked.CompareExchange(ref target, candidate, current);
if (seen == current)
return;
current = seen;
}
}
Revenue: 3499500.00
Highest price: 59.99
Order numbers: 1001, 1002, 1003, 1004, 1005
Import started by 1 caller(s)
Increment and Add make read-add-write indivisible; since Increment returns the new value, it's also perfect for generating unique order numbers. Exchange writes a value and returns the old one; that's enough for "the first caller starts the import, the others return". CompareExchange is the foundation of them all: "if the value is still what I expect, write the new one; otherwise tell me what it is now". The loop in UpdateMax is that pattern: read, compare, try to write, and if someone got in between, retry with the new value. Volatile.Read stops the compiler from keeping the value in a register and working with a stale copy, and stops the read from being reordered with later operations.
Notice that we keep the amount in cents as a long. The arithmetic methods of Interlocked exist only for integer types; try it with decimal and you get:
decimal revenue = 0m;
Interlocked.Add(ref revenue, 37.90m);
Program.cs(2,21): error CS1503: Argument 1: cannot convert from 'ref decimal' to 'ref int'
Program.cs(2,30): error CS1503: Argument 2: cannot convert from 'decimal' to 'int'
For money, either work with an integer number of cents or protect the total with a lock. If you need to update two separate variables together (stock and reserved, say), Interlocked isn't enough; two separate atomic operations aren't atomic as a whole. Then the answer is lock.
SemaphoreSlim: A Lock That Works with async
SemaphoreSlim is a counter: it lets at most N callers inside at once. With N set to one it acts like a lock, and the key difference is that you can wait on it with WaitAsync without blocking a thread. The classic use is an asynchronously loaded cache. When five requests arrive at the same time while the cache is empty, they shouldn't fetch the price list from the database five times:
var catalog = new CatalogCache();
// five requests arrive at the same moment while the cache is empty
Task<IReadOnlyDictionary<string, decimal>>[] requests = Enumerable.Range(0, 5)
.Select(_ => catalog.GetPricesAsync(CancellationToken.None))
.ToArray();
await Task.WhenAll(requests);
Console.WriteLine($"Requests served: {requests.Length}, loads from database: {catalog.LoadCount}");
class CatalogCache
{
private readonly SemaphoreSlim _gate = new(1, 1);
private IReadOnlyDictionary<string, decimal>? _prices;
private int _loadCount;
public int LoadCount => _loadCount;
public async Task<IReadOnlyDictionary<string, decimal>> GetPricesAsync(CancellationToken cancellationToken)
{
if (_prices is not null)
return _prices;
await _gate.WaitAsync(cancellationToken);
try
{
// check again: another caller may have loaded it while we waited
if (_prices is null)
_prices = await LoadFromDatabaseAsync(cancellationToken);
return _prices;
}
finally
{
_gate.Release();
}
}
private async Task<IReadOnlyDictionary<string, decimal>> LoadFromDatabaseAsync(CancellationToken cancellationToken)
{
Interlocked.Increment(ref _loadCount);
await Task.Delay(200, cancellationToken);
return new Dictionary<string, decimal> { ["978-0132350884"] = 37.90m, ["978-0135957059"] = 42.50m };
}
}
Requests served: 5, loads from database: 1
Memorize the pattern: await _gate.WaitAsync(cancellationToken), immediately followed by try, with Release() always inside finally. If you put WaitAsync inside the try, a canceled wait ends up releasing a lock you never acquired. Checking again inside the lock is part of the pattern too: while the first request loads the data, the four waiting requests see that the data is already there when their turn comes and skip the load. Give N a value greater than one and the same class becomes a concurrency limiter; that's how bounded concurrency was built before the Parallel.ForEachAsync from the previous post.
Concurrent Collections
The collections in the System.Collections.Concurrent namespace do the locking for you, internally and often at a finer grain than a lock would. The most widely used one is ConcurrentDictionary<TKey, TValue>:
using System.Collections.Concurrent;
string[] sales = Enumerable.Range(0, 100_000)
.Select(i => i % 3 == 0 ? "978-0132350884" : i % 3 == 1 ? "978-0135957059" : "978-0321125217")
.ToArray();
var salesByIsbn = new ConcurrentDictionary<string, int>();
Parallel.ForEach(sales, isbn => salesByIsbn.AddOrUpdate(isbn, 1, (_, count) => count + 1));
foreach (KeyValuePair<string, int> pair in salesByIsbn.OrderBy(p => p.Key))
Console.WriteLine($"{pair.Key}: {pair.Value}");
int factoryCalls = 0;
var covers = new ConcurrentDictionary<string, string>();
Parallel.For(0, 10, _ => covers.GetOrAdd("978-0132350884", isbn =>
{
Interlocked.Increment(ref factoryCalls);
Thread.Sleep(50); // an expensive cover download
return $"cover-{isbn}.jpg";
}));
Console.WriteLine($"GetOrAdd: {covers.Count} entry, factory ran {factoryCalls} times");
int lazyCalls = 0;
var lazyCovers = new ConcurrentDictionary<string, Lazy<string>>();
string[] results = new string[10];
Parallel.For(0, results.Length, i => results[i] = lazyCovers.GetOrAdd("978-0132350884", isbn => new Lazy<string>(() =>
{
Interlocked.Increment(ref lazyCalls);
Thread.Sleep(50);
return $"cover-{isbn}.jpg";
})).Value);
Console.WriteLine($"GetOrAdd + Lazy: {results.Distinct().Count()} distinct result, factory ran {lazyCalls} time");
978-0132350884: 33334
978-0135957059: 33333
978-0321125217: 33333
GetOrAdd: 1 entry, factory ran 10 times
GetOrAdd + Lazy: 1 distinct result, factory ran 1 time
AddOrUpdate does "add if missing, update if present" atomically; it's a perfect fit for counting sales per ISBN. But look at the second line: the dictionary has a single entry, yet the cover download function ran ten times. The documentation says it plainly: the factory you pass to GetOrAdd and AddOrUpdate is called outside the lock and may run more than once for the same key; only one result is stored. If the factory is cheap and has no side effects, that's fine. If it's expensive or has side effects (an HTTP request, a database write), wrap the value in Lazy<T>: what races in the dictionary are cheap Lazy objects, and Value is computed only once.
The other members are there for narrower jobs. ConcurrentQueue<T> is FIFO and ConcurrentStack<T> is LIFO; both say "take one if there is one" with TryDequeue/TryPop. ConcurrentBag<T> is unordered and optimized for scenarios where the same thread both adds and takes. BlockingCollection<T> is designed for producer/consumer, but as the name says, it blocks the waiting thread. Its counterpart in the async world is the topic of the next post.
Which Tool When
First check whether you can avoid shared state altogether: let each job produce its own result and merge the results at the end. If that's not possible, use Interlocked for a single counter or flag. Use lock for a short block with no await in which several variables must stay consistent together. Use SemaphoreSlim when you need await inside the protected region, or when you want to allow N jobs at once. When you need a dictionary, queue or bag shared between threads, use a concurrent collection instead of writing your own locking.
Common Pitfalls
Writing lock (this), lock (typeof(Book)) or lock ("stock"). These are objects you don't control: any outside code can lock this, and because string literals are interned, the same string elsewhere in the program is the same object. Always use private readonly object _gate = new();.
Taking two locks in different orders. If one method takes _ordersGate and then _stockGate while another does the reverse, two threads wait for each other forever; it's a different deadlock from the one in the series' deadlock post, but just as silent. If you need several locks, take them in the same order everywhere, and reduce them to a single lock if you can.
Assuming SemaphoreSlim is reentrant like lock. The thread holding a lock can take the same lock again; SemaphoreSlim doesn't know who holds it:
object monitor = new();
lock (monitor)
{
lock (monitor)
{
Console.WriteLine("lock: re-entered on the same thread");
}
}
var gate = new SemaphoreSlim(1, 1);
await gate.WaitAsync();
bool acquiredAgain = await gate.WaitAsync(TimeSpan.FromMilliseconds(100));
Console.WriteLine($"SemaphoreSlim: second wait acquired = {acquiredAgain}");
lock: re-entered on the same thread
SemaphoreSlim: second wait acquired = False
Without the timeout, the second WaitAsync would have waited forever. Don't call a method that takes the lock from inside another method that already holds it; take the lock in the outer method and write the inner helper without locking.
Sharing a plain Dictionary between threads. If it's only read, there's no problem; if even one thread writes, the dictionary's internal structure can get corrupted, loop forever or throw. Either protect it with a lock or move to ConcurrentDictionary.
Thinking everything is safe once you use a concurrent collection. if (!dict.ContainsKey(k)) dict.TryAdd(k, v); is two separate atomic calls, with a check-then-act race between them. Use the method that does it in one call: GetOrAdd, AddOrUpdate, TryUpdate.
A Small Experiment
★☆☆ Fix the counter from the guessing question first with lock, then with Interlocked.Increment. Then write a third solution with the thread-local Parallel.For overload from the previous post. Which one is lock-free, and which one synchronizes on every iteration?
★★☆ Add Restock(int quantity) and TryReserve(string isbn, int quantity) to SafeShelf, and keep the stock per ISBN in a Dictionary<string, int>. One lock, or a separate lock per ISBN? Write down the pros and cons of each.
★★★ Change CatalogCache so that the cache goes stale after 5 minutes and gets reloaded. While it's refreshing, is it better to keep serving the old data or to make requests wait? If LoadFromDatabaseAsync throws, what should the waiting requests see?
When we covered the concurrent collections, we said BlockingCollection has a counterpart in the async world. In the next post we build producer/consumer with System.Threading.Channels: writing orders at one end and reading them at the other, slowing the producer down when the queue is full, and sharing the work among several consumers.