Revisando Task.ConfigureAwait (continueOnCapturedContext: false)

Demasiado tiempo para leer. El uso de Task.ConfigureAwait(continueOnCapturedContext: false) puede estar introduciendo una conmutación redundante de subprocesos. Estoy buscando una solución consistente para eso.

Versión larga. El principal objective de diseño detrás de ConfigureAwait(false) es reducir las repeticiones de llamada de continuación redundantes de SynchronizationContext.Post para await , siempre que sea posible. Esto generalmente significa menos cambio de subprocesos y menos trabajo en los subprocesos de la interfaz de usuario. Sin embargo, no siempre es así como funciona.

Por ejemplo, hay una biblioteca de terceros que implementa la API SomeAsyncApi . Tenga en cuenta que ConfigureAwait(false) no se usa en ninguna parte de esta biblioteca, por alguna razón:

 // some library, SomeClass class public static async Task SomeAsyncApi() { TaskExt.Log("X1"); // await Task.Delay(1000) without ConfigureAwait(false); // WithCompletionLog only shows the actual Task.Delay completion thread // and doesn't change the awaiter behavior await Task.Delay(1000).WithCompletionLog(step: "X1.5"); TaskExt.Log("X2"); return 42; } // logging helpers public static partial class TaskExt { public static void Log(string step) { Debug.WriteLine(new { step, thread = Environment.CurrentManagedThreadId }); } public static Task WithCompletionLog(this Task anteTask, string step) { return anteTask.ContinueWith( _ => Log(step), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } } 

Ahora, digamos que hay algún código de cliente ejecutándose en un hilo de IU de WinForms y usando SomeAsyncApi :

 // another library, AnotherClass class public static async Task MethodAsync() { TaskExt.Log("B1"); await SomeClass.SomeAsyncApi().ConfigureAwait(false); TaskExt.Log("B2"); } // ... // a WinFroms app private async void Form1_Load(object sender, EventArgs e) { TaskExt.Log("A1"); await AnotherClass.MethodAsync(); TaskExt.Log("A2"); } 

La salida:

 {paso = A1, hilo = 9}
 {paso = B1, hilo = 9}
 {paso = X1, hilo = 9}
 {paso = X1.5, hilo = 11}
 {paso = X2, hilo = 9}
 {paso = B2, hilo = 11}
 {paso = A2, hilo = 9}

Aquí, el flujo de ejecución lógica pasa a través de 4 interruptores de hilo. 2 de ellos son redundantes y están causados ​​por SomeAsyncApi().ConfigureAwait(false) . Sucede porque ConfigureAwait(false) empuja la continuación a ThreadPool desde un subproceso con contexto de sincronización (en este caso, el subproceso UI).

En este caso particular, MethodAsync es mejor sin ConfigureAwait(false) . Entonces solo toma 2 interruptores de hilo frente a 4:

 {paso = A1, hilo = 9}
 {paso = B1, hilo = 9}
 {paso = X1, hilo = 9}
 {paso = X1.5, hilo = 11}
 {paso = X2, hilo = 9}
 {paso = B2, hilo = 9}
 {paso = A2, hilo = 9}

Sin embargo, el autor de MethodAsync usa ConfigureAwait(false) con todas las buenas intenciones y siguiendo las mejores prácticas , y no sabe nada sobre la implementación interna de SomeAsyncApi . No sería un problema si ConfigureAwait(false) se usara “todo el camino” (es decir, dentro de SomeAsyncApi también), pero eso está más allá de su control.

Así es como funciona con WindowsFormsSynchronizationContext (o DispatcherSynchronizationContext ), donde no nos preocupan los switches de subprocesos en absoluto. Sin embargo, una situación similar podría ocurrir en ASP.NET, donde AspNetSynchronizationContext.Post esencialmente hace esto:

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

Todo esto puede parecer un problema artificial, pero sí vi muchos códigos de producción como este, tanto del lado del cliente como del lado del servidor. Otro patrón cuestionable que encontré: await TaskCompletionSource.Task.ConfigureAwait(false) con SetResult siendo llamado en el mismo contexto de sincronización que el capturado para el antiguo en await . Una vez más, la continuación se ThreadPool redundante a ThreadPool . El razonamiento detrás de este patrón era que “ayuda a evitar interlockings”.

La pregunta : a la luz del comportamiento descrito de ConfigureAwait(false) , estoy buscando una manera elegante de utilizar async/await mientras se minimiza el cambio redundante de subprocesos / contextos. Idealmente, algo que funcionaría con las bibliotecas de terceros existentes.

Lo que he visto, hasta ahora :

  • La descarga de una lambda async con Task.Run no es ideal, ya que introduce al menos un conmutador de subproceso adicional (aunque potencialmente puede ahorrar muchos otros):

     await Task.Run(() => SomeAsyncApi()).ConfigureAwait(false); 
  • Otra solución hackish podría ser eliminar temporalmente el contexto de sincronización del hilo actual, por lo que no será capturado por ninguna espera posterior en la cadena interna de llamadas (lo mencioné anteriormente aquí ):

     async Task MethodAsync() { TaskExt.Log("B1"); await TaskExt.WithNoContext(() => SomeAsyncApi()).ConfigureAwait(false); TaskExt.Log("B2"); } 
     {paso = A1, hilo = 8}
     {paso = B1, hilo = 8}
     {paso = X1, hilo = 8}
     {paso = X1.5, hilo = 10}
     {paso = X2, hilo = 10}
     {paso = B2, hilo = 10}
     {paso = A2, hilo = 8}
    
     public static Task WithNoContext(Func<Task> func) { Task task; var sc = SynchronizationContext.Current; try { SynchronizationContext.SetSynchronizationContext(null); // do not await the task here, so the SC is restred right after // the execution point hits the first await inside func task = func(); } finally { SynchronizationContext.SetSynchronizationContext(sc); } return task; } 

    Esto funciona, pero no me gusta el hecho de que altera el contexto de sincronización actual del hilo, aunque por un scope muy corto. Además, hay otra implicación aquí: en ausencia de SynchronizationContext en el hilo actual, se TaskScheduler.Current un TaskScheduler.Current ambiente para await continuas. Para dar cuenta de esto, WithNoContext podría posiblemente modificarse como a continuación, lo que haría que este hack sea aún más exótico:

     // task = func(); var task2 = new Task<Task>(() => func()); task2.RunSynchronously(TaskScheduler.Default); task = task2.Unwrap(); 

Agradecería cualquier otra idea.

Actualizado , para abordar el comentario de @ i3arnon :

Yo diría que es al revés porque, como dijo Stephen en su respuesta, “el propósito de ConfigureAwait (falso) no es inducir un cambio de hilo (si es necesario), sino más bien evitar que se ejecute demasiado código en un contexto especial particular. ” con el que no está de acuerdo y es la raíz de su cumplimiento.

Como su respuesta ha sido editada, esta es su statement con la que no estoy de acuerdo, para mayor claridad:

El objective ConfigureAwait (false) es reducir, en la medida de lo posible, el trabajo que los subprocesos “especiales” (por ejemplo, UI) deben procesar a pesar de los conmutadores de subprocesos que requiere.

También estoy en desacuerdo con su versión actual de esa statement. Te referiré a la fuente principal, la publicación de blog de Stephen Toub:

Evitar Marshaling innecesario

Si es posible, asegúrese de que la implementación asíncrona a la que llama no necesita el hilo bloqueado para completar la operación (de ese modo, puede usar mecanismos de locking normales para esperar sincrónicamente para que el trabajo asíncrono se complete en otro lugar). En el caso de async / await, esto normalmente significa asegurarse de que cualquier espera dentro de la implementación asincrónica a la que llama usa ConfigureAwait (falso) en todos los puntos de espera; esto evitará que la espera intente regresar al actual SynchronizationContext. Como implementador de una biblioteca, es una buena práctica usar siempre ConfigureAwait (falso) en todos sus procesos pendientes, a menos que tenga una razón específica para no hacerlo; esto es bueno no solo para ayudar a evitar este tipo de problemas de interlocking, sino también para el rendimiento, ya que evita costos de clasificación innecesarios.

Sí dice que el objective es evitar costos de clasificación innecesarios para el rendimiento . Un cambio de hilo (que fluye el ExecutionContext , entre otras cosas) es un gran costo de cálculo.

Ahora, no dice en ninguna parte que el objective es reducir la cantidad de trabajo que se realiza en hilos o contextos “especiales”.

Si bien esto puede tener cierto sentido para los subprocesos de interfaz de usuario, todavía no creo que sea el objective principal detrás de ConfigureAwait . Hay otras formas más estructuradas de minimizar el trabajo en los subprocesos de UI, como usar fragmentos de await Task.Run(work) .

Además, no tiene sentido minimizar el trabajo en AspNetSynchronizationContext , que a su vez fluye de un hilo a otro, a diferencia de un subproceso de interfaz de usuario. Muy al contrario, una vez que estás en AspNetSynchronizationContext , quieres hacer tanto trabajo como sea posible para evitar el cambio innecesario en el medio de manejar la solicitud HTTP. Sin embargo, todavía tiene sentido usar ConfigureAwait(false) en ASP.NET: si se usa correctamente, reduce nuevamente la conmutación de hilos del lado del servidor.

Cuando se trata de operaciones asincrónicas, la sobrecarga de un conmutador de subprocesos es demasiado pequeña para preocuparse (en términos generales). El propósito de ConfigureAwait(false) no es inducir un cambio de subproceso (si es necesario), sino evitar que se ejecute demasiado código en un contexto especial particular.

El razonamiento detrás de este patrón era que “ayuda a evitar interlockings”.

Y astack las fichas.

Pero creo que esto no es un problema en el caso general. Cuando me encuentro con un código que no usa adecuadamente ConfigureAwait , simplemente lo Task.Run en una Task.Run y Task.Run adelante. La sobrecarga de los interruptores de hilo no vale la pena preocuparse.

El principal objective de diseño detrás de ConfigureAwait (falso) es reducir las repeticiones de llamada de continuación redundantes de SynchronizationContext.Post para esperar, siempre que sea posible. Esto generalmente significa menos cambio de subprocesos y menos trabajo en los subprocesos de la interfaz de usuario.

No estoy de acuerdo con tu premisa. ConfigureAwait(false) objective ConfigureAwait(false) es reducir, en la medida de lo posible, el trabajo que se debe reorganizar a contextos “especiales” (por ejemplo, UI) a pesar de los conmutadores de subprocesos que puede requerir fuera de ese contexto.

Si el objective era reducir los interruptores de hilo, podría permanecer en el mismo contexto especial durante todo el trabajo, y luego no se requieren otros hilos.

Para lograrlo, debe usar ConfigureAwait todos los lugares donde no le importe el subproceso que ejecuta la continuación. Si toma su ejemplo y utiliza ConfigureAwait adecuada, solo obtendrá un único modificador (en lugar de 2 sin él):

 private async void Button_Click(object sender, RoutedEventArgs e) { TaskExt.Log("A1"); await AnotherClass.MethodAsync().ConfigureAwait(false); TaskExt.Log("A2"); } public class AnotherClass { public static async Task MethodAsync() { TaskExt.Log("B1"); await SomeClass.SomeAsyncApi().ConfigureAwait(false); TaskExt.Log("B2"); } } public class SomeClass { public static async Task SomeAsyncApi() { TaskExt.Log("X1"); await Task.Delay(1000).WithCompletionLog(step: "X1.5").ConfigureAwait(false); TaskExt.Log("X2"); return 42; } } 

Salida:

 { step = A1, thread = 9 } { step = B1, thread = 9 } { step = X1, thread = 9 } { step = X1.5, thread = 11 } { step = X2, thread = 11 } { step = B2, thread = 11 } { step = A2, thread = 11 } 

Ahora, si le importa el hilo de la continuación (por ejemplo, cuando usa controles de UI), “paga” cambiando a ese hilo, publicando el trabajo relevante en ese hilo. Todavía has ganado de todo el trabajo que no requirió ese hilo.

Si desea llevarlo más lejos y eliminar el trabajo sincrónico de estos métodos async del subproceso UI, solo necesita usar Task.Run una vez y agregar otro switch:

 private async void Button_Click(object sender, RoutedEventArgs e) { TaskExt.Log("A1"); await Task.Run(() => AnotherClass.MethodAsync()).ConfigureAwait(false); TaskExt.Log("A2"); } 

Salida:

 { step = A1, thread = 9 } { step = B1, thread = 10 } { step = X1, thread = 10 } { step = X1.5, thread = 11 } { step = X2, thread = 11 } { step = B2, thread = 11 } { step = A2, thread = 11 } 

Esta guía para usar ConfigureAwait(false) está dirigida a los desarrolladores de la biblioteca porque es donde realmente importa, pero el punto es usarlo siempre que sea posible y en ese caso se reduce el trabajo en estos contextos especiales mientras se mantiene la conmutación de hilos al mínimo.


Usar WithNoContext tiene exactamente el mismo resultado que usar ConfigureAwait(false) todas partes. Sin embargo, el inconveniente es que se mezcla con el SynchronizationContext la SynchronizationContext y que no se tiene conocimiento de eso dentro del método async . ConfigureAwait afecta directamente la await actual para que tenga la causa y el efecto juntos.

Usar Task.Run también, como he señalado, tiene exactamente el mismo resultado que usar ConfigureAwait(false) todas partes con el valor agregado de descargar las partes sincrónicas del método async al ThreadPool . Si esto es necesario, Task.Run es apropiado, de lo contrario ConfigureAwait(false) es suficiente.


Ahora, si está tratando con una biblioteca con errores cuando ConfigureAwait(false) no se usa apropiadamente, puede hackearlo eliminando el SynchronizationContext pero usando Thread.Run es mucho más simple y más claro y el trabajo de descarga al ThreadPool tiene un muy insignificante sobrecarga.