Search code examples
c#roslynroslyn-code-analysis

How to identify if the method implementation is marked as async / can be called asynchronously, based only on its interface using Roslyn?


Background Information

I am building a Roslyn based CodeFix for Visual Studio, that handles the condition where a class does not implement an interface (or is missing part of that interface).

The interfaces will typically be third-party code, e.g. Microsoft's IDocumentClient.

I then create an implementation of that interface, where calls to methods and properties are 'wrapped' by handling their actual execution by a the most relevant candidate from 3 helper methods, as part of a decorated implementation. These helper methods handle scenarios for different return types, including void return, a non-Task type, and a generic Task type.

The helper methods make a call to the Polly library; in the case of the helper that returns generic Task types , specifically the Polly ExecuteAsync method, which performs execution of the passed method delegate, and handles exceptions according to user specified behaviour (retry, circuit breaker, etc).

The code for my project can be found on Github, Polly.Contrib.Decorator.

Problem

I need to be able to identify if the method that I am creating is asynchronous, by the information contained in the interface declaration.

This would determine two things:

  1. If my implementation should be marked with the async modifier.

  2. If the implementation could be called asynchronously, allowing me to decide if my implementation of the method - which is wrapped - could and then, if it should be handled asynchronously by my wrapping code.

I cannot rely on any other external information.

What I have considered

I have looked at using the return type of the method, to determine if it is a Task, but in some cases it is possible that the method declaration in its interface is 'returning void', even though its actual implementation is marked with the async modifier or is callable in an asynchronous way.

Checking the name for the Async suffix is obviously not reliable; not everyone follows such a convention.

Question

Is there a reliable method for identifying if a method implementation is asynchronous, i.e. should it be decorated with async, and can it be handled asynchronously based only on its interface declaration, using Roslyn?

(Please refer to comment discussion, which indicates the evolution of this question)


Solution

  • TL;DR

    The underlying question is how a library wrapping methods in an interface, should call those methods - with await or not. Not only is whether async was declared on the implementations not detectable (from an interface, out of context of the call site), but it is not determining, for this purpose.

    • (1) Whether the async keyword appears in a method implementation is not sufficiently determining for whether a call to it can or should use await.
    • (2) A method can be awaited if-and-only-if its return type is awaitable (this is sufficiently determining).
    • (3) async void methods do not change the above conclusions; an async void method does not run any less asynchronously for the fact that you cannot call it with await.

    (1) Whether the async keyword appears in a method implementation is not sufficiently determining for whether a call to it can or should use await.

    async is not formally part of a method signature. So async is not found in interfaces. So you can't determine from an interface, if the interface's original author intended method implementations to be written with the async keyword or called with await.

    However, whether the called method is written with the async keyword is not actually the determining (/even a sufficiently determining) factor, for whether a method could/should be called with await. There are valid cases for writing methods without the async keyword which return awaitables:

    [a] async-await eilision, as used extensively by Polly
    [b] an interface mostly used for implementations over I/O, hence declared with awaitable return types, but for which you might also want sometimes to write an in-memory (so synchronous) implementation/interceptor. Discussion of common case: sync in-memory cache around async I/O [c] for test purposes, stubbing out some asynchronous dependency with an in-memory (sync) stub

    (It's actually good that async isn't part of the method signature, because it permits us occasionally to fulfil with sync implementations, as above.)

    (2) A method can be awaited if-and-only-if its return type is awaitable (this is sufficiently determining); ie the return type is Task, Task<TResult> or has a suitable GetAwaiter() method

    The only thing uniquely determining whether a method call can be await-ed is whether its return type is awaitable.

    (3) async void methods do not change the above conclusions

    This addresses async void methods at length, because of the comment in the question that the return type of the method might be insufficient, presumably because void could not be distinguished (in the interface) from async void.

    The starting point is that async void methods cannot be awaited, for the simple reason that, although written with the async keyword, they do not return any type that can be await-ed.

    Does that invalidate our conclusion (2)? (that we can uniquely use whether a method returns an awaitable to determine how to call it). Are we losing some capability to run async void methods asynchronously, because we can't await them?

    Put concretely: say the method behind the interface is async void Foo() but all we know about it from the interface is that it is void Foo(). Are we losing some ability for Foo() to run asynchronously if we only invoke it Foo()?

    The answer is no, because of the way async methods operate. async void methods behave just as any async Task/Task<T> method called with await: they run synchronously until their first internal await; they then return (void, or a Task representing their promise to complete), and schedule the remainder of the called method (the part after the first await) as a continuation. That continuation is the part which will run asynchronously, after the awaited thing completes. (That's rather a condensed description, but this is widely blogged; example discussion.)

    In other words: The determining factor that some portion of an async void method will run asynchronously is not that it is called with await, but that, in its body, it has an await with some meaningful work after it.

    (3b) Another angle on async void

    Since the essential q (for the purposes of Polly.Contrib.Decorator) is how should we call a wrapped method, we can run an alternative thought experiment around async void methods. What if we could (somehow) determine that a void method behind an interface had been declared async void? Would we call it any differently?

    Back to our example, async void Foo(). What are our choices? We could Foo() directly or Task.Run(() => Foo()).

    As Stephen Cleary covers, a library shouldn't use Task.Run() on a caller's behalf. Doing so means the library is taking a choice that work should be offloaded onto a background thread, denying the caller that choice. (Note: per above discussion about how async void methods operate, this only applies to the work up to the first await within the called method anyway.)

    So: even if we could know the method behind void Foo() in the interface was async void Foo(), Polly.Contrib.Decorator should still only call Foo() anyway. If the user wants to immediately offload the work onto a different thread (eg they want to offload it off the GUI thread), they would (before Polly.Contrib.Decorator was introduced) be calling that interface method with Task.Run(() => ...) anyway. We don't need to add an extra one.

    This conforms with a principle Polly follows and I would recommend other delegate-inflecting libraries should follow: it should have least effect (apart from the declared intended effects) on how users' delegates are run.


    Key for all the above is that the async keyword doesn't (in and of itself) make a method run asynchronously or even concurrently, so is not the clincher. The async keyword just permits the compiler to chop a method up into a series of chunks at await statements; chunks 2..n of such a method run (asynchronously; at a different time) as continuations after the preceding awaited call completes. The caller is (except with async void methods) returned a Task which is a 'promise' that will complete when the awaited method completes.

    The fact of being returned that Task (or something else implementing GetAwaiter()) is the determining factor for whether it can be called with await: if the returned type of the method implements this awaitable pattern, it can be awaited.

    The existence of async/await elision and the sync-cache-over-async-io pattern in particular, demonstrate that the return type of the called method is key, not whether the implementation used the async keyword.