Zero-allocation async event handler structs for .NET. Lock-free registration, ValueTask invocation, ArrayPool parallel dispatch.
- Lock-free registration — CAS-loop register/unregister, no locks
- ValueTask throughout — no
Taskallocations on hot paths - ArrayPool parallel dispatch — rented array for fan-out, returned immediately after
WhenAll - Multi-target —
netstandard2.0,netstandard2.1,net8.0,net10.0 - AOT compatible
10 handlers registered, invoked once. Compared against EventHandler<T> (sync multicast, baseline) and naive async (Func<string, Task> + Task.WhenAll). BenchmarkDotNet v0.14.0, .NET 9, X64 RyuJIT AVX2.
| Method | Mean | Error | StdDev | Ratio | Allocated |
|---|---|---|---|---|---|
| Sync_MulticastDelegate_10Handlers | 22.96 ns | 0.926 ns | 2.597 ns | 1.01 | — |
| NaiveAsync_TaskWhenAll_10Handlers | 203.48 ns | 4.111 ns | 7.307 ns | 8.96x | 280 B |
| ZeroAlloc_Parallel_10Handlers | 70.10 ns | 1.446 ns | 4.103 ns | 3.09x | 136 B |
| ZeroAlloc_Sequential_10Handlers | 16.10 ns | 0.655 ns | 1.857 ns | 0.71x | — |
ZeroAlloc sequential mode is 30% faster than a sync multicast delegate with zero allocations. ZeroAlloc parallel mode is 3× faster than naive async with 51% less memory.
dotnet add package ZeroAlloc.AsyncEvents
// Declare the backing field
private AsyncEventHandler<OrderPlacedArgs> _orderPlaced = new(InvokeMode.Parallel);
// Expose as a C# event — or use [AsyncEvent] to let the source generator do this
public event AsyncEvent<OrderPlacedArgs> OrderPlaced
{
add => _orderPlaced.Register(value);
remove => _orderPlaced.Unregister(value);
}
// Invoke
await _orderPlaced.InvokeAsync(new OrderPlacedArgs(orderId), cancellationToken);Annotate fields with [AsyncEvent] on a partial class and the generator writes the event property for you:
public partial class OrderService
{
[AsyncEvent(InvokeMode.Parallel)]
private AsyncEventHandler<OrderPlacedArgs> _orderPlaced = new(InvokeMode.Parallel);
}Generates:
public event AsyncEvent<OrderPlacedArgs> OrderPlaced
{
add => _orderPlaced.Register(value);
remove => _orderPlaced.Unregister(value);
}Apply [AsyncEvent] to the class instead to cover all AsyncEventHandler<TArgs> fields at once. See Source Generator for details.
Async INotify* interfaces and event args are provided by ZeroAlloc.Notify, which builds on this package.
AsyncEventHandler<TArgs> is a struct wrapping a State reference — copy semantics are intentional and match how event fields work in C#. Use it as a private field and expose Register/Unregister methods, or use the +=/-= operators directly.
CancellationToken is threaded through every call site — handlers opt into cooperative cancellation at the delegate boundary. Sequential mode respects cancellation between handler invocations; parallel mode checks before dispatch.
See docs/ for full documentation.