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 — 15.3× faster than the baseline.
In this run: ValueTask_completed is fastest — 15.3× faster than the baseline.
RESULTS
| method | net9.0 | net10.0 | ratio | allocated | Δ net10.0 |
|---|---|---|---|---|---|
| Task_completed baseline | 88.9 µs ±11.5 µs | 90.9 µs ±13.8 µs | 1.00× | 702.6 KB | +2% |
| ValueTask_completed | 7.9 µs ±31 ns | 5.9 µs ±41 ns | 0.07× | 72 B | -24% |
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.2 µs ±1.5 µs | 463.3 µs ±6.3 µs | 1.00× | 130.8 KB | -2% |
| Bounded_channel | 529.4 µs ±1.1 µs | 501.7 µs ±4.7 µs | 1.08× | 129.4 KB | -5% |
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