AspNetSynchronizationContext и ожидает продолжения в ASP.NET

Я заметил неожиданный (и, я бы сказал, лишний) потоковый переключатель после await внутри асинхронного метода контроллера ASP.NET Web API.

Например, ниже я ожидаю увидеть те же самые ManagedThreadId в местах № 2 и 3 #, но чаще всего я вижу другой поток на # 3:

public class TestController : ApiController
{
    public async Task<string> GetData()
    {
        Debug.WriteLine(new
        {
            where = "1) before await",
            thread = Thread.CurrentThread.ManagedThreadId,
            context = SynchronizationContext.Current
        });

        await Task.Delay(100).ContinueWith(t =>
        {
            Debug.WriteLine(new
            {
                where = "2) inside ContinueWith",
                thread = Thread.CurrentThread.ManagedThreadId,
                context = SynchronizationContext.Current
            });
        }, TaskContinuationOptions.ExecuteSynchronously); //.ConfigureAwait(false);

        Debug.WriteLine(new
        {
            where = "3) after await",
            thread = Thread.CurrentThread.ManagedThreadId,
            context = SynchronizationContext.Current
        });

        return "OK";
    }
}

Я посмотрел на реализацию AspNetSynchronizationContext.Post, по сути это сводится к следующему:

Task newTask = _lastScheduledTask.ContinueWith(_ => SafeWrapCallback(action));
_lastScheduledTask = newTask;

Таким образом, продолжение запланировано на ThreadPool, а не нарисовано. Здесь ContinueWith использует TaskScheduler.Current, который по моему опыту всегда является экземпляром ThreadPoolTaskScheduler внутри ASP.NET(но это не обязательно, см. Ниже).

Я мог бы устранить избыточный переключатель потока, подобный этому, с помощью ConfigureAwait(false) или пользовательского awaiter, но это уберет автоматический поток свойств состояния HTTP-запроса, например HttpContext.Current.

Там есть другой побочный эффект текущей реализации AspNetSynchronizationContext.Post. Это приводит к взаимоблокировке в следующем случае:

await Task.Factory.StartNew(
    async () =>
    {
        return await Task.Factory.StartNew(
            () => Type.Missing,
            CancellationToken.None,
            TaskCreationOptions.None,
            scheduler: TaskScheduler.FromCurrentSynchronizationContext());
    },
    CancellationToken.None,
    TaskCreationOptions.None,
    scheduler: TaskScheduler.FromCurrentSynchronizationContext()).Unwrap();

Этот пример, хотя и немного надуманный, показывает, что может произойти, если TaskScheduler.Current есть TaskScheduler.FromCurrentSynchronizationContext(), т.е. сделано из AspNetSynchronizationContext. Он не использует какой-либо код блокировки и был бы выполнен гладко в WinForms или WPF.

Это поведение AspNetSynchronizationContext отличается от реализации v4.0 (которое все еще существует как LegacyAspNetSynchronizationContext).

Итак, в чем причина таких изменений? Я подумал, что идея в этом может заключаться в сокращении разрыва для взаимоблокировок, но тупиковая ситуация по-прежнему возможна с текущей реализацией при использовании Task.Wait() или Task.Result.

IMO, было бы более целесообразно сделать следующее:

Task newTask = _lastScheduledTask.ContinueWith(_ => SafeWrapCallback(action),
    TaskContinuationOptions.ExecuteSynchronously);
_lastScheduledTask = newTask;

Или, по крайней мере, я ожидаю, что он будет использовать TaskScheduler.Default, а не TaskScheduler.Current.

Если я включаю LegacyAspNetSynchronizationContext с <add key="aspnet:UseTaskFriendlySynchronizationContext" value="false" /> в web.config, он работает по желанию: контекст синхронизации устанавливается в потоке, где ожидаемая задача завершена, и продолжение там синхронно выполняется.

Ответ 1

Теперь я предполагаю, что они внедрили AspNetSynchronizationContext.Post таким образом, чтобы избежать возможности бесконечной рекурсии, которая может привести к переполнению стека. Это может произойти, если вызов Post вызывается из обратного вызова, переданного самому Post.

Тем не менее, я думаю, что дополнительный переключатель потока может быть слишком дорогим для этого. Возможно, это можно было бы избежать следующим образом:

var sameStackFrame = true
try
{
    //TODO: also use TaskScheduler.Default rather than TaskScheduler.Current 
    Task newTask = _lastScheduledTask.ContinueWith(completedTask => 
    {
        if (sameStackFrame) // avoid potential recursion
           return completedTask.ContinueWith(_ => SafeWrapCallback(action));
        else 
        {
           SafeWrapCallback(action);
           return completedTask;
        }
    }, TaskContinuationOptions.ExecuteSynchronously).Unwrap();

    _lastScheduledTask = newTask;    
}
finally
{
    sameStackFrame = false;
}

Основываясь на этой идее, я создал пользовательский awaiter, который дает мне желаемое поведение:

await task.ConfigureContinue(synchronously: true);

Он использует SynchronizationContext.Post, если операция выполняется синхронно в одном стеке стека, и SynchronizationContext.Send, если она выполнялась в другом стеке стека (это может быть даже один и тот же поток, асинхронно повторно используемый ThreadPool после некоторых циклов):

using System;
using System.Diagnostics;
using System.Runtime.Remoting.Messaging;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using System.Web.Http;

