Skip to content

Commit 342fb27

Browse files
authored
Add SyncAsyncEventHandler (Azure#18170)
1 parent f86284e commit 342fb27

22 files changed

+1848
-797
lines changed

sdk/core/Azure.Core/api/Azure.Core.net461.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,12 @@ protected Response() { }
186186
public static implicit operator T (Azure.Response<T> response) { throw null; }
187187
public override string ToString() { throw null; }
188188
}
189+
public partial class SyncAsyncEventArgs : System.EventArgs
190+
{
191+
public SyncAsyncEventArgs(bool runSynchronously, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { }
192+
public System.Threading.CancellationToken CancellationToken { get { throw null; } }
193+
public bool RunSynchronously { get { throw null; } }
194+
}
189195
}
190196
namespace Azure.Core
191197
{
@@ -410,6 +416,7 @@ internal RetryOptions() { }
410416
public Azure.Core.RetryMode Mode { get { throw null; } set { } }
411417
public System.TimeSpan NetworkTimeout { get { throw null; } set { } }
412418
}
419+
public delegate System.Threading.Tasks.Task SyncAsyncEventHandler<T>(T e) where T : Azure.SyncAsyncEventArgs;
413420
public abstract partial class TokenCredential
414421
{
415422
protected TokenCredential() { }

sdk/core/Azure.Core/api/Azure.Core.net5.0.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,12 @@ protected Response() { }
186186
public static implicit operator T (Azure.Response<T> response) { throw null; }
187187
public override string ToString() { throw null; }
188188
}
189+
public partial class SyncAsyncEventArgs : System.EventArgs
190+
{
191+
public SyncAsyncEventArgs(bool runSynchronously, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { }
192+
public System.Threading.CancellationToken CancellationToken { get { throw null; } }
193+
public bool RunSynchronously { get { throw null; } }
194+
}
189195
}
190196
namespace Azure.Core
191197
{
@@ -410,6 +416,7 @@ internal RetryOptions() { }
410416
public Azure.Core.RetryMode Mode { get { throw null; } set { } }
411417
public System.TimeSpan NetworkTimeout { get { throw null; } set { } }
412418
}
419+
public delegate System.Threading.Tasks.Task SyncAsyncEventHandler<T>(T e) where T : Azure.SyncAsyncEventArgs;
413420
public abstract partial class TokenCredential
414421
{
415422
protected TokenCredential() { }

sdk/core/Azure.Core/api/Azure.Core.netstandard2.0.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,12 @@ protected Response() { }
186186
public static implicit operator T (Azure.Response<T> response) { throw null; }
187187
public override string ToString() { throw null; }
188188
}
189+
public partial class SyncAsyncEventArgs : System.EventArgs
190+
{
191+
public SyncAsyncEventArgs(bool runSynchronously, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { }
192+
public System.Threading.CancellationToken CancellationToken { get { throw null; } }
193+
public bool RunSynchronously { get { throw null; } }
194+
}
189195
}
190196
namespace Azure.Core
191197
{
@@ -410,6 +416,7 @@ internal RetryOptions() { }
410416
public Azure.Core.RetryMode Mode { get { throw null; } set { } }
411417
public System.TimeSpan NetworkTimeout { get { throw null; } set { } }
412418
}
419+
public delegate System.Threading.Tasks.Task SyncAsyncEventHandler<T>(T e) where T : Azure.SyncAsyncEventArgs;
413420
public abstract partial class TokenCredential
414421
{
415422
protected TokenCredential() { }
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# Azure.Core Event samples
2+
3+
**NOTE:** Samples in this file only apply to packages following the
4+
[Azure SDK Design Guidelines](https://azure.github.io/azure-sdk/dotnet_introduction.html).
5+
The names of these packages usually start with `Azure`.
6+
7+
Most Azure client libraries for .NET offer both synchronous and asynchronous
8+
methods for calling Azure services. You can distinguish the asynchronous
9+
methods by their `Async` suffix. For example, `BlobClient.Download` and
10+
`BlobClient.DownloadAsync` make the same underlying REST call and only differ in
11+
whether they block. We recommend using our async methods for new applications,
12+
but there are perfectly valid cases for using sync methods as well. These dual
13+
method invocation semantics allow for flexibility, but require a little extra
14+
care when writing event handlers.
15+
16+
The `SyncAsyncEventHandler` is a delegate used by events in Azure client
17+
libraries to represent an event handler that can be invoked from either sync or
18+
async code paths. It takes event arguments deriving from `SyncAsyncEventArgs`
19+
that contain important information for writing your event handler.
20+
21+
- `SyncAsyncEventArgs.CancellationToken` is a cancellation token related to the
22+
original operation that raised the event. It's important for your handler to
23+
pass this token along to any asynchronous or long-running synchronous
24+
operations that take a token so cancellation (via something like
25+
`new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token`, for example)
26+
will correctly propagate.
27+
28+
- There is a `SyncAsyncEventArgs.RunSynchronously` flag indicating whether your
29+
handler was invoked synchronously or asynchronously. In general,
30+
31+
- If you're calling sync methods on your client, you should use sync methods
32+
to implement your event handler. You can return `Task.CompletedTask`.
33+
- If you're calling async methods on your client, you should use async
34+
methods where possible to implement your event handler.
35+
- If you're not in control of how the client will be used or want to write
36+
safer code, you should check the `RunSynchronously` property and call
37+
either sync or async methods as directed.
38+
39+
There are code examples of all three situations below to compare. Please also
40+
see the note at the very end discussing the dangers of sync-over-async to
41+
understand the risks of not using the `RunSynchronously` flag.
42+
43+
- Most events will customize the event data by deriving from `SyncAsyncEventArgs`
44+
and including details about what triggered the event or providing options to
45+
react. Many times this will include a reference to the client that raised the
46+
event in case you need it for additional processing.
47+
48+
When an event using `SyncAsyncEventHandler` is raised, the handlers will be
49+
executed sequentially to avoid introducing any unintended parallelism. The
50+
event handlers will finish before returning control to the code path raising the
51+
event. This means blocking for events raised synchronously and waiting for the
52+
returned `Task` to complete for events raised asynchronously.
53+
54+
Any exceptions thrown from a handler will be wrapped in a single
55+
`AggregateException`. If one handler throws an exception, it will not prevent
56+
other handlers from running. This is also relevant for cancellation because all
57+
handlers are still raised if cancellation occurs. You should both pass
58+
`SyncAsyncEventArgs.CancellationToken` to asynchronous or long-running
59+
synchronous operations and consider calling `CancellationToken.ThrowIfCancellationRequested`
60+
in compute heavy handlers.
61+
62+
A [distributed tracing span](https://github.com/Azure/azure-sdk-for-net/blob/master/sdk/core/Azure.Core/samples/Diagnostics.md#distributed-tracing)
63+
is wrapped around your handlers using the event name so you can see how long
64+
your handlers took to run, whether they made other calls to Azure services, and
65+
details about any exceptions that were thrown.
66+
67+
The rest of the code samples are using a fictitious `AlarmClient` to demonstrate
68+
how to handle `SyncAsyncEventHandler` events. There are `Snooze` and
69+
`SnoozeAsync` methods that both raise a `Ring` event.
70+
71+
## Adding a synchronous event handler
72+
73+
If you're using the synchronous, blocking methods of a client (i.e., methods
74+
without an `Async` suffix), they will raise events that require handlers to
75+
execute synchronously as well. Even though the signature of your handler
76+
returns a `Task`, you should write regular sync code that blocks and return
77+
`Task.CompletedTask` when finished.
78+
79+
```C# Snippet:Azure_Core_Samples_EventSamples_SyncHandler
80+
var client = new AlarmClient();
81+
client.Ring += (SyncAsyncEventArgs e) =>
82+
{
83+
Console.WriteLine("Wake up!");
84+
return Task.CompletedTask;
85+
};
86+
87+
client.Snooze();
88+
```
89+
90+
If you need to call an async method from a synchronous event handler, you have
91+
two options:
92+
93+
- You can use [`Task.Run`](https://docs.microsoft.com/dotnet/api/system.threading.tasks.task.run)
94+
to queue a task for execution on the ThreadPool without waiting on it to
95+
complete. This "fire and forget" approach may not run before your handler
96+
finishes executing. Be sure to understand
97+
[exception handling in the Task Parallel Library](https://docs.microsoft.com/dotnet/standard/parallel-programming/exception-handling-task-parallel-library)
98+
to avoid unhandled exceptions tearing down your process.
99+
- If you absolutely need the async method to execute before returning from your
100+
handler, you can call `myAsyncTask.GetAwaiter().GetResult()`. Please be aware
101+
this may cause ThreadPool starvation. See the sync-over-async note below for
102+
more details.
103+
104+
## Adding an asynchronous event handler
105+
106+
If you're using the asynchronous, non-blocking methods of a client (i.e.,
107+
methods with an `Async` suffix), they will raise events that expect handlers to
108+
execute asynchronously.
109+
110+
```C# Snippet:Azure_Core_Samples_EventSamples_AsyncHandler
111+
var client = new AlarmClient();
112+
client.Ring += async (SyncAsyncEventArgs e) =>
113+
{
114+
await Console.Out.WriteLineAsync("Wake up!");
115+
};
116+
117+
await client.SnoozeAsync();
118+
```
119+
120+
## Handlers that can be called sync or async
121+
122+
The same event can be raised from both synchronous and asynchronous code paths
123+
depending on whether you're calling sync or async methods on a client. If you
124+
write an async handler but raise it from a sync method, the handler will be
125+
doing sync-over-async and may cause ThreadPool starvation. See the note at the
126+
bottom for more details.
127+
128+
You should use the `SyncAsyncEventArgs.RunSynchronously` property to check how
129+
the event is being raised and implement your handler accordingly. Here's an
130+
example handler that's safe to invoke from both sync and async code paths.
131+
132+
```C# Snippet:Azure_Core_Samples_EventSamples_CombinedHandler
133+
var client = new AlarmClient();
134+
client.Ring += async (SyncAsyncEventArgs e) =>
135+
{
136+
if (e.RunSynchronously)
137+
{
138+
Console.WriteLine("Wake up!");
139+
}
140+
else
141+
{
142+
await Console.Out.WriteLineAsync("Wake up!");
143+
}
144+
};
145+
146+
client.Snooze(); // sync call that blocks
147+
await client.SnoozeAsync(); // async call that doesn't block
148+
```
149+
150+
## Handling exceptions
151+
152+
Any exceptions thrown by an event handler will be wrapped in a single
153+
[`AggregateException`](https://docs.microsoft.com/dotnet/api/system.aggregateexception) and thrown from the code that raised the event. You can check the
154+
[`AggregateException.InnerExceptions`](https://docs.microsoft.com/dotnet/api/system.aggregateexception.innerexceptions)
155+
property to see the original exceptions thrown by your event handlers.
156+
`AggregateException` also provides
157+
[a number of helpful methods](https://docs.microsoft.com/archive/msdn-magazine/2009/brownfield/aggregating-exceptions)
158+
like `Flatten` and `Handle` to make complex failures easier to work with.
159+
160+
```C# Snippet:Azure_Core_Samples_EventSamples_Exceptions
161+
var client = new AlarmClient();
162+
client.Ring += (SyncAsyncEventArgs e) =>
163+
throw new InvalidOperationException("Alarm unplugged.");
164+
165+
try
166+
{
167+
client.Snooze();
168+
}
169+
catch (AggregateException ex)
170+
{
171+
ex.Handle(e => e is InvalidOperationException);
172+
Console.WriteLine("Please switch to your backup alarm.");
173+
}
174+
```
175+
176+
## Sync-over-async
177+
178+
Executing asynchronous code from a sync code path is commonly referred to as
179+
sync-over-async because you're getting sync behavior but still invoking all the
180+
async machinery. See
181+
[Diagnosing .NET Core ThreadPool Starvation with PerfView](https://docs.microsoft.com/archive/blogs/vancem/diagnosing-net-core-threadpool-starvation-with-perfview-why-my-service-is-not-saturating-all-cores-or-seems-to-stall)
182+
for a detailed explanation of how that can cause serious performance problems.
183+
We recommend you use the `SyncAsyncEventArgs.RunSynchronously` flag to avoid
184+
ThreadPool starvation.
185+
186+
But what about executing synchronous code on an async code path like the "Adding
187+
a synchronous event handler" code sample above? This is perfectly okay. Behind
188+
the scenes, we're effectively doing something like:
189+
190+
```C#
191+
var task = InvokeHandler();
192+
if (!task.IsCompleted)
193+
{
194+
task.Wait();
195+
}
196+
```
197+
198+
Writing sync code in your handler will block before returning a completed `Task`
199+
so there's no need to involve the ThreadPool to run your handler.

sdk/core/Azure.Core/samples/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ description: Samples for the Azure.Core client library
1414
- [Response](https://github.com/Azure/azure-sdk-for-net/blob/master/sdk/core/Azure.Core/samples/Response.md)
1515
- [Pipeline](https://github.com/Azure/azure-sdk-for-net/blob/master/sdk/core/Azure.Core/samples/Pipeline.md)
1616
- [Long Running Operations](https://github.com/Azure/azure-sdk-for-net/blob/master/sdk/core/Azure.Core/samples/LongRunningOperations.md)
17+
- [Events](https://github.com/Azure/azure-sdk-for-net/blob/master/sdk/core/Azure.Core/samples/Events.md)
18+
- [Diagnostics](https://github.com/Azure/azure-sdk-for-net/blob/master/sdk/core/Azure.Core/samples/Diagnostics.md)
1719
- [Mocking](https://github.com/Azure/azure-sdk-for-net/blob/master/sdk/core/Azure.Core/samples/Mocking.md)
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Threading.Tasks;
7+
using Azure.Core.Pipeline;
8+
9+
namespace Azure.Core
10+
{
11+
/// <summary>
12+
/// Extensions for raising <see cref="SyncAsyncEventHandler{T}"/>
13+
/// events.
14+
/// </summary>
15+
internal static class SyncAsyncEventHandlerExtensions
16+
{
17+
/// <summary>
18+
/// Raise an <see cref="Azure.Core.SyncAsyncEventHandler{T}"/>
19+
/// event by executing each of the handlers sequentially (to avoid
20+
/// introducing accidental parallelism in customer code) and collecting
21+
/// any exceptions.
22+
/// </summary>
23+
/// <typeparam name="T">Type of the event arguments.</typeparam>
24+
/// <param name="eventHandler">The event's delegate.</param>
25+
/// <param name="e">
26+
/// An <see cref="SyncAsyncEventArgs"/> instance that contains the
27+
/// event data.
28+
/// </param>
29+
/// <param name="declaringTypeName">
30+
/// The name of the type declaring the event to construct a helpful
31+
/// exception message and distributed tracing span.
32+
/// </param>
33+
/// <param name="eventName">
34+
/// The name of the event to construct a helpful exception message and
35+
/// distributed tracing span.
36+
/// </param>
37+
/// <param name="clientDiagnostics">
38+
/// Client diagnostics to wrap all the handlers in a new distributed
39+
/// tracing span.
40+
/// </param>
41+
/// <returns>
42+
/// A task that represents running all of the event's handlers.
43+
/// </returns>
44+
/// <exception cref="AggregateException">
45+
/// An exception was thrown during the execution of at least one of the
46+
/// event's handlers.
47+
/// </exception>
48+
/// <exception cref="ArgumentNullException">
49+
/// Thrown when <paramref name="e"/>, <paramref name="declaringTypeName"/>,
50+
/// <paramref name="eventName"/>, or <paramref name="clientDiagnostics"/>
51+
/// are null.
52+
/// </exception>
53+
/// <exception cref="ArgumentException">
54+
/// Thrown when <paramref name="declaringTypeName"/> or
55+
/// <paramref name="eventName"/> are empty.
56+
/// </exception>
57+
public static async Task RaiseAsync<T>(
58+
this SyncAsyncEventHandler<T> eventHandler,
59+
T e,
60+
string declaringTypeName,
61+
string eventName,
62+
ClientDiagnostics clientDiagnostics)
63+
where T : SyncAsyncEventArgs
64+
{
65+
Argument.AssertNotNull(e, nameof(e));
66+
Argument.AssertNotNullOrEmpty(declaringTypeName, nameof(declaringTypeName));
67+
Argument.AssertNotNullOrEmpty(eventName, nameof(eventName));
68+
Argument.AssertNotNull(clientDiagnostics, nameof(clientDiagnostics));
69+
70+
// Get the invocation list, but return early if there's no work
71+
if (eventHandler == null) { return; }
72+
Delegate[] handlers = eventHandler.GetInvocationList();
73+
if (handlers == null || handlers.Length == 0) { return; }
74+
75+
// Wrap handler invocation in a distributed tracing span so it's
76+
// easy for customers to track and measure
77+
string eventFullName = declaringTypeName + "." + eventName;
78+
using DiagnosticScope scope = clientDiagnostics.CreateScope(eventFullName);
79+
scope.Start();
80+
try
81+
{
82+
// Collect any exceptions raised by handlers
83+
List<Exception> failures = null;
84+
85+
// Raise the handlers sequentially so we don't introduce any
86+
// unintentional parallelism in customer code
87+
foreach (Delegate handler in handlers)
88+
{
89+
SyncAsyncEventHandler<T> azureHandler = (SyncAsyncEventHandler<T>)handler;
90+
try
91+
{
92+
Task runHandlerTask = azureHandler(e);
93+
// We can consider logging something when e.RunSynchronously
94+
// is true, but runHandlerTask.IsComplete is false.
95+
// (We'll not bother to check our tests because
96+
// EnsureCompleted on the code path that raised the
97+
// event will catch it for us.)
98+
await runHandlerTask.ConfigureAwait(false);
99+
}
100+
catch (Exception ex)
101+
{
102+
failures ??= new List<Exception>();
103+
failures.Add(ex);
104+
}
105+
}
106+
107+
// Wrap any exceptions in an AggregateException
108+
if (failures?.Count > 0)
109+
{
110+
// Include the event name in the exception for easier debugging
111+
throw new AggregateException(
112+
"Unhandled exception(s) thrown when raising the " + eventFullName + " event.",
113+
failures);
114+
}
115+
}
116+
catch (Exception ex)
117+
{
118+
scope.Failed(ex);
119+
throw;
120+
}
121+
}
122+
}
123+
}

0 commit comments

Comments
 (0)