AspNetSynchronizationContext y espera continuidades en ASP.NET

Observé un inesperado (y yo diría, un redundante) cambio de hilo después de await dentro del método asincrónico del controlador de API Web ASP.NET.

Por ejemplo, a continuación, esperaría ver el mismo ManagedThreadId en las ubicaciones n. ° 2 y n. ° 3, pero la mayoría de las veces veo un hilo diferente en n. ° 3:

 public class TestController : ApiController { public async Task 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"; } } 

He visto la implementación de AspNetSynchronizationContext.Post , esencialmente se trata de esto:

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

Por lo tanto, la continuación está progtwigda en ThreadPool , en lugar de estar ingresada. Aquí, ContinueWith utiliza TaskScheduler.Current , que en mi experiencia es siempre una instancia de ThreadPoolTaskScheduler dentro de ASP.NET (pero no tiene que ser eso, ver más abajo).

Podría eliminar un cambio de subproceso redundante como este con ConfigureAwait(false) o un awaiter personalizado, pero eso eliminaría el flujo automático de las propiedades de estado de la solicitud HTTP como HttpContext.Current .

Hay otro efecto secundario de la implementación actual de AspNetSynchronizationContext.Post . Resulta en un punto muerto en el siguiente caso:

 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(); 

Este ejemplo, aunque un poco artificial, muestra lo que puede suceder si TaskScheduler.Current es TaskScheduler.FromCurrentSynchronizationContext() , es decir, está hecho de AspNetSynchronizationContext . No utiliza ningún código de locking y se habría ejecutado sin problemas en WinForms o WPF.

Este comportamiento de AspNetSynchronizationContext es diferente de la implementación de v4.0 (que todavía está allí como LegacyAspNetSynchronizationContext ).

Entonces, ¿cuál es el motivo de tal cambio? Pensé que la idea detrás de esto podría ser reducir la brecha para los interlockings, pero aún es posible un punto muerto con la implementación actual, al usar Task.Wait() o Task.Result .

OMI, sería más apropiado decirlo así:

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

O, al menos, espero que use TaskScheduler.Default lugar de TaskScheduler.Current .

Si LegacyAspNetSynchronizationContext con en web.config , funciona como se desee: el contexto de sincronización se instala en el subproceso donde finalizó la tarea esperada, y la continuación es Sincrónicamente ejecutado allí.

Que la continuación se distribuya en un nuevo hilo en lugar de en línea es intencional. Vamos a desglosar esto:

  1. Llamas a Task.Delay (100). Después de 100 milisegundos, la Tarea subyacente pasará a un estado completado. Pero esa transición ocurrirá en un hilo de ThreadPool / IOCP arbitrario; no sucederá en un subproceso bajo el contexto de sincronización de ASP.NET.

  2. El .ContinueWith (…, ExecuteSynchronously) hará que Debug.WriteLine (2) tenga lugar en el hilo que hizo la transición de Task.Delay (100) a un estado de terminal. ContinuarCon él mismo devolverá una nueva Tarea.

  3. Estás esperando la tarea devuelta por [2]. Como el hilo que completa la Tarea [2] no está bajo el control del contexto de sincronización ASP.NET, el mecanismo async / await llamará a SynchronizationContext.Post. Este método se contrae siempre para despachar de forma asincrónica.

La maquinaria async / await tiene algunas optimizaciones para ejecutar continuaciones en línea en el hilo de completar en lugar de llamar a SynchronizationContext.Post, pero esa optimización solo se activa si el hilo de completar se está ejecutando actualmente en el contexto de sincronización al que está a punto de enviarse. Este no es el caso en su ejemplo anterior, ya que [2] se está ejecutando en un subproceso de grupo de subprocesos arbitrario, pero necesita enviarse de vuelta al AspNetSynchronizationContext para ejecutar la continuación [3]. Esto también explica por qué el salto de hilo no ocurre si usa .ConfigureAwait (falso): la continuación [3] puede estar en línea en [2] ya que se enviará en el contexto de sincronización predeterminado.

Para sus otras preguntas sobre: ​​Task.Wait () y Task.Result, el nuevo contexto de sincronización no fue pensado para reducir las condiciones de interlocking en relación con .NET 4.0. (De hecho, es un poco más fácil obtener lockings en el nuevo contexto de sincronización que en el contexto anterior). El nuevo contexto de sincronización tenía la intención de tener una implementación de .Post () que funcionara bien con la maquinaria async / await, que el viejo contexto de sincronización falló miserablemente al hacerlo. (La implementación del antiguo contexto de sincronización de .Post () consistía en bloquear el hilo de llamada hasta que la primitiva de sincronización estuviera disponible, luego enviar la callback en línea).

Llamar a Task.Wait () y Task.Result desde el subproceso de solicitud en una tarea que no se sabe que se complete aún puede causar interlockings, como llamar a Task.Wait () o Task.Result desde el subproceso de la interfaz de usuario en una aplicación Win Forms o WPF .

Finalmente, la rareza con Task.Factory.StartNew podría ser un error real. Pero hasta que haya un escenario real (no artificial) para respaldar esto, el equipo no estaría dispuesto a investigar esto más a fondo.

Ahora mi conjetura es que han implementado AspNetSynchronizationContext.Post esta manera para evitar una posibilidad de recursión infinita que podría conducir al desbordamiento de la stack. Eso podría suceder si se llama a Post desde la callback pasada a Post .

Aún así, creo que un cambio de hilo adicional puede ser demasiado caro para esto. Posiblemente se podría haber evitado así:

 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; } 

Basado en esta idea, he creado un sistema de espera personalizado que me proporciona el comportamiento deseado:

 await task.ConfigureContinue(synchronously: true); 

Utiliza SynchronizationContext.Post si la operación se completó sincrónicamente en el mismo marco de la stack, y SynchronizationContext.Send si lo hizo en un marco de stack diferente (incluso podría ser el mismo subproceso, reutilizado asincrónicamente por ThreadPool después de algunos ciclos):

 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 { ///  /// TestController ///  public class TestController : ApiController { public async Task 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"; } } ///  /// TaskExt ///  public static class TaskExt { ///  /// ConfigureContinue - http://stackoverflow.com/q/23062154/1768303 ///  public static ContextAwaiter ConfigureContinue(this Task @this, bool synchronously = true) { return new ContextAwaiter(@this, synchronously); } ///  /// ContextAwaiter /// TODO: non-generic version ///  public class ContextAwaiter : System.Runtime.CompilerServices.ICriticalNotifyCompletion { readonly bool _synchronously; readonly Task _task; public ContextAwaiter(Task task, bool synchronously) { _task = task; _synchronously = synchronously; } // awaiter methods public ContextAwaiter 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; } } } } }