Async events for .NET that you can actually await: a drop-in replacement for EventHandler<T> and Func<T, Task> + Task.WhenAll, with no allocations per invocation when handlers complete synchronously.
- 0 B per invocation. Raising an event whose handlers complete synchronously allocates nothing, in parallel and in sequential mode; tests assert it on net8.0 and net10.0. Naive async allocates 280–288 B per call.
- More than 10× faster than
Task.WhenAllfan-out, and faster than a plain synchronous multicast delegate, measured in the same run. - Real
asyncsemantics. Handlers returnValueTask, receive aCancellationToken, and run in parallel or in registration order. Exceptions reach the caller instead of vanishing in anasync void. - Works under NativeAOT and trimming. No reflection; CI publishes and runs a NativeAOT sample on every build.
dotnet add package ZeroAlloc.AsyncEventsusing ZeroAlloc.AsyncEvents;
var orders = new OrderService();
orders.OrderPlaced += async (order, ct) =>
{
await Task.Delay(10, ct); // e.g. send a confirmation email
Console.WriteLine($"Emailed order {order.Id}");
};
orders.OrderPlaced += (order, ct) =>
{
Console.WriteLine($"Audited order {order.Id}"); // completes synchronously
return ValueTask.CompletedTask;
};
await orders.PlaceAsync(new OrderPlaced(42));
public sealed record OrderPlaced(int Id);
public partial class OrderService
{
// The generator writes `public event AsyncEvent<OrderPlaced> OrderPlaced` for this field.
[AsyncEvent]
private AsyncEventHandler<OrderPlaced> _orderPlaced = new(InvokeMode.Parallel);
public ValueTask PlaceAsync(OrderPlaced order, CancellationToken ct = default)
=> _orderPlaced.InvokeAsync(order, ct);
}PlaceAsync returns once both handlers have finished. Subscribers use the familiar += and -=.
- Parallel or sequential dispatch, chosen per field and overridable per call. Invoke modes
- Cancelable events. A handler sets
Canceland the remaining handlers are skipped, for validation and veto pipelines. Cancelable events - Generated
eventaccessors with[AsyncEvent]on a field or on the whole class. Mistakes are reported at compile time. Source generator, diagnostics - Lock-free, thread-safe subscription. Registering and unregistering never takes a lock.
- Broad reach:
netstandard2.0,netstandard2.1,net8.0andnet10.0. - Async property and collection notifications, the async
INotifyPropertyChangedfamily, come from ZeroAlloc.Notify, which builds on this package.
Ten handlers, each returning a completed task, invoked once. net10.0, BenchmarkDotNet, same run; the ratio is against a synchronous multicast delegate. Allocations are exact; speed is a same-run ratio.
| Method | Ratio | Allocated |
|---|---|---|
EventHandler<T>, sync multicast |
1.00 | 0 B |
Func<T, Task> + Task.WhenAll |
11.93 | 280 B |
| ZeroAlloc.AsyncEvents, parallel | 0.44 | 0 B |
| ZeroAlloc.AsyncEvents, sequential | 0.61 | 0 B |
Full results for net8.0 and net10.0, methodology and allocation notes: docs/performance.md.
- Documentation: asyncevents.zeroalloc.net
- Related packages: ZeroAlloc.Notify for async property and collection notifications, and the rest of the ZeroAlloc family
- Contributing: issues and pull requests are welcome on GitHub
- License: MIT