C#
.NET
concurrency
BlockingCollection
performance optimization

Channel/BlockingCollection alloc free alternatives?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

High-throughput pipelines in .NET often suffer from garbage collection pauses when each message allocates temporary objects. Many teams ask for an "allocation-free queue" as a silver bullet, but the queue is only one part of memory pressure. The effective strategy is combining modern queue primitives with pooled buffers and predictable message shapes.

Measure Before Replacing Primitives

BlockingCollection can be good enough in moderate workloads, and replacing it without profiling can waste time. First identify where allocations happen in your hot path.

A quick runtime check:

bash
dotnet-counters monitor --process-id 12345 System.Runtime

Watch allocation rate and GC counts while running representative load. If allocations remain high after queue replacement, root cause is likely payload creation, serialization, logging, or per-item task creation.

Prefer Bounded Channel for Async Pipelines

For modern async producer and consumer workloads, System.Threading.Channels usually gives better ergonomics and backpressure behavior than BlockingCollection.

csharp
1using System.Threading.Channels;
2
3var channel = Channel.CreateBounded<int>(new BoundedChannelOptions(2048)
4{
5    SingleWriter = false,
6    SingleReader = true,
7    FullMode = BoundedChannelFullMode.Wait
8});

Bounded capacity avoids unbounded heap growth during spikes. FullMode.Wait slows producers instead of dropping work.

Producer and consumer example:

csharp
1var producer = Task.Run(async () =>
2{
3    for (int i = 0; i < 100_000; i++)
4    {
5        await channel.Writer.WriteAsync(i);
6    }
7    channel.Writer.Complete();
8});
9
10var consumer = Task.Run(async () =>
11{
12    await foreach (var item in channel.Reader.ReadAllAsync())
13    {
14        _ = item * 2;
15    }
16});
17
18await Task.WhenAll(producer, consumer);

Reduce Payload Allocations with ArrayPool

If each message carries bytes, pooling can eliminate repeated large allocations.

csharp
1using System.Buffers;
2
3byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
4try
5{
6    // fill and process buffer
7}
8finally
9{
10    ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
11}

The ownership rule is strict: one renter, one return, exactly once. Violating ownership causes subtle data corruption.

Use Reusable Message Objects Carefully

For reference-heavy objects, reuse can work with ObjectPool from Microsoft.Extensions.ObjectPool.

csharp
1using Microsoft.Extensions.ObjectPool;
2
3public sealed class WorkItem
4{
5    public int Id;
6    public byte[] Data = new byte[1024];
7
8    public void Reset()
9    {
10        Id = 0;
11        Array.Clear(Data, 0, Data.Length);
12    }
13}
14
15var provider = new DefaultObjectPoolProvider();
16var pool = provider.Create<WorkItem>();
17
18WorkItem item = pool.Get();
19try
20{
21    item.Id = 42;
22    // process
23}
24finally
25{
26    item.Reset();
27    pool.Return(item);
28}

Object pools help only when object setup cost is high enough to justify complexity.

Choose Value Types for Tiny Metadata

Small immutable metadata can use value types to reduce reference allocations.

csharp
public readonly record struct QueueToken(int Id, int Partition);

Do not make large structs in hot paths, because copy overhead can offset allocation savings.

Build an End-to-End Low-Allocation Loop

Combine bounded channel, pooling, and explicit cancellation:

csharp
1using var cts = new CancellationTokenSource();
2
3var ch = Channel.CreateBounded<QueueToken>(1024);
4
5Task writer = Task.Run(async () =>
6{
7    for (int i = 0; i < 10_000; i++)
8    {
9        await ch.Writer.WriteAsync(new QueueToken(i, i % 4), cts.Token);
10    }
11    ch.Writer.Complete();
12});
13
14Task reader = Task.Run(async () =>
15{
16    await foreach (var token in ch.Reader.ReadAllAsync(cts.Token))
17    {
18        byte[] rented = ArrayPool<byte>.Shared.Rent(256);
19        try
20        {
21            rented[0] = (byte)token.Partition;
22        }
23        finally
24        {
25            ArrayPool<byte>.Shared.Return(rented);
26        }
27    }
28});
29
30await Task.WhenAll(writer, reader);

This pattern keeps memory bounded and supports orderly shutdown.

Validate with Realistic Benchmarks

Compare designs under burst and steady traffic. Include payload size, percentiles for queue latency, and GC pause behavior. Average throughput alone can hide serious tail latency regressions.

A queue strategy is successful only if it improves total service behavior, not just microbenchmark operations per second.

Common Pitfalls

  • Chasing queue replacement without profiling the full pipeline.
  • Using unbounded queues and exhausting memory under load spikes.
  • Returning pooled buffers incorrectly or multiple times.
  • Reusing mutable objects without complete reset logic.
  • Measuring only throughput and ignoring p95 and p99 latency.

Summary

  • Allocation pressure is usually pipeline-wide, not queue-only.
  • Bounded Channel is a strong default for async producer and consumer flows.
  • ArrayPool and object pooling can cut heap churn when ownership is explicit.
  • Small value types help for metadata, but large structs can hurt performance.
  • Validate changes with realistic load tests that include latency and GC metrics.

Course illustration
Course illustration

All Rights Reserved.