async/await vs. hand made continuations: is ExecuteSynchronously cleverly used?

1.9k views Asked by At

I recently wrote the following code:

    Task<T> ExecAsync<T>( string connectionString, SqlCommand cmd, Func<SqlCommand, T> resultBuilder, CancellationToken cancellationToken = default(CancellationToken) )
    {
        var tcs = new TaskCompletionSource<T>();

        SqlConnectionProvider p;
        try
        {
            p = GetProvider( connectionString );
            Task<IDisposable> openTask = p.AcquireConnectionAsync( cmd, cancellationToken );
            openTask
                .ContinueWith( open =>
                {
                    if( open.IsFaulted ) tcs.SetException( open.Exception.InnerExceptions );
                    else if( open.IsCanceled ) tcs.SetCanceled();
                    else
                    {
                        var execTask = cmd.ExecuteNonQueryAsync( cancellationToken );
                        execTask.ContinueWith( exec =>
                        {
                            if( exec.IsFaulted ) tcs.SetException( exec.Exception.InnerExceptions );
                            else if( exec.IsCanceled ) tcs.SetCanceled();
                            else
                            {
                                try
                                {
                                    tcs.SetResult( resultBuilder( cmd ) );
                                }
                                catch( Exception exc ) { tcs.TrySetException( exc ); }
                            }
                        }, TaskContinuationOptions.ExecuteSynchronously );
                    }
                } )
                .ContinueWith( _ =>
                {
                    if( !openTask.IsFaulted ) openTask.Result.Dispose();
                }, TaskContinuationOptions.ExecuteSynchronously );
        }
        catch( Exception ex )
        {
            tcs.SetException( ex );
        }
        return tcs.Task;
    }

This works as intended. The same code written with async/await is (obviously) simpler:

async Task<T> ExecAsync<T>( string connectionString, SqlCommand cmd, Func<SqlCommand, T> resultBuilder, CancellationToken cancellationToken = default(CancellationToken) )
{
    SqlConnectionProvider p = GetProvider( connectionString );
    using( IDisposable openTask = await p.AcquireConnectionAsync( cmd, cancellationToken ) )
    {
        await cmd.ExecuteNonQueryAsync( cancellationToken );
        return resultBuilder( cmd );
    }
}

I had a quick look at the generated IL for the 2 versions: the async/await is bigger (not a surprise) but I was wondering if the async/await code generator analyses the fact that a continuation is actually synchronous to use TaskContinuationOptions.ExecuteSynchronously where it can... and I failed to find this in the IL generated code.

If anyone knows this or have any clue about it, I'd be pleased to know!

2

There are 2 answers

6
noseratio On BEST ANSWER

I was wondering if the async/await code generator analyses the fact that a continuation is actually synchronous to use TaskContinuationOptions.ExecuteSynchronously where it can... and I failed to find this in the IL generated code.

Whether await continuations - without ConfigureAwait(continueOnCapturedContext: false) - execute asynchronously or synchronously depends on the presence of a synchronization context on the thread which was executing your code when it hit the await point. If SynchronizationContext.Current != null, the further behavior depends on the implementation of SynchronizationContext.Post.

E.g., if you are on the main UI thread of a WPF/WinForms app, your continuations will be executed on the same thread, but still asynchronously, upon some future iteration of the message loop. It will be posted via SynchronizationContext.Post. That's provided the antecedent task has completed on a thread pool thread, or on a different synchronization context (e.g., Why a unique synchronization context for each Dispatcher.BeginInvoke callback?).

If the antecedent task has completed on a thread with the same synchronization context (e.g. a WinForm UI thread), the await continuation will be executed synchronously (inlined). SynchronizationContext.Post will not be used in this case.

In the absence of synchronization context, an await continuation will be executed synchronously on the same thread the antecedent task has completed on.

This is how it is different from your ContinueWith with TaskContinuationOptions.ExecuteSynchronously implementation, which doesn't care at all about the synchronization context of either initial thread or completion thread, and always executes the continuation synchronously (there are exceptions to this behavior, nonetheless).

You can use ConfigureAwait(continueOnCapturedContext: false) to get closer to the desired behavior, but its semantic is still different from TaskContinuationOptions.ExecuteSynchronously. In fact, it instructs the scheduler to not run a continuation on a thread with any synchronization context, so you may experience situations where ConfigureAwait(false) pushes the continuation to thread pool, while you might have been expecting a synchronous execution.

Also related: Revisiting Task.ConfigureAwait(continueOnCapturedContext: false).

5
Servy On

Such optimizations are done on the task scheduler level. The task scheduler doesn't just have a big queue of tasks to do; it separates them into various tasks for each of the worker threads it has. When work is scheduled from one of those worker threads (which happens a lot when you have lots of continuations) it'll add it to the queue for that thread. This ensures that when you have an operation with a series of continuations that context switches between threads are minimized. Now if a thread runs out of work, it can pull work items from another thread's queue too, so that everyone can stay busy.

Of course, all of that said, none of the actual tasks that your awaiting in your code are actually CPU bound work; they're IO bound work, so they're not running on worker threads that could continue to be re-purposed to handle the continuation as well, since their work isn't being done by allocated threads in the first place.