Fork me on GitHub

Polly and synchronous versus asynchronous policies

Polly uses separate policy instances for synchronous and asynchronous executions. This article sets out to answer: Why?

TL;DR Sync-over-async and async-over-sync implementations are both to be avoided. Users expect policy hooks on async policies to be async methods (and vice versa for sync), and thus a single Policy instance could not act for both sync and async executions without potentially defining each policy hook in both sync and async forms.

Background

Polly uses separate policy instances for synchronous and asynchronous executions.

For synchronous executions, you define a synchronous policy instance, and can call the synchronous execute method on it:

Policy  
    .Timeout(Timespan.FromSeconds(30))
    .Execute(Action action);

For asynchronous executions, you define an asynchronous policy instance, and use asynchronous execute methods:

Policy  
    .TimeoutAsync(Timespan.FromSeconds(30))
    .ExecuteAsync(Func<..., Task> func);

Why the separate policies? Why can't Polly users use the same policy instance for both synchronous and asynchronous executions?

Internal Policy implementations are sync and async specific

It hardly needs saying that the sync and async internal implementations of Polly policies are sync and async specific.

Async implementations are fully async/await (as users would expect), with full .ConfigureAwait() support.

The sync implementations on the other hand have no business to change thread[*] or execution context, and do not. If you are introducing Polly into sync code, you should be able to expect that delegates you execute synchronously are executed on the same thread, and in the same context, that you call from.

One shouldn't expect anything less from a library, but Stephen Toub has blogged in much greater detail on the pitfalls of async-over-sync and sync-over-async approaches.

So far so good. But even if implementations would be sync and async specific, why could the same policy instances not give access to both the sync .Execute() and .ExecuteAsync() implementations?

The devil is in ... the delegates

Excuse my bad pun. The point is policy hooks. Most policies expose policy hooks where users can attach delegates to be invoked on specific policy events: onRetry, onBreak, onFallback, etc.

The original intention was that all policy hooks would be synchronous.

Users however (quite reasonably) expected async policies to offer async Policy hooks. For example, you might be using onRetry to log information about the retry made, or call a re-authentication endpoint. And in an async-optimised code flow, you might want to make those calls within onRetry in an async-optimised fashion ("async all the way").

This leads to:

  • synchronous executions expect synchronous policy hooks;
  • asynchronous executions expect asynchronous policy hooks.

In that context, for a single Policy instance to offer both sync and async executions, guaranteeing expected operation, Polly would have to (with some awkwardness with the current syntax) require, or allow, you to define both (for example) onRetry and onRetryAsync delegates when configuring the Policy.

For policies with multiple delegate hooks, this would quickly lead to unmanagably proliferating Policy configuration overloads.

The devil is in ... the compiler

Could Polly have (as originally) attempted to enforce only synchronous policy hooks? Enter a twist from the C# compiler.

The C# compiler intentionally allows the assignment of async void () => {...} lambdas to (non-async) Action delegates, without any kind of warning.

Which is the recipe for unexpected out-of-sequence execution of policy hooks and continuing policy actions. For example, async onRetry delegates (assigned by the compiler to sync delegates) might not complete execution before the next retry commenced. Further issues include the risk of unobserved exceptions.

The only option to prevent the compiler assigning async void lambdas to Action, is to provide comparable method overloads offering the parameter instead in the async-friendly form Func<Task>: compilers (from Visual Studio 2012 onward) then correctly prefer to assign the async void lambdas to the method overload expecting Func<Task>. Back to having both sync and async policy hooks again.

Why does the compiler allows the silent assignment of async void () => {...} lambdas to non-async Action delegates, if async void methods carry so many risks? The reason is to support the one use case for which it does make sense: async void event handlers for UI actions.

What do you think?

Could we do this differently?

The split between synchronous and asynchronous policies in Polly is not ideal, but has arisen from a combination of legacy decisions (before the current maintainers), Polly's syntax (which while strong in many ways, begins to strain if overloads proliferate), and compiler gotchas.

Would I tackle this differently in a greenfield project? Very probably.

There also remains the possibility that we could eliminate the split, so that the same policy instance could act for both sync and async executions, if we decide on a major overhaul of the Polly syntax - notwithstanding the breaking changes that would mean. EDIT: Proposal now open on the Polly repo, for comment.


Footnote [*]

Synchronous TimeoutPolicy in TimeoutStrategy.Pessimistic mode is the exception: synchronous delegates are invoked on a background thread, to permit the calling thread to walk away from an uncancellable execution it chooses to time out on.

Author image
United Kingdom Website
Dylan is the lead contributor and architect on Polly, and the brains behind the roadmap, which is seeing Polly growing into the most robust, flexible and fully-featured resilience library for .NET!