Thread safety and channels
Share data between concurrent operations safely — locks, Interlocked, concurrent collections and channels.
13 min read
You will learn
- Explain race conditions
- Protect shared state with lock and Interlocked
- Use concurrent collections
- Build a producer/consumer pipeline with Channel<T>
When concurrency meets shared data
As soon as two operations run at the same time and touch the same data, things can go wrong in ways that are hard to reproduce. A web server is concurrent by nature: many requests run at once, so any static mutable state or shared singleton service is at risk.
Race conditions
int visits = 0;
Parallel.For(0, 100_000, _ =>
{
visits++; // ❌ read → add → write: not atomic
});
Console.WriteLine(visits); // often less than 100,000 on a multi-core machine!visits++ is really three steps. Two threads can both read 41, both add one, and both write 42 — one update is lost. Bugs like this depend on timing, so they appear under load and vanish under the debugger.
About this course's editor
Code in your browser runs on a single thread, so races won't show up here. On a real server they absolutely do.
Fix 1: Interlocked for simple counters
Interlocked.Increment(ref visits); // atomic add
Interlocked.Add(ref total, amount);
Interlocked.CompareExchange(ref state, newValue, expected);Fix 2: lock for anything bigger
private readonly object gate = new();
private decimal balance;
public bool Withdraw(decimal amount)
{
lock (gate) // one thread at a time in here
{
if (amount > balance) return false; // check and update must happen together
balance -= amount;
return true;
}
}Keep locked sections short, always lock on a private object, and never await inside a lock (the compiler won't let you — use SemaphoreSlim.WaitAsync for async code).
Fix 3: concurrent collections
List<T> and Dictionary<TKey, TValue> are not thread-safe for writes. Use the concurrent versions:
var cache = new ConcurrentDictionary<string, Product>();
var product = cache.GetOrAdd(id, key => LoadProduct(key));
var queue = new ConcurrentQueue<Job>();
queue.Enqueue(job);
if (queue.TryDequeue(out var next)) { ... }Fix 4 (best): don't share mutable state
The safest shared data is data that never changes. Immutable records, ImmutableList<T>, and passing messages between workers instead of sharing objects eliminate whole classes of bugs.
Channels: async producer/consumer
System.Threading.Channels provides async-friendly queues: producers write items, consumers read them, and nobody blocks a thread.
var channel = Channel.CreateBounded<Order>(capacity: 100); // back-pressure when full
// Producer — e.g. an API endpoint enqueuing work
await channel.Writer.WriteAsync(order);
// Consumer — e.g. a background service
await foreach (var order in channel.Reader.ReadAllAsync(stoppingToken))
{
await ProcessAsync(order);
}A bounded channel makes producers wait when it's full, so a burst can't exhaust memory. This is the in-process version of the message queues you'll meet in Backend Fundamentals.
Parallelism for CPU-bound work
For CPU-heavy work on many items, spread it across cores:
Parallel.ForEach(images, image => Resize(image)); // CPU-bound
await Parallel.ForEachAsync(urls, async (url, ct) => await DownloadAsync(url, ct)); // I/O with a concurrency limitTry it
C# runtime not loaded
Key takeaways
- Concurrent access to shared mutable data causes race conditions.
- Use
Interlockedfor counters,lockfor compound updates, concurrent collections for shared maps and queues. - Prefer immutability and message passing over shared state.
Channel<T>is the async producer/consumer queue, with back-pressure when bounded.
Exercise
Build two producer/consumer pipelines with System.Threading.Channels. SumViaChannelAsync writes the numbers 1..count into a channel from a producer task while a consumer reads and sums them. ProcessOrdersAsync feeds the orders into a channel and starts `workers` consumers that each record "processed <order>"; return the results sorted alphabetically.
C# runtime not loaded
Hint 1
Producer: var producer = Task.Run(async () => { for (int i = 1; i <= count; i++) await channel.Writer.WriteAsync(i); channel.Writer.Complete(); });
Hint 2
Consumer: await foreach (var n in channel.Reader.ReadAllAsync()) sum += n; — the loop ends when the writer completes. Then await producer.
Hint 3
For ProcessOrdersAsync, start several consumers with Enumerable.Range(0, workers).Select(_ => Task.Run(async () => { await foreach (...) results.Add(...); })) and await Task.WhenAll.
Show solution
using System.Collections.Concurrent;
using System.Threading.Channels;
Console.WriteLine(await SumViaChannelAsync(10));
async Task<int> SumViaChannelAsync(int count)
{
var channel = Channel.CreateUnbounded<int>();
var producer = Task.Run(async () =>
{
for (int i = 1; i <= count; i++)
await channel.Writer.WriteAsync(i);
channel.Writer.Complete();
});
int sum = 0;
await foreach (var n in channel.Reader.ReadAllAsync())
sum += n;
await producer;
return sum;
}
async Task<List<string>> ProcessOrdersAsync(string[] orders, int workers)
{
var results = new ConcurrentBag<string>();
var channel = Channel.CreateUnbounded<string>();
foreach (var order in orders)
await channel.Writer.WriteAsync(order);
channel.Writer.Complete();
var consumers = Enumerable.Range(0, workers).Select(_ => Task.Run(async () =>
{
await foreach (var order in channel.Reader.ReadAllAsync())
results.Add($"processed {order}");
}));
await Task.WhenAll(consumers);
return results.Order().ToList();
}Quiz
4 questions · 3 correct to pass1.Two threads run `count++` 1,000 times each on a shared int. Why might the result be less than 2,000?
2.What does a lock statement guarantee?
3.Which collection is safe to add to from many threads at once without your own locking?
4.What problem does Channel<T> solve?
To complete this lesson
- ○ Solve the exercise
- ○ Pass the quiz (3/4 correct)