BENCHMARKS · ASYNC MACHINERY
Task vs ValueTask overhead and channel throughput. Suite and raw reports: github.com/gdhami-net/dotnet-benchmarks.
AwaitOverhead
Await 10,000 already-completed operations: Task vs ValueTask — the overhead argument, measured.
TAKEAWAY ValueTask pays off when a method usually completes synchronously (caches, buffered readers). If the result is usually awaited across threads or stored, plain Task stays simpler and safe.
In this run: ValueTask_completed is fastest — 10.9× faster than the baseline.
In this run: ValueTask_completed is fastest — 10.9× faster than the baseline.
RESULTS
| method | net9.0 | net10.0 | ratio | allocated | Δ net10.0 |
|---|---|---|---|---|---|
| Task_completed baseline | 73.5 µs ±1.7 µs | 75.0 µs ±2.0 µs | 1.00× | 702.6 KB | +2% |
| ValueTask_completed | 5.9 µs ±20 ns | 6.9 µs ±12 ns | 0.09× | 72 B | +17% |
RATIO VS BASELINE · net10.0
THE LEDGER · SAME WORKLOAD ACROSS RELEASES (LOG)
Task_completedValueTask_completed
THE CODE BEING MEASURED
Task_completed — what this measures
Awaiting Task.FromResult in a loop — one allocation per call when not cached.
[Benchmark (Baseline)]
public async Task<int> Task_completed()
{
var sum = 0;
for (var i = 0; i < 10_000; i++) sum += await GetTask(i);
return sum;
}ValueTask_completed — what this measures
Awaiting a completed ValueTask — no allocation on the synchronous path.
[Benchmark]
public async Task<int> ValueTask_completed()
{
var sum = 0;
for (var i = 0; i < 10_000; i++) sum += await GetValueTask(i);
return sum;
}ChannelThroughput
Push 10,000 items through a Channel and read them back: unbounded vs bounded.
TAKEAWAY Bounded channels cost a little more per item - that's the price of backpressure, and on a real queue you almost always want it. Unbounded means your memory is the backpressure.
In this run: Unbounded_channel is fastest (it is the baseline).
In this run: Unbounded_channel is fastest (it is the baseline).
RESULTS
| method | net9.0 | net10.0 | ratio | allocated | Δ net10.0 |
|---|---|---|---|---|---|
| Unbounded_channel baseline | 472.4 µs ±1.2 µs | 458.8 µs ±1.1 µs | 1.00× | 130.8 KB | -3% |
| Bounded_channel | 524.7 µs ±983 ns | 494.2 µs ±878 ns | 1.08× | 129.4 KB | -6% |
RATIO VS BASELINE · net10.0
THE LEDGER · SAME WORKLOAD ACROSS RELEASES (LOG)
Unbounded_channelBounded_channel
THE CODE BEING MEASURED
Unbounded_channel — what this measures
Channel.CreateUnbounded — no backpressure, no capacity checks.
[Benchmark (Baseline)]
public async Task<int> Unbounded_channel()
{
var ch = Channel.CreateUnbounded<int>();
for (var i = 0; i < 10_000; i++) await ch.Writer.WriteAsync(i);
ch.Writer.Complete();
var sum = 0;
await foreach (var v in ch.Reader.ReadAllAsync()) sum += v;
return sum;
}Bounded_channel — what this measures
Channel.CreateBounded with room for everything — the capacity bookkeeping alone.
[Benchmark]
public async Task<int> Bounded_channel()
{
var ch = Channel.CreateBounded<int>(10_000);
for (var i = 0; i < 10_000; i++) await ch.Writer.WriteAsync(i);
ch.Writer.Complete();
var sum = 0;
await foreach (var v in ch.Reader.ReadAllAsync()) sum += v;
return sum;
}speed vs allocation
Fast is one axis. What it costs the GC is the other.
Task_completedValueTask_completedUnbounded_channelBounded_channel