namespace TestApp.Controllers
{
    /// <summary>
    /// TestController
    /// </summary>
    public class TestController : ApiController
    {
        public async Task<string> GetData()
        {
            Debug.WriteLine(String.Empty);

            Debug.WriteLine(new
            {
                where = "before await",
                thread = Thread.CurrentThread.ManagedThreadId,
                context = SynchronizationContext.Current
            });

            // add some state to flow
            HttpContext.Current.Items.Add("_context_key", "_contextValue");
            CallContext.LogicalSetData("_key", "_value");

            var task = Task.Delay(100).ContinueWith(t =>
            {
                Debug.WriteLine(new
                {
                    where = "inside ContinueWith",
                    thread = Thread.CurrentThread.ManagedThreadId,
                    context = SynchronizationContext.Current
                });
                // return something as we only have the generic awaiter so far
                return Type.Missing; 
            }, TaskContinuationOptions.ExecuteSynchronously);

            await task.ConfigureContinue(synchronously: true);

            Debug.WriteLine(new
            {
                logicalData = CallContext.LogicalGetData("_key"),
                contextData = HttpContext.Current.Items["_context_key"],
                where = "after await",
                thread = Thread.CurrentThread.ManagedThreadId,
                context = SynchronizationContext.Current
            });

            return "OK";
        }
    }

    /// <summary>
    /// TaskExt
    /// </summary>
    public static class TaskExt
    {
        /// <summary>
        /// ConfigureContinue - http://stackoverflow.com/q/23062154/1768303
        /// </summary>
        public static ContextAwaiter<TResult> ConfigureContinue<TResult>(this Task<TResult> @this, bool synchronously = true)
        {
            return new ContextAwaiter<TResult>(@this, synchronously);
        }

        /// <summary>
        /// ContextAwaiter
        /// TODO: non-generic version 
        /// </summary>
        public class ContextAwaiter<TResult> :
            System.Runtime.CompilerServices.ICriticalNotifyCompletion
        {
            readonly bool _synchronously;
            readonly Task<TResult> _task;

            public ContextAwaiter(Task<TResult> task, bool synchronously)
            {
                _task = task;
                _synchronously = synchronously;
            }

            // awaiter methods
            public ContextAwaiter<TResult> GetAwaiter()
            {
                return this;
            }

            public bool IsCompleted
            {
                get { return _task.IsCompleted; }
            }

            public TResult GetResult()
            {
                return _task.Result;
            }

            // ICriticalNotifyCompletion
            public void OnCompleted(Action continuation)
            {
                UnsafeOnCompleted(continuation);
            }

            // Why UnsafeOnCompleted? http://blogs.msdn.com/b/pfxteam/archive/2012/02/29/10274035.aspx
            public void UnsafeOnCompleted(Action continuation)
            {
                var syncContext = SynchronizationContext.Current;
                var sameStackFrame = true; 
                try
                {
                    _task.ContinueWith(_ => 
                    {
                        if (null != syncContext)
                        {
                            // async if the same stack frame
                            if (sameStackFrame)
                                syncContext.Post(__ => continuation(), null);
                            else
                                syncContext.Send(__ => continuation(), null);
                        }
                        else
                        {
                            continuation();
                        }
                    }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
                }
                finally
                {
                    sameStackFrame = false;
                }
            }
        }
    }
}

Ответ 2

То, что продолжение отправляется на новый поток, а не в наклонном, является намеренным. Пусть это сломается:

  • Вы вызываете Task.Delay(100). После 100 миллисекунд основная задача перейдет в завершенное состояние. Но этот переход произойдет на произвольном потоке ThreadPool/IOCP; это не произойдет в потоке в контексте синхронизации ASP.NET.

  • .ContinueWith(..., ExecuteSynchronously) приведет к тому, что Debug.WriteLine(2) будет проходить в потоке, который переместил Task.Delay(100) в состояние терминала. ContinueWith сам вернет новую задачу.

  • Ожидаете задачу, возвращенную [2]. Поскольку поток, который завершает Task [2], не находится под управлением контекста синхронизации ASP.NET, механизм async/wait вызовет SynchronizationContext.Post. Этот метод сжимается всегда для отправки асинхронно.

Механизм async/await имеет некоторые оптимизации для выполнения продолжений inline в заполняющем потоке, а не для вызова SynchronizationContext.Post, но эта оптимизация только начинается, если завершающий поток в настоящее время работает в контексте синхронизации, который он собирается отправить в, Это не так в вашем примере выше, так как [2] работает в потоке пула потоков, но ему нужно отправить обратно в AspNetSynchronizationContext, чтобы запустить продолжение [3]. Это также объясняет, почему скачок потока не возникает, если вы используете .ConfigureAwait(false): продолжение [3] может быть включено в [2], поскольку оно будет отправлено в контексте синхронизации по умолчанию.

На другие ваши вопросы: Task.Wait() и Task.Result, новый контекст синхронизации не был предназначен для снижения условий взаимоблокировки относительно .NET 4.0. (На самом деле, немного легче получить взаимоблокировки в новом контексте синхронизации, чем в прежнем контексте.) Новый контекст синхронизации предполагал реализацию .Post(), которая хорошо сочетается с механизмом async/wait, который старый контекст синхронизации неудачно провалился. (Старая реализация контекста синхронизации .Post() заключалась в блокировании вызывающего потока до тех пор, пока не будет доступен примитив синхронизации, а затем отправьте обратный вызов inline.)

Вызов Task.Wait() и Task.Result из потока запросов в задаче, которая не известна для завершения, все равно может вызвать взаимоблокировки, так же как вызов Task.Wait() или Task.Result из потока пользовательского интерфейса в Win Forms или WPF.

Наконец, странность с Task.Factory.StartNew может быть фактической ошибкой. Но до тех пор, пока не будет фактический (не надуманный) сценарий для поддержки этого, команда не будет склонна исследовать это дальше.