Observed regression
Replacing the wake-up semaphore in Kevlar's concurrency limiter with UnpairedAsyncSemaphore 2.1.0 increased the time for a guaranteed queued handoff from 2.081 us to 2.378 us (+14.3%). Repeating the original baseline returned 2.083 us. Allocation improved from 1.62 KB to 1.53 KB per pair of executions.
| Phase |
Mean |
Error |
Allocated per pair |
| SemaphoreSlim baseline |
2.081 us |
0.0722 us |
1.62 KB |
| UnpairedAsyncSemaphore 2.1.0 |
2.378 us |
0.2109 us |
1.53 KB |
| SemaphoreSlim repeat |
2.083 us |
0.0647 us |
1.62 KB |
This exercises the signal-style API added following #581. Permit ownership remains in the limiter's existing interlocked state; the semaphore only wakes queued callers. No limiter accounting rewrite is involved.
Reproduction
Start from Kevlar commit 3f078a96. Add this benchmark to benchmarks/Kevlar.Benchmarks for both versions:
using BenchmarkDotNet.Attributes;
namespace Kevlar.Benchmarks;
[MemoryDiagnoser]
public class ConcurrencyLimitContentionBenchmarks
{
private readonly Shield _shield = Shield.ConcurrencyLimit(maxConcurrency: 1, queueLimit: 1);
[Benchmark]
public async Task<int> QueuedHandoff()
{
var release = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
var running = _shield.ExecuteAsync(
release,
static (source, _) => new ValueTask<int>(source.Task));
var queued = _shield.ExecuteAsync(static _ => new ValueTask<int>(42));
release.SetResult(1);
await running.ConfigureAwait(false);
return await queued.ConfigureAwait(false);
}
}
The completion source ensures the second execution queues before the first is released. One invocation measures the complete pair, including completion-source/task overhead, rather than just the semaphore handoff.
For the candidate:
- Add an
AsyncSemaphore package reference to src/Kevlar/Kevlar.csproj, with central version 2.1.0 in Directory.Packages.props. Raise the existing Microsoft.Bcl.AsyncInterfaces central version to 9.0.8 to satisfy the netstandard2.0 dependency.
- In ConcurrencyLimitStrategy.cs, add
using Semaphores;, change the field and constructor from SemaphoreSlim to UnpairedAsyncSemaphore, retaining initial count 0, and replace _semaphore.Wait(0) with _semaphore.TryWait().
- Leave all state accounting, cancellation handling, waits, and releases otherwise unchanged.
Run sequentially for baseline, candidate, then baseline:
dotnet run --project benchmarks/Kevlar.Benchmarks -c Release -- --filter '*ConcurrencyLimitContentionBenchmarks.QueuedHandoff' --job Short --iterationCount 10 --warmupCount 5 --noOverwrite
Measurement conditions and limitations
Measured on 2026-09-19: AsyncSemaphore 2.1.0, package source commit 008f1ea6205e5c2b90ea35c91e327fafba981660; BenchmarkDotNet 0.15.8; .NET SDK 10.0.401 / runtime 10.0.12; Windows 11 25H2; Intel Core i7-12700K, 20 logical cores.
Saved Release DLLs were measured sequentially as baseline, candidate, then baseline again on the same machine, with identical benchmark sources/settings. One launch, five warmups, ten measured iterations, MemoryDiagnoser. Error values are half the 99.9% confidence interval; KB values are rounded. The candidate was a local working-tree migration, not a published commit.
A shared performance reservation prevented cooperating builds/tests/benchmarks from overlapping. Desktop, WSL, Docker, and antivirus background CPU activity remained. These are local diagnostic observations, not isolated primitive measurements or CI acceptance. The comparison is against SemaphoreSlim, not an older AsyncSemaphore version. The final migration passed the solution build, 2,978 relevant tests, unchanged allocation gates, and net48 compatibility.
Requested investigation
Add equivalent signal-style handoff coverage to the package benchmarks, isolate primitive cost from the Kevlar pipeline, and profile queue publication, waiter completion, and continuation scheduling before choosing an optimization. Preserve cancellation behavior and the reduced allocation. The integration measurements do not establish which internal operation causes the slowdown.
Observed regression
Replacing the wake-up semaphore in Kevlar's concurrency limiter with
UnpairedAsyncSemaphore2.1.0 increased the time for a guaranteed queued handoff from 2.081 us to 2.378 us (+14.3%). Repeating the original baseline returned 2.083 us. Allocation improved from 1.62 KB to 1.53 KB per pair of executions.This exercises the signal-style API added following #581. Permit ownership remains in the limiter's existing interlocked state; the semaphore only wakes queued callers. No limiter accounting rewrite is involved.
Reproduction
Start from Kevlar commit 3f078a96. Add this benchmark to
benchmarks/Kevlar.Benchmarksfor both versions:The completion source ensures the second execution queues before the first is released. One invocation measures the complete pair, including completion-source/task overhead, rather than just the semaphore handoff.
For the candidate:
AsyncSemaphorepackage reference tosrc/Kevlar/Kevlar.csproj, with central version2.1.0inDirectory.Packages.props. Raise the existingMicrosoft.Bcl.AsyncInterfacescentral version to9.0.8to satisfy the netstandard2.0 dependency.using Semaphores;, change the field and constructor fromSemaphoreSlimtoUnpairedAsyncSemaphore, retaining initial count0, and replace_semaphore.Wait(0)with_semaphore.TryWait().Run sequentially for baseline, candidate, then baseline:
dotnet run --project benchmarks/Kevlar.Benchmarks -c Release -- --filter '*ConcurrencyLimitContentionBenchmarks.QueuedHandoff' --job Short --iterationCount 10 --warmupCount 5 --noOverwriteMeasurement conditions and limitations
Measured on 2026-09-19: AsyncSemaphore 2.1.0, package source commit
008f1ea6205e5c2b90ea35c91e327fafba981660; BenchmarkDotNet 0.15.8; .NET SDK 10.0.401 / runtime 10.0.12; Windows 11 25H2; Intel Core i7-12700K, 20 logical cores.Saved Release DLLs were measured sequentially as baseline, candidate, then baseline again on the same machine, with identical benchmark sources/settings. One launch, five warmups, ten measured iterations, MemoryDiagnoser. Error values are half the 99.9% confidence interval; KB values are rounded. The candidate was a local working-tree migration, not a published commit.
A shared performance reservation prevented cooperating builds/tests/benchmarks from overlapping. Desktop, WSL, Docker, and antivirus background CPU activity remained. These are local diagnostic observations, not isolated primitive measurements or CI acceptance. The comparison is against SemaphoreSlim, not an older AsyncSemaphore version. The final migration passed the solution build, 2,978 relevant tests, unchanged allocation gates, and net48 compatibility.
Requested investigation
Add equivalent signal-style handoff coverage to the package benchmarks, isolate primitive cost from the Kevlar pipeline, and profile queue publication, waiter completion, and continuation scheduling before choosing an optimization. Preserve cancellation behavior and the reduced allocation. The integration measurements do not establish which internal operation causes the slowdown.