C# & .NET (C# 14, .NET 10 LTS) Module 16 – Threads, parallelism and synchronisation
Concurrent collections and channels
Share data between C# threads with ConcurrentDictionary and ConcurrentQueue, and pass work through bounded channels that make a fast producer wait.
What you will learn
- Update shared data from many threads with one atomic call, such as AddOrUpdate, instead of a read and a write
- Choose a concurrent dictionary, queue, stack or bag, or a channel, for a given hand-over between threads
- Build a producer and a consumer on a bounded channel, and end the consumer with Writer.Complete
- Choose a full mode for a bounded channel from what its data means
- Pass an error and a cancel through a pipeline of channels
Before you start
What you’ll learn · Before you start
What you will learn
- Update shared data from many threads with one atomic call, such as AddOrUpdate, instead of a read and a write
- Choose a concurrent dictionary, queue, stack or bag, or a channel, for a given hand-over between threads
- Build a producer and a consumer on a bounded channel, and end the consumer with Writer.Complete
- Choose a full mode for a bounded channel from what its data means
- Pass an error and a cancel through a pipeline of channels
Before you start
On this page
Eight threads counted 800,000 page views into one ConcurrentDictionary, and the code that read a count and then
wrote it back recorded only 680,274 of them. The C# concurrent collections make each call safe, not two calls in a row.
You will update shared data with one call per change, pass work from producers to consumers through channels, and bound a channel so that a fast producer waits instead of filling memory.
Concurrent collections make each call atomic, not a sequence of calls
System.Collections.Concurrent holds collections that many threads can use at once without a lock of your own.
counts.cs lets eight threads count 100,000 page views each, spread over four pages, into two dictionaries: one
updated with TryGetValue and then an indexer set, the other with AddOrUpdate.
using System.Collections.Concurrent;
// Eight threads each count 100,000 page views, spread over four pages, into one ConcurrentDictionary.
string[] pages = ["/home", "/cart", "/pay", "/help"];
const int Threads = 8;
const int ViewsPerThread = 100_000;
var checkThenSet = new ConcurrentDictionary<string, int>();
var addOrUpdate = new ConcurrentDictionary<string, int>();
RunThreads(i =>
{
string page = pages[i % pages.Length];
// Two safe calls in a row are not one safe operation: another thread can update the page in between.
if (checkThenSet.TryGetValue(page, out int views))
{
checkThenSet[page] = views + 1;
}
else
{
checkThenSet[page] = 1;
}
// One call that reads, adds and stores for its key, and tries again if another thread got there first.
addOrUpdate.AddOrUpdate(page, 1, (_, current) => current + 1);
});
Console.WriteLine($"expected: {Threads * ViewsPerThread:N0} views");
Console.WriteLine($"TryGetValue, then set: {checkThenSet.Values.Sum():N0} views");
Console.WriteLine($"AddOrUpdate: {addOrUpdate.Values.Sum():N0} views");
foreach (string page in pages)
{
Console.WriteLine($" {page,-6} {addOrUpdate[page],7:N0}");
}
void RunThreads(Action<int> view)
{
using var start = new Barrier(Threads);
var threads = new Thread[Threads];
for (int t = 0; t < Threads; t++)
{
threads[t] = new Thread(() =>
{
start.SignalAndWait();
for (int i = 0; i < ViewsPerThread; i++)
{
view(i);
}
});
threads[t].Start();
}
foreach (Thread thread in threads)
{
thread.Join();
}
} Output
expected: 800,000 views TryGetValue, then set: 680,274 views AddOrUpdate: 800,000 views /home 200,000 /cart 200,000 /pay 200,000 /help 200,000
This output changes from run to run: how many views the two-call version loses changes from run to run
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run counts.cs
Both dictionaries stayed intact: no exception, no corrupted table. Yet the first lost 119,726 views, because another
thread could change a page’s count between the read and the write, the lost update of the lesson on locks.
AddOrUpdate reads, computes and stores in one call, and tries again when another thread got there first, so it
counted all 800,000. The C# concurrent collections give you one atomic call per operation; an update that takes two
calls is still a race. The collections, and what each one is for:
| Collection | Gives items back | Use it for |
|---|---|---|
ConcurrentDictionary<TKey, TValue> |
by key | shared lookups and counts, changed with AddOrUpdate, GetOrAdd, TryUpdate |
ConcurrentQueue<T> |
first in, first out | handing items to other threads in order |
ConcurrentStack<T> |
last in, first out | reusing the most recent item first |
ConcurrentBag<T> |
in no order | threads that add and take their own items |
BlockingCollection<T> |
as its inner collection | the older way to bound and block: Take blocks a thread |
Channel<T> |
first in, first out | async producers and consumers: the next section |
Microsoft’s guide to choosing a thread-safe collection adds the trade-offs: a Dictionary that is only read needs no
synchronisation at all, and ConcurrentBag does best when the threads that add items are also the ones that take
them. ConcurrentQueue<T> is the plain hand-over in order: Enqueue adds an item at the back, and TryDequeue takes
the one at the front. When the queue is empty, TryDequeue returns false at once, so a consumer that must wait for
work needs something to wait on, which is what a channel adds. One trap stays inside ConcurrentDictionary itself.
GetOrAdd runs its value factory outside its locks, so under contention the factory can run more than once, though
only one value is stored.
Checkpoint
Check: One atomic call per update
A bounded channel passes items along and makes a fast producer wait
A channel: A Channel<T> from System.Threading.Channels: a first-in, first-out queue that producers write to and consumers read from, both with await. A bounded channel holds at most its capacity; an unbounded one accepts every write., from System.Threading.Channels, is a first-in, first-out queue with two ends: producers write to
channel.Writer, consumers read from channel.Reader, and both sides wait with await, without blocking a thread.
Channel.CreateUnbounded<T>() accepts every write at once. Channel.CreateBounded<T>(capacity) holds at most
capacity items, and its full mode: The setting of a bounded channel that decides what a write does when the channel is full. Wait, the default, makes WriteAsync wait for room; DropOldest, DropNewest and DropWrite throw an item away instead. decides what a write does when it is full. bounded.cs fills a channel of
capacity 2 and reads it out:
using System.Threading.Channels;
// A channel that holds at most 2 orders. The writer is the producer's end, the reader the consumer's.
Channel<int> orders = Channel.CreateBounded<int>(2);
Console.WriteLine($"TryWrite(1): {orders.Writer.TryWrite(1)}");
Console.WriteLine($"TryWrite(2): {orders.Writer.TryWrite(2)}");
Console.WriteLine($"TryWrite(3): {orders.Writer.TryWrite(3)}, the channel is full");
ValueTask write3 = orders.Writer.WriteAsync(3);
Console.WriteLine($"WriteAsync(3) completed: {write3.IsCompleted}, it waits for room");
int first = await orders.Reader.ReadAsync();
Console.WriteLine($"ReadAsync() took order {first}; WriteAsync(3) completed: {write3.IsCompleted}");
await write3;
orders.Writer.Complete(); // no more orders: readers finish once the channel is empty
await foreach (int order in orders.Reader.ReadAllAsync())
{
Console.WriteLine($"ReadAllAsync() took order {order}");
}
Console.WriteLine($"ReadAllAsync() ended; Reader.Completion is {orders.Reader.Completion.Status}");
// A producer and a slower consumer: the producer can never get more than 2 orders ahead.
Channel<int> pipe = Channel.CreateBounded<int>(2);
int maxWaiting = 0;
Task producer = Task.Run(async () =>
{
for (int order = 1; order <= 5; order++)
{
await pipe.Writer.WriteAsync(order);
maxWaiting = Math.Max(maxWaiting, pipe.Reader.Count);
}
pipe.Writer.Complete();
});
int total = 0;
await foreach (int order in pipe.Reader.ReadAllAsync())
{
await Task.Delay(20); // the consumer takes 20 ms an order
total += order;
}
await producer;
Console.WriteLine($"orders 1 to 5 added up to {total}; at most {maxWaiting} orders ever waited in the channel"); Output
TryWrite(1): True TryWrite(2): True TryWrite(3): False, the channel is full WriteAsync(3) completed: False, it waits for room ReadAsync() took order 1; WriteAsync(3) completed: True ReadAllAsync() took order 2 ReadAllAsync() took order 3 ReadAllAsync() ended; Reader.Completion is RanToCompletion orders 1 to 5 added up to 15; at most 2 orders ever waited in the channel
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run bounded.cs
The third TryWrite returned false, and WriteAsync(3) did not complete until ReadAsync had taken order 1: the
producer waited for room. That is back-pressure: A consumer setting the pace of its producer. An async stream has it built in: the producer runs only when the consumer asks for the next item, so items never pile up between them. More in “Async streams with IAsyncEnumerable”, the consumer setting the pace of its
producer. Step through it:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Two orders go in. TryWrite(1) and TryWrite(2) both return true: the channel had room for two orders.
- A third does not fit. TryWrite(3) returns false at once. In the default full mode, Wait, a write never throws away an order.
- WriteAsync waits. WriteAsync(3) does not complete: the producer waits, without a blocked thread, until the channel has room. This is the back-pressure of a bounded channel.
- The consumer takes one. ReadAsync takes order 1. That makes room, so the waiting write of order 3 completes at once.
- Complete. The producer calls Writer.Complete: no more orders will come. Orders already in the channel can still be read.
- ReadAllAsync ends. ReadAllAsync gives orders 2 and 3, then ends, because the channel is both complete and empty. Reader.Completion ends RanToCompletion.
Writer.Complete() says that no more items will come. Items written before it are still read, and ReadAllAsync
ends only when the channel is complete and empty. In the second half of bounded.cs, a producer of five orders met a
consumer that takes 20 ms an order: the orders added up to 15, and the channel never held more than 2.
The default full mode, Wait, loses nothing. The other three drop an item instead of waiting. DropOldest removes the
oldest item to make room, DropNewest removes the newest item already in the channel, and DropWrite drops the item
being written. SingleReader and SingleWriter in a channel’s options promise one reader or one writer, which
Microsoft’s channels article says lets the channel work more efficiently.
Key idea
A bounded channel turns a fast producer’s lead into waiting: the producer never gets more than the capacity ahead, so memory stays bounded and the work flows at the consumer’s pace.
Checkpoint
Check: Bounded channels and completion
GetOrAdd and AddOrUpdate under contention, measured Pro: opens with a Pro, Premium or Ultimate pass
The delegates you pass to GetOrAdd and AddOrUpdate run outside the dictionary’s locks, and can run more often than the update they serve. This part measures how often, shows what that does to a delegate with side effects, and makes the factory run once per key.
In this part:
- A factory that ran eight times for one key
- AddOrUpdate runs its update again when it loses a race
1 diagram · 1 runnable example (C#) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Full modes and back-pressure, measured Pro: opens with a Pro, Premium or Ultimate pass
A bounded channel must decide what to do when a producer outruns its consumer. This part measures what each of the four full modes keeps and drops, shows where a backlog waits with and without a bound, and helps you choose the mode for your data.
In this part:
- Six readings into a channel of three, in each full mode
- Where a backlog waits: unbounded against bounded
- Sizing the bound, with numbers
1 runnable example (C#) · about 6 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
A lab: a log pipeline that fails and stops cleanly Pro: opens with a Pro, Premium or Ultimate pass
A pipeline of channels has to end in two more ways than a single loop: when one stage fails, and when the caller cancels. This lab gives you a three-stage log counter that handles neither, and four checks to make pass.
In this part:
- The four checks, and the two that fail
- Completing every stage, with or without an error
1 runnable example (C#) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise
The self-check covers the first two sections: one atomic call per update, and bounded channels with their completion. It starts where your answers so far were least sure:
Practice that adapts to you
Practise this lesson
6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What does a bounded channel do when it is full?
That depends on its full mode, chosen when you create it. In the default mode, Wait, nothing is lost: TryWrite
returns false at once, and WriteAsync waits until a consumer reads an item and makes room, without blocking a
thread. That wait is the channel’s back-pressure: a producer can never get more than the capacity ahead of its
consumer, so memory stays bounded. The three drop modes never make the producer wait and throw an item away instead:
DropOldest the oldest item in the channel, DropNewest the newest item in it, and DropWrite the item being
written. Choose Wait for data that must all be processed, such as orders, and a drop mode only where losing some is
acceptable, such as live readings.
-
Mid to senior
A currency service caches exchange rates in a ConcurrentDictionary with GetOrAdd(code, LoadRates). During a traffic spike the rates provider starts refusing calls, because it received 40 identical requests in one second. Why, and what do you change?
-
Mid to senior
A service writes telemetry events into Channel.CreateUnbounded and a background consumer posts them in batches to a collector. During a two-hour collector outage the process grew by 3 GB and was restarted, losing every queued event. What would you change?
-
Senior to staff
A log pipeline of a reader, four parsers and an aggregator, joined by channels, sometimes reports counts that are too low, and nothing is logged. A malformed line was found in the input. Explain how the error disappeared, and how errors should travel through such a pipeline.
Key takeaways
- A concurrent collection makes each call atomic, so a read and a write in two calls still lose updates: counts.cs lost 119,726 of 800,000 views.
- Change shared values with one call that does the whole update, such as
AddOrUpdate,GetOrAddorTryUpdate. - A bounded channel holds at most its capacity, and in the default
Waitmode a full channel makesWriteAsyncwait. Writer.Completeends the readers once the channel is empty, and a producer that never completes leaves them waiting.
Terms in this lesson
- channel
- A Channel<T> from System.Threading.Channels: a first-in, first-out queue that producers write to and consumers read from, both with await. A bounded channel holds at most its capacity; an unbounded one accepts every write.
- full mode
- The setting of a bounded channel that decides what a write does when the channel is full. Wait, the default, makes WriteAsync wait for room; DropOldest, DropNewest and DropWrite throw an item away instead.
References
- Channels (Microsoft Learn)
- When to use a thread-safe collection (Microsoft Learn)
- ConcurrentQueue<T> class (Microsoft Learn)
- ConcurrentDictionary<TKey,TValue> class (Microsoft Learn)
- ConcurrentDictionary<TKey,TValue>.GetOrAdd method (Microsoft Learn)
- ConcurrentDictionary<TKey,TValue>.GetEnumerator method (Microsoft Learn)
- BoundedChannelFullMode enum (Microsoft Learn)
- ChannelWriter<T>.TryComplete method (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress