AspNetSynchronizationContext e attendi continuazioni in ASP.NET

Ho notato uno switch di thread inaspettato (e direi ridondante) dopo l’ await nel metodo di controllo asincrono ASP.NET Web API.

Ad esempio, di seguito mi aspetto di vedere lo stesso ManagedThreadId nelle posizioni # 2 e 3 #, ma il più delle volte vedo un thread diverso al # 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"; } } 

Ho esaminato l’implementazione di AspNetSynchronizationContext.Post , essenzialmente si tratta di questo:

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

Pertanto, la continuazione è pianificata su ThreadPool , anziché essere inline. Qui, ContinueWith utilizza TaskScheduler.Current , che nella mia esperienza è sempre un’istanza di ThreadPoolTaskScheduler all’interno di ASP.NET (ma non deve essere così, vedi sotto).

Potrei eliminare un interruttore di thread ridondante come questo con ConfigureAwait(false) o un custom waiter, ma questo porterebbe via il stream automatico delle proprietà di stato della richiesta HTTP come HttpContext.Current .

C’è un altro effetto collaterale dell’attuale implementazione di AspNetSynchronizationContext.Post . Risulta in un deadlock nel seguente 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(); 

Questo esempio, anche se un po ‘forzato, mostra cosa può accadere se TaskScheduler.Current è TaskScheduler.FromCurrentSynchronizationContext() , cioè, creato da AspNetSynchronizationContext . Non usa alcun codice di blocco e sarebbe stato eseguito senza problemi in WinForms o WPF.

Questo comportamento di AspNetSynchronizationContext è diverso dall’implementazione v4.0 (che è ancora presente come LegacyAspNetSynchronizationContext ).

Quindi, qual è la ragione di questo cambiamento? Ho pensato, l’idea alla base di questo potrebbe essere quella di ridurre il divario per deadlock, ma deadlock sono ancora possibili con l’implementazione corrente, quando si utilizza Task.Wait() o Task.Result .

IMO, sarebbe più appropriato metterlo in questo modo:

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

O, almeno, mi aspetterei che usasse TaskScheduler.Default piuttosto che TaskScheduler.Current .

Se LegacyAspNetSynchronizationContext con in web.config , funziona come desiderato: il contesto di sincronizzazione viene installato sul thread in cui è terminata l’attività attesa e la continuazione è eseguito in modo sincrono lì.

Che la continuazione sia spedita su una nuova discussione piuttosto che in linea è intenzionale. Rompiamo questo:

  1. Stai chiamando Task.Delay (100). Dopo 100 millisecondi, l’attività sottostante passerà a uno stato completato. Ma quella transizione avverrà su un thread ThreadPool / IOCP arbitrario; non si verificherà su un thread nel contesto di sincronizzazione di ASP.NET.

  2. Il .ContinueWith (…, ExecuteSynchronously) farà in modo che Debug.WriteLine (2) si verifichi sul thread che ha passato Task.Delay (100) a uno stato terminale. ContinueWith stesso restituirà una nuova attività.

  3. Stai aspettando l’attività restituita da [2]. Poiché il thread che completa Task [2] non è sotto il controllo del contesto di sincronizzazione di ASP.NET, il meccanismo async / await chiamerà SynchronizationContext.Post. Questo metodo viene contratto sempre per la distribuzione in modo asincrono.

Il meccanismo asincrono / atteso ha alcune ottimizzazioni per eseguire continuazioni in linea sul thread di completamento piuttosto che chiamare SynchronizationContext.Post, ma quell’ottimizzazione viene triggersta solo se il thread di completamento è attualmente in esecuzione nel contesto di sincronizzazione a cui sta per essere inviato. Questo non è il caso nell’esempio sopra, poiché [2] è in esecuzione su un thread di thread thread arbitrario, ma è necessario rimandare all’AspNetSynchronizationContext per eseguire la continuazione [3]. Questo spiega anche perché l’hop del thread non si verifica se si utilizza .ConfigureAwait (false): la continuazione [3] può essere inline in [2] poiché verrà inviata nel contesto di sincronizzazione predefinito.

Per le altre domande su: Task.Wait () e Task.Result, il nuovo contesto di sincronizzazione non ha lo scopo di ridurre le condizioni di deadlock relative a .NET 4.0. (In effetti, è leggermente più facile ottenere deadlock nel nuovo contesto di sincronizzazione rispetto al vecchio contesto.) Il nuovo contesto di sincronizzazione doveva avere un’implementazione di .Post () che funziona bene con il meccanismo async / await, che il vecchio contesto di sincronizzazione ha fallito miseramente nel farlo. (L’implementazione del vecchio contesto di sincronizzazione di .Post () era di bloccare il thread chiamante fino a quando la primitiva di sincronizzazione era disponibile, quindi inviare la richiamata in linea.)

Chiamare Task.Wait () e Task.Result dal thread di richiesta su un’attività che non si sa essere completata può ancora causare deadlock, proprio come chiamare Task.Wait () o Task.Result dal thread UI in un’applicazione Win Form o WPF .

Infine, la stranezza con Task.Factory.StartNew potrebbe essere un bug reale. Ma fino a quando non ci sarà uno scenario (non forzato) reale per supportare questo, la squadra non sarebbe incline a indagare ulteriormente su questo.

Ora, suppongo, abbiano implementato AspNetSynchronizationContext.Post questo modo per evitare la possibilità di una ricorsione infinita che potrebbe causare un overflow dello stack. Ciò potrebbe accadere se Post viene chiamato dal callback passato a Post stesso.

Tuttavia, penso che un interruttore aggiuntivo potrebbe essere troppo costoso per questo. Potrebbe essere evitato in questo modo:

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

Sulla base di questa idea, ho creato un cameriere personalizzato che mi dà il comportamento desiderato:

 await task.ConfigureContinue(synchronously: true); 

Utilizza SynchronizationContext.Post se l’operazione viene completata in modo sincrono sullo stesso frame dello stack e SynchronizationContext.Send se lo fa su un frame di stack diverso (potrebbe anche essere lo stesso thread, riutilizzato in modo asincrono da ThreadPool dopo alcuni cicli):

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