c# - net - Default SynchronizationContext vs Default TaskScheduler
set task scheduler c# (2)
Cuando comienza a profundizar en los detalles de implementación, es importante diferenciar entre el comportamiento documentado / confiable y el comportamiento no documentado. Además, realmente no se considera adecuado tener SynchronizationContext.Current
configurado en el new SynchronizationContext()
; algunos tipos en .NET tratan null
como el planificador predeterminado, y otros tipos tratan null
o new SynchronizationContext()
como el planificador predeterminado.
Cuando TaskAwaiter
una Task
incompleta, TaskAwaiter
captura de forma predeterminada el SynchronizationContext
actual, a menos que sea null
(o GetType
devuelve typeof(SynchronizationContext)
), en cuyo caso TaskAwaiter
captura el TaskScheduler
actual. Este comportamiento está principalmente documentado (la cláusula GetType
no es AFAIK). Sin embargo, tenga en cuenta que esto describe el comportamiento de TaskAwaiter
, no TaskScheduler.Default
o TaskFactory.StartNew
.
Después de capturar el contexto (si hay alguno), await
horarios una continuación. Esta continuación está programada con ExecuteSynchronously
, como se describe en mi blog (este comportamiento no está documentado). Sin embargo, tenga en cuenta que ExecuteSynchronously
no siempre se ejecuta de forma síncrona ; en particular, si una continuación tiene un planificador de tareas, solo solicitará ejecutar de forma síncrona en el hilo actual, y el planificador de tareas tiene la opción de rechazar ejecutarlo de forma síncrona (también indocumentado).
Finalmente, tenga en cuenta que se le puede solicitar a TaskScheduler
que ejecute una tarea de forma síncrona, pero un SynchronizationContext
no puede. Entonces, si el await
captura un SynchronizationContext
personalizado, entonces siempre debe ejecutar la continuación de forma asincrónica.
Por lo tanto, en su prueba original n. ° 1:
-
StartNew
inicia una nueva tarea con el programador de tareas predeterminado (en el hilo 10). -
SetResult
ejecuta de forma síncrona el conjunto de continuación mediante laawait tcs.Task
. - Al final de la tarea
StartNew
, ejecuta sincrónicamente el conjunto de continuación mediante laawait task
.
En su prueba original n. ° 2:
-
StartNew
inicia una nueva tarea con un contenedor de programador de tareas para un contexto de sincronización construido por defecto (en el hilo 10). Tenga en cuenta que la tarea en el hilo 10 tieneTaskScheduler.Current
configurado enSynchronizationContextTaskScheduler
cuyom_synchronizationContext
es la instancia creada por elnew SynchronizationContext()
; sin embargo,SynchronizationContext.Current
ese subproceso esnull
. -
SetResult
intenta ejecutar la continuación deawait tcs.Task
sincrónicamente en el planificador de tareas actual; sin embargo, no puede porqueSynchronizationContextTaskScheduler
ve que el hilo 10 tiene unSynchronizationContext.Current
ofnull
mientras requiere unnew SynchronizationContext()
. Por lo tanto, programa la continuación de forma asincrónica (en el hilo 11). - Una situación similar ocurre al final de la tarea de
StartNew
; en este caso, creo que es una coincidencia que laawait task
continúe en el mismo hilo.
En conclusión, debo enfatizar que depender de los detalles de la implementación indocumentada no es prudente. Si desea que su método async
continúe en un hilo del grupo de subprocesos, envuélvalo en una Task.Run
. Task.Run
. Eso hará que la intención de su código sea mucho más clara, y también hará que su código sea más resistente a futuras actualizaciones del marco. Además, no configure SynchronizationContext.Current
en el new SynchronizationContext()
, ya que el manejo de ese escenario es incoherente.
Esto va a ser un poco largo, así que por favor tengan paciencia conmigo.
Estaba pensando que el comportamiento del programador de tareas predeterminado ( ThreadPoolTaskScheduler
) es muy similar al del SynchronizationContext
" ThreadPool
" predeterminado (este último se puede referenciar implícitamente mediante TaskScheduler.FromCurrentSynchronizationContext()
o explícitamente a través de TaskScheduler.FromCurrentSynchronizationContext()
). Ambos programan tareas que se ejecutarán en un hilo aleatorio de ThreadPool
. De hecho, SynchronizationContext.Post
simplemente llama a ThreadPool.QueueUserWorkItem
.
Sin embargo, hay una diferencia sutil pero importante en cómo funciona TaskCompletionSource.SetResult
, cuando se utiliza desde una tarea en cola en el SynchronizationContext
predeterminado. Aquí hay una aplicación de consola simple que lo ilustra:
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleTcs
{
class Program
{
static async Task TcsTest(TaskScheduler taskScheduler)
{
var tcs = new TaskCompletionSource<bool>();
var task = Task.Factory.StartNew(() =>
{
Thread.Sleep(1000);
Console.WriteLine("before tcs.SetResult, thread: " + Thread.CurrentThread.ManagedThreadId);
tcs.SetResult(true);
Console.WriteLine("after tcs.SetResult, thread: " + Thread.CurrentThread.ManagedThreadId);
Thread.Sleep(2000);
},
CancellationToken.None,
TaskCreationOptions.None,
taskScheduler);
Console.WriteLine("before await tcs.Task, thread: " + Thread.CurrentThread.ManagedThreadId);
await tcs.Task.ConfigureAwait(true);
Console.WriteLine("after await tcs.Task, thread: " + Thread.CurrentThread.ManagedThreadId);
await task.ConfigureAwait(true);
Console.WriteLine("after await task, thread: " + Thread.CurrentThread.ManagedThreadId);
}
// Main
static void Main(string[] args)
{
// SynchronizationContext.Current is null
// install default SynchronizationContext on the thread
SynchronizationContext.SetSynchronizationContext(new SynchronizationContext());
// use TaskScheduler.Default for Task.Factory.StartNew
Console.WriteLine("Test #1, thread: " + Thread.CurrentThread.ManagedThreadId);
TcsTest(TaskScheduler.Default).Wait();
// use TaskScheduler.FromCurrentSynchronizationContext() for Task.Factory.StartNew
Console.WriteLine("/nTest #2, thread: " + Thread.CurrentThread.ManagedThreadId);
TcsTest(TaskScheduler.FromCurrentSynchronizationContext()).Wait();
Console.WriteLine("/nPress enter to exit, thread: " + Thread.CurrentThread.ManagedThreadId);
Console.ReadLine();
}
}
}
La salida:
Test #1, thread: 9 before await tcs.Task, thread: 9 before tcs.SetResult, thread: 10 after await tcs.Task, thread: 10 after tcs.SetResult, thread: 10 after await task, thread: 10 Test #2, thread: 9 before await tcs.Task, thread: 9 before tcs.SetResult, thread: 10 after tcs.SetResult, thread: 10 after await tcs.Task, thread: 11 after await task, thread: 11 Press enter to exit, thread: 9
Esta es una aplicación de consola, su hilo Main
no tiene ningún contexto de sincronización de forma predeterminada, por lo que instalo explícitamente el predeterminado al principio, antes de ejecutar las pruebas: SynchronizationContext.SetSynchronizationContext(new SynchronizationContext())
.
Inicialmente, pensé que comprendí completamente el flujo de trabajo de ejecución durante la prueba n. ° 1 (donde la tarea está programada con TaskScheduler.Default
). There tcs.SetResult
invoca de forma sincrónica la primera parte de continuación ( await tcs.Task
), luego el punto de ejecución vuelve a tcs.SetResult
y continúa sincrónicamente para siempre, incluida la segunda await task
. Eso tuvo sentido para mí, hasta que me di cuenta de lo siguiente . Como ahora tenemos el contexto de sincronización predeterminado instalado en el hilo que await tcs.Task
, debe capturarse y la continuación debe ocurrir de forma asincrónica (es decir, en un hilo de grupo diferente puesto en cola por SynchronizationContext.Post
). Por analogía, si ejecuté la prueba n. ° 1 desde una aplicación de WinForms, se habría continuado de forma asíncrona después de await tcs.Task
, en WinFormsSynchronizationContext
en una futura iteración del ciclo de mensajes.
Pero eso no es lo que sucede dentro de la prueba n. ° 1. Por curiosidad, cambié ConfigureAwait(true)
a ConfigureAwait(false)
y eso no tuvo ningún efecto en la salida. Estoy buscando una explicación de esto.
Ahora, durante la prueba n. ° 2 (la tarea está programada con TaskScheduler.FromCurrentSynchronizationContext()
), de hecho hay un cambio de TaskScheduler.FromCurrentSynchronizationContext()
más, en comparación con el n. ° 1. Como se puede ver en el resultado, la continuación de await tcs.Task
activada por tcs.SetResult
se produce de forma asincrónica en otro subproceso del grupo. Intenté con ConfigureAwait(false)
también, eso tampoco cambió nada. También traté de instalar SynchronizationContext
inmediatamente antes de comenzar la prueba n. ° 2 , en lugar de hacerlo al principio. Eso dio como resultado exactamente el mismo resultado, tampoco.
De hecho, me gusta más el comportamiento de la prueba n. ° 2, porque deja menos espacio para los efectos secundarios (y, potencialmente, interbloqueos) que pueden ser causados por la continuación síncrona activada por tcs.SetResult
, aunque a un precio de un interruptor de hilo adicional. Sin embargo, no entiendo completamente por qué dicho cambio de hilo tiene lugar independientemente de ConfigureAwait(false)
.
Estoy familiarizado con los siguientes excelentes recursos sobre el tema, pero todavía estoy buscando una buena explicación de los comportamientos vistos en las pruebas n. ° 1 y n. ° 2. ¿Alguien puede dar más detalles sobre esto?
La naturaleza de TaskCompletionSource
Programación en paralelo: Programadores de tareas y contexto de sincronización
Programación paralela: TaskScheduler.FromCurrentSynchronizationContext
Se trata del SynchronizationContext
[ACTUALIZACIÓN] Lo que quiero decir es que el objeto de contexto de sincronización predeterminado se ha instalado explícitamente en el hilo principal, antes de que el hilo await tcs.Task
al primer await tcs.Task
En la prueba n.º 1. IMO, el hecho de que no es un contexto de sincronización GUI no significa que no deba capturarse para continuar después de await
. Es por eso que espero que la continuación después de tcs.SetResult
tenga lugar en un hilo diferente de ThreadPool
(en cola allí por SynchronizationContext.Post
), mientras que el hilo principal aún puede ser bloqueado por TcsTest(...).Wait()
. Este es un escenario muy similar al descrito aquí .
Así que seguí adelante e implementé una clase de contexto de sincronización tonta TestSyncContext
, que es solo una envoltura alrededor de SynchronizationContext
. Ahora está instalado en lugar del propio SynchronizationContext
:
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ConsoleTcs
{
public class TestSyncContext : SynchronizationContext
{
public override void Post(SendOrPostCallback d, object state)
{
Console.WriteLine("TestSyncContext.Post, thread: " + Thread.CurrentThread.ManagedThreadId);
base.Post(d, state);
}
public override void Send(SendOrPostCallback d, object state)
{
Console.WriteLine("TestSyncContext.Send, thread: " + Thread.CurrentThread.ManagedThreadId);
base.Send(d, state);
}
};
class Program
{
static async Task TcsTest(TaskScheduler taskScheduler)
{
var tcs = new TaskCompletionSource<bool>();
var task = Task.Factory.StartNew(() =>
{
Thread.Sleep(1000);
Console.WriteLine("before tcs.SetResult, thread: " + Thread.CurrentThread.ManagedThreadId);
tcs.SetResult(true);
Console.WriteLine("after tcs.SetResult, thread: " + Thread.CurrentThread.ManagedThreadId);
Thread.Sleep(2000);
},
CancellationToken.None,
TaskCreationOptions.None,
taskScheduler);
Console.WriteLine("before await tcs.Task, thread: " + Thread.CurrentThread.ManagedThreadId);
await tcs.Task.ConfigureAwait(true);
Console.WriteLine("after await tcs.Task, thread: " + Thread.CurrentThread.ManagedThreadId);
await task.ConfigureAwait(true);
Console.WriteLine("after await task, thread: " + Thread.CurrentThread.ManagedThreadId);
}
// Main
static void Main(string[] args)
{
// SynchronizationContext.Current is null
// install default SynchronizationContext on the thread
SynchronizationContext.SetSynchronizationContext(new TestSyncContext());
// use TaskScheduler.Default for Task.Factory.StartNew
Console.WriteLine("Test #1, thread: " + Thread.CurrentThread.ManagedThreadId);
TcsTest(TaskScheduler.Default).Wait();
// use TaskScheduler.FromCurrentSynchronizationContext() for Task.Factory.StartNew
Console.WriteLine("/nTest #2, thread: " + Thread.CurrentThread.ManagedThreadId);
TcsTest(TaskScheduler.FromCurrentSynchronizationContext()).Wait();
Console.WriteLine("/nPress enter to exit, thread: " + Thread.CurrentThread.ManagedThreadId);
Console.ReadLine();
}
}
}
¡Mágicamente, las cosas han cambiado de una mejor manera! Aquí está el nuevo resultado:
Test #1, thread: 10 before await tcs.Task, thread: 10 before tcs.SetResult, thread: 6 TestSyncContext.Post, thread: 6 after tcs.SetResult, thread: 6 after await tcs.Task, thread: 11 after await task, thread: 6 Test #2, thread: 10 TestSyncContext.Post, thread: 10 before await tcs.Task, thread: 10 before tcs.SetResult, thread: 11 TestSyncContext.Post, thread: 11 after tcs.SetResult, thread: 11 after await tcs.Task, thread: 12 after await task, thread: 12 Press enter to exit, thread: 10
Ahora la prueba # 1 ahora se comporta como se esperaba ( await tcs.Task
se pone en cola asíncronamente a un subproceso de grupo). # 2 parece estar bien, también. Cambiemos ConfigureAwait(true)
a ConfigureAwait(false)
:
Test #1, thread: 9 before await tcs.Task, thread: 9 before tcs.SetResult, thread: 10 after await tcs.Task, thread: 10 after tcs.SetResult, thread: 10 after await task, thread: 10 Test #2, thread: 9 TestSyncContext.Post, thread: 9 before await tcs.Task, thread: 9 before tcs.SetResult, thread: 11 after tcs.SetResult, thread: 11 after await tcs.Task, thread: 10 after await task, thread: 10 Press enter to exit, thread: 9
La prueba n. ° 1 todavía se comporta correctamente como se esperaba: ConfigureAwait(false)
hace que la await tcs.Task
ignore el contexto de sincronización (la llamada TestSyncContext.Post
se ha ido), por lo que ahora continúa de forma síncrona después de tcs.SetResult
.
¿Por qué es esto diferente del caso cuando se usa SynchronizationContext
defecto? Todavía tengo curiosidad por saber. Tal vez, el programador de tareas predeterminado (que es responsable de las continuidades de await
) comprueba la información del tipo de tiempo de ejecución del contexto de sincronización de la secuencia, y da un tratamiento especial a SynchronizationContext
?
Ahora, todavía no puedo explicar el comportamiento de la prueba # 2 para cuando ConfigureAwait(false)
. Es una llamada menos TestSyncContext.Post
, eso se entiende. Sin embargo, await tcs.Task
todavía se continúa en un hilo diferente de tcs.SetResult
(a diferencia del n. ° 1), eso no es lo que esperaría. Todavía estoy buscando una razón para esto.
SynchronizationContext
simplemente llama a ThreadPool.QueueUserWorkItem
en la publicación, lo que explica por qué siempre se ve un hilo diferente en la prueba n.º 2.
En la prueba n. ° 1 está utilizando un TaskScheduler
más inteligente. await
se supone que debe continuar en el mismo hilo (o "permanecer en el hilo actual" ). En una aplicación de consola no hay forma de "programar" regresar al hilo principal como lo hace en los marcos de interfaz de usuario basados en cola de mensajes. Una await
en una aplicación de consola tendría que bloquear el hilo principal hasta que el trabajo esté terminado (dejando el hilo principal sin nada que hacer) para continuar en ese mismo hilo. Si el planificador lo sabe , entonces también podría ejecutar el código de forma sincrónica en el mismo subproceso, ya que tendría el mismo resultado sin tener que crear otro subproceso y arriesgarse a un cambio de contexto.
Puede encontrar más información aquí: http://blogs.msdn.com/b/pfxteam/archive/2012/01/20/10259049.aspx
Actualización : en términos de ConfigureAwait
. Las aplicaciones de consola no tienen forma de "ordenar" de vuelta al hilo principal, así que, presumiblemente, ConfigureAwait(false)
no significa nada en una aplicación de consola.
Ver también: http://msdn.microsoft.com/en-us/magazine/jj991977.aspx