How to Guarantee Equivalent Behavior of `await` from `ContinueWith` in both UI and Non-UI Code

72 Views Asked by At

I'm in search of the simplest and safest way to fully guarantee the same behavior as await but using Task.ContinueWith, most specifically to ensure that if the original caller is in the UI thread then the continuation occurs on the UI thread, otherwise it should continue on any thread. My current approach throws an exception in some cases which I can't see an obvious way to prevent in advance.

My prior understanding was that this code:

await DoSomethingAsync();
DoSomethingElse();

and this code:

DoSomethingAsync().ContinueWith(
     t => DoSomethingElse(),
     TaskScheduler.FromCurrentSynchronizationContext());

ought to be equivalent, both resulting in DoSomethingElse() executing on the original calling thread if it was a UI thread, otherwise the continuation thread would be undefined.

However, it appears that TaskScheduler.FromCurrentSynchronizationContext() fails in at least some contexts when there is no UI thread or synchronizer defined. This occurs, at minimum, in Mono WASM projects. The documentation also mentions the method can fail if "the current SynchronizationContext may not be used as a TaskScheduler", but it doesn't say how to check whether this is the case before attempting to use it with ContinueWith.

As this code will be shared among many different consumers, from WPF to Mono WASM and others, I need a solution that fits all of them and doesn't require the consumer to specify if it's in a UI context or not.

What would be the simplest way to write the second block so that the continuation occurs on the UI thread when there is in fact a valid synchronization context such as in a WPF app - but also doesn't throw an exception if there isn't one - exactly the way await would?

1

There are 1 best solutions below

1
Stephen Cleary On BEST ANSWER

I'm not sure why you wouldn't use await. Code using await is easier to write and maintain than code using the old ContinueWith method.

That said, there are a couple major differences. The first is that await captures the current context; this is SynchronizationContext.Current or TaskScheduler.Current (or no context at all, which is logically equivalent to TaskScheduler.Default). await schedules its continuation directly on that context and doesn't wrap the SynchronizationContext into a TaskScheduler.

So, if you want to use ContinueWith, then you can use FromCurrentSynchronizationContext, but there's already a semantic difference: with a non-null SynchronizationContext.Current, the await continuation would have TaskScheduler.Current equal to TaskScheduler.Default, whereas the ContinueWith continuation would have TaskScheduler.Current equal to a TaskScheduler instance wrapping that SynchronizationContext. Perhaps not a huge issue, but be aware that many low-level TPL methods implicitly use TaskScheduler.Current (including ContinueWith).

The other major difference is that ContinueWith doesn't have a built-in understanding of asynchronous code. So if DoSomethingElse is asynchronous, then the behavior will be different. In that case, ContinueWith would return a Task<Task> and you should call Unwrap to more closely emulate the behavior of await.

There's also a number of minor differences; passing TaskContinuationOptions.DenyChildAttach | TaskContinuationOptions.ExecuteSynchronously should be closer to the await behavior. You could also try TaskContinuationOptions.HideScheduler which should cause TaskScheduler.Current to be TaskScheduler.Default in the continuation.

So in summary, these two should be mostly equivalent:

await DoSomethingAsync();
DoSomethingElseSynchronously();

var continuationTask = DoSomethingAsync()
    .ContinueWith(
        _ => DoSomethingElseSynchronously(),
        CancellationToken.None,
        TaskContinuationOptions.DenyChildAttach | TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.HideScheduler,
        SynchronizationContext.Current == null ? TaskScheduler.Default : TaskScheduler.FromCurrentSynchronizationContext());

or, if asynchronous:

await DoSomethingAsync();
await DoSomethingElseAsync();

var continuationTask = DoSomethingAsync()
    .ContinueWith(
        _ => DoSomethingElseAsync(),
        CancellationToken.None,
        TaskContinuationOptions.DenyChildAttach | TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.HideScheduler,
        SynchronizationContext.Current == null ? TaskScheduler.Default : TaskScheduler.FromCurrentSynchronizationContext())
    .Unwrap();