Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 20 minutes
  • Examples run with .NET 10.0.401
  • By MySmartCoPilot
Free core, Pro depth: 8 of 20 minutes free
Free: Concurrent collections make each call atomic, not a sequence of calls · A bounded channel passes items along and makes a fast producer wait. With a pass: 3 in-depth parts and 3 interview answers, about 12 minutes.

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.

counts.cs: two ways to count page views from eight threads C# · counts/counts.cs
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

  1. Question 1 of 2 In counts.cs, "TryGetValue, then set" recorded 680,274 of 800,000 views, on a ConcurrentDictionary. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Each call was safe, but another thread could update the page between the read and the write

    A concurrent collection makes each call atomic. A read and a write in two calls are two operations, so a read-modify-write needs one call that does both, such as AddOrUpdate.

  2. Question 2 of 2 Several threads add log lines, and one thread writes them to a file in the order they arrived. Which collection fits?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: ConcurrentQueue<string>

    Choose the concurrent collection by the order you need: queue for FIFO, stack for LIFO, bag for no order, and a dictionary for lookups by key.

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:

bounded.cs: a channel of two orders C# · bounded/bounded.cs
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:

Interactive · message by message A full channel makes its producer wait Step through bounded.cs. The producer is on the left, the channel of capacity 2 in the middle, the consumer on the right.
A full channel makes its producer wait, step 6 of 6. Messages so far: Producer to Channel, capacity 2: orders 1 and 2; Producer to Channel, capacity 2: TryWrite(3) false; Channel, capacity 2 to Consumer: order 1; Producer to Channel, capacity 2: Complete(); Channel, capacity 2 to Consumer: orders 2, 3, then the end.Producer3 writtenChannel, capacity 2empty, completedConsumerthe Reader endorders 1 and 2TryWrite(3) falseorder 1Complete()orders 2, 3, then the end

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Two orders go in. TryWrite(1) and TryWrite(2) both return true: the channel had room for two orders.
  2. A third does not fit. TryWrite(3) returns false at once. In the default full mode, Wait, a write never throws away an order.
  3. 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.
  4. The consumer takes one. ReadAsync takes order 1. That makes room, so the waiting write of order 3 completes at once.
  5. Complete. The producer calls Writer.Complete: no more orders will come. Orders already in the channel can still be read.
  6. 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

  1. Question 1 of 2 A bounded channel of capacity 2, in the default full mode, already holds 2 items. What do TryWrite and WriteAsync do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: TryWrite returns false at once; WriteAsync waits until a reader makes room

    The waiting WriteAsync is the back-pressure: a producer cannot get more than the capacity ahead of its consumer.

  2. Question 2 of 2 A consumer runs await foreach over reader.ReadAllAsync(). When does the loop end?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: When the writer has called Complete and the channel is empty

    A producer that never calls Complete leaves every ReadAllAsync loop waiting for ever.

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.

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.

  1. Question 1 of 6 What does a concurrent collection guarantee about two calls in a row, such as TryGetValue and then an indexer set?

    Choose one answer.

    Show the answer: Question 1 of 6

    Answer: Nothing: each call is atomic on its own, and other threads can act between them

    Use the method that does the whole update: AddOrUpdate, GetOrAdd, TryUpdate, TryRemove.

  2. Question 2 of 6 Which call counts a page view correctly from many threads at once?

    Choose one answer.

    Show the answer: Question 2 of 6

    Answer: views.AddOrUpdate(page, 1, (_, n) => n + 1)

    AddOrUpdate counted all 800,000 views in counts.cs.

  3. Question 3 of 6 An older service hands work between threads with BlockingCollection<T>. Its consumers are async methods. What is the problem, and the newer tool?

    Choose one answer.

    Show the answer: Question 3 of 6

    Answer: Take blocks a thread while it waits; a channel lets an async consumer wait with await instead

    BlockingCollection suits threads that may block. Async code passes items through channels.

  4. Question 4 of 6 In the second half of bounded.cs, the producer writes orders 1 to 5 into a bounded channel of capacity 2, and the consumer takes 20 ms an order. At most how many orders ever waited in the channel?

    Type a number.

    Show the answer: Question 4 of 6

    Answer: 2 orders

    The run printed "at most 2 orders ever waited": the capacity. The producer then waited in WriteAsync.

  5. Question 5 of 6 A producer writes 100 items and returns without calling Writer.Complete. What does the consumer's await foreach over ReadAllAsync do?

    Choose one answer.

    Show the answer: Question 5 of 6

    Answer: Reads the 100 items, then waits for ever for the next one

    Call Writer.Complete when the producer is done, in a finally block, so that it runs even when the producer fails.

  6. Question 6 of 6 What is the full mode of Channel.CreateBounded<T>(10) when you give no options?

    Choose one answer.

    Show the answer: Question 6 of 6

    Answer: Wait: a write waits for room, and nothing is dropped

    The drop modes, DropOldest, DropNewest and DropWrite, are chosen with BoundedChannelOptions.FullMode.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

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, GetOrAdd or TryUpdate.
  • A bounded channel holds at most its capacity, and in the default Wait mode a full channel makes WriteAsync wait.
  • Writer.Complete ends 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.