7

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!

Spi
  • 686
  • 3
  • 18
  • The async version is not actually synchronous. Where did you get that idea? – Martijn Jun 18 '15 at 15:30
  • @Martijn: Sorry it it was not clear. My point is that when the continuation is ONLY `if( !openTask.IsFaulted ) openTask.Result.Dispose();`, I want it to be executed directly on the "previous" running task in order to avoid any scheduling/context switching. – Spi Jun 18 '15 at 15:45
  • From decompiling TaskAwaiter I think this can't be done with the built-in awaiter. You need to write your own awaiter. Likely not worth it. – usr Jun 18 '15 at 16:26
  • @usr Thanks. Be sure I won't try this ;). – Spi Jun 18 '15 at 16:33

2 Answers2

10

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).

Community
  • 1
  • 1
noseratio
  • 59,932
  • 34
  • 208
  • 486
  • 1
    This is the answer. I just tested this behavior. The await continuation can indeed be inlined. http://pastebin.com/0eem3jDh That prints 9, 11, 11. The call stack shows that the timer expired which caused the continuation to run synchronously which caused the await continuation to run synchronously as well. The relevant code is in RunOrScheduleAction. – usr Jun 18 '15 at 17:28
  • @usr, tks. I indeed forgot to mention the inlining behavior. On a thread *with* synchronization context an `await` continuation will be inlined *if* the ante task has completed on a thread with the same sync. context (e.g. a UI thread) and `ConfigureAwait(false)` was not used. – noseratio Jun 18 '15 at 17:58
  • 1
    Fascinating. It's scary how much nastiness lies in edge cases around the TPL, await and sync contexts. The team has made terrible default choices in the name of performance. – usr Jun 18 '15 at 18:15
  • @usr, I agree. I think it might be useful to have something like `await task.ConfigureContinuation(bool async: true)`. Which would always continue either asynchronously (via `SC.Post` or `ThreadPool.QueueUserWorkItem`) or synchronously, regardless of synchronization context. – noseratio Jun 18 '15 at 18:26
  • 1
    Yes, all reentrancy must be disabled by default (especially SetResult). Otherwise it's easy to catch some piece of code in the middle of some mutating operation with invariants broken. The implicit child attach feature also was a big blunder because it works against composability. – usr Jun 18 '15 at 18:41
  • @usr, apparently they've tried to mitigate this with [`Task{Creation/Continuation}Options.RunContinuationsAsynchronously`](http://blogs.msdn.com/b/pfxteam/archive/2015/02/02/new-task-apis-in-net-4-6.aspx), especially for `TaskCompletionSource/SetResult`. – noseratio Jun 18 '15 at 18:45
0

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.

Servy
  • 202,030
  • 26
  • 332
  • 449
  • 1
    Right :). The two calls here end up to OpenConnectionAsync and ExecuteQueryAsync that (at least in recent .Net framework as far as I know) are correctly implemented: these I/O bound operations actually run on I/O threads, not on ThreadPool threads. BUT, there are small piece of code that must be executed before and after reaching these "real async points". My idea was that `ExecuteSynchronously`, as Stephen Toub explains it here (http://blogs.msdn.com/b/pfxteam/archive/2012/02/07/10265067.aspx), avoids the queuing, even if it is in the same thread... – Spi Jun 18 '15 at 16:14
  • @Spi They don't run on IO threads; they *aren't run on threads at all*. They're *actually* asynchronous operations, rather than synchronous operations that involve a thread sitting there waiting for it to finish. – Servy Jun 18 '15 at 16:16
  • The completion does run on pool/IO threads (doesn't matter which) and there is queueing overhead without ExecuteSynchronously. – usr Jun 18 '15 at 16:23
  • @usr It's a *very, very* small overhead though. Adding an item to a queue and then immediately getting that item back out again and running it is an *extremely* low cost. The context switches between threads are what one needs to be concerned about, and those are avoided in all of the situations that they can be. – Servy Jun 18 '15 at 16:27
  • @usr, @Servy: I think we agree. `await` does not allow me to express this, but avoiding the queing is not a big issue. @usr: thanks for your precision: "The completion does run on pool/IO threads", I did not know how to respond to "they aren't run on threads at all." ;) – Spi Jun 18 '15 at 16:40