Informática en paralelo

Todo gira en torno SynchronizationContext

Stephen Cleary

La programación multiproceso puede ser bastante difícil y existe un enorme conjunto de conceptos y herramientas que aprender cuando uno se embarca en esta tarea. Para ayudar, Microsoft .NET Framework proporciona la clase SynchronizationContext. Lamentablemente, muchos desarrolladores ni siquiera están al tanto de esta útil herramienta.

Independientemente de la plataforma (ya sea ASP.NET, Windows Forms, Windows Presentation Foundation (WPF), Silverlight u otras), todos los programas .NET incluyen el concepto de SynchronizationContext y todos los programadores de multiproceso se pueden beneficiar de comprenderla y aplicarla.

La necesidad de SynchronizationContext

Los programas de multiproceso existieron bien antes de la llegada de .NET Framework. Estos programas a menudo tenían la necesidad de que un subproceso pasara una unidad de trabajo a otro subproceso. Los programas de Windows se centraban en bucles de mensajes, de manera que muchos programadores usaban esta cola integrada para pasar unidades de trabajo. Cada programa de multiproceso que deseaba usar la cola de mensajes de Windows de esta manera tenía que definir su propio mensaje y convención personalizados de Windows para administrarla.

Cuando se lanzó .NET Framework por primera vez, este patrón común se estandarizó. En ese momento, el único tipo de aplicación GUI que .NET admitía era Windows Forms. Sin embargo, los diseñadores de marcos previeron otros modelos y desarrollaron una solución genérica. Nacía así ISynchronizeInvoke.

La idea detrás de ISynchronizeInvoke es que el subproceso “origen” pueda poner en cola un delegado en un subproceso “objetivo), a la espera opcional de que ese delegado se complete. ISynchronizeInvoke también proporcionó una propiedad para determinar si el código actual ya estaba en ejecución en el subproceso objetivo; en este caso, poner en cola el delegado sería necesario. Windows Forms proporcionaba la implementación única de ISynchronizeInvoke y se desarrollaba un patrón para diseñar componentes asincrónicos, de manera que todos quedaban contentos.

La versión 2.0 de .NET Framework contenía muchos cambios genéricos. Una de las mejoras más importantes fue introducir páginas asincrónicas a la arquitectura de ASP.NET. Antes de .NET Framework 2.0, cada solicitud de ASP.NET requería un subproceso hasta que la solicitud se completara. Esto era un uso ineficiente de los subprocesos, porque crear una página web solía depender de consultas a la base de daros y llamadas a servicios web, y el subproceso que administraba esa solicitud tenía que esperar hasta que cada una de esas operaciones finalizaba. Con páginas asincrónicas, el subproceso que administraba la solicitud podía iniciar cada una de las operaciones y luego volver al grupo de subprocesos de ASP.NET; cuando las operaciones finalizaban, otro subproceso del grupo de subprocesos de ASP.NET completaba la solicitud.

Sin embargo, ISynchronizeInvoke no se ajustaba bien a la arquitectura de páginas asincrónicas de ASP.NET. Los componentes asincrónicos desarrollados al usar el patrón ISynchronizeInvoke no funcionaban correctamente dentro de las páginas de ASP.NET porque estas no están asociadas con un subproceso único. En lugar de poner en cola el trabajo en el subproceso original, las páginas asincrónicas solo deben mantener un recuento de operaciones sobresalientes para determinar cuándo se puede completar la solicitud de página. Después de mucho pensar y un diseño cuidadoso, ISynchronizeInvoke se reemplazó con SynchronizationContext.

El concepto de SynchronizationContext

ISynchronizeInvoke satisfacía dos necesidades: determinar si la sincronización era necesaria y poner en cola una unidad de trabajo de un subproceso a otro. SynchronizationContext se diseñó para reemplazar a ISynchronizeInvoke, pero después del proceso de diseño, resultó no ser un reemplazo exacto.

Un aspecto de SynchronizationContext es que proporciona una manera de poner en cola una unidad de trabajo en un contexto. Observe que esta unidad de trabajo se pone en cola en un contexto y no en un subproceso específico. Esta distinción es importante, porque muchas implementaciones de SynchronizationContext no se basan en un subproceso único y específico. SynchronizationContext no incluye un mecanismo para determinar si la sincronización es necesaria, porque esto no siempre es posible.

Otro aspecto de SynchronizationContext es que cada subproceso tiene un contexto “actual”. El contexto de un subproceso no es necesariamente único; su instancia de contexto puede compartirse con otros subprocesos. Es posible que un subproceso cambie su contexto actual, pero esto es bastante raro.

Un tercer aspecto de SynchronizationContext es que mantiene un recuento de operaciones asincrónicas sobresalientes. Esto permite el uso de páginas asincrónicas de ASP.NET y de cualquier otro host que requiera este tipo de recuento. En la mayoría de los casos, el recuento se incrementa cuando se capta la clase SynchronizationContext actual y disminuye cuando la SynchronizationContext captada se usa para poner en cola una notificación de finalización en el contexto.

Existen otros aspectos de SynchronizationContext, pero son menos importantes para la mayoría de los programadores. Los aspectos más importantes se ilustran en la Figura 1.

Figura 1 Aspectos de la API de SynchronizationContext

// The important aspects of the SynchronizationContext APIclass SynchronizationContext

{

  // Dispatch work to the context.

  void Post(..); // (asynchronously)

  void Send(..); // (synchronously)

  // Keep track of the number of asynchronous operations.

  void OperationStarted();

  void OperationCompleted();

  // Each thread has a current context.

  // If "Current" is null, then the thread's current context is


  // "new SynchronizationContext()", by convention.

  static SynchronizationContext Current { get; }

  static void SetSynchronizationContext(SynchronizationContext);
}

La implementación de SynchronizationContext

El contexto “real" de SynchronizationContext no se encuentra definido claramente. Diferentes marcos y hosts son libres de definir su propio contexto. Comprender estas diferentes implementaciones y sus limitaciones aclara con exactitud lo que el concepto de SynchronizationContext garantiza y no garantiza. Analizaré brevemente algunas de estas implementaciones.

WindowsFormsSynchronizationContext (System.Windows.Forms.dll: System.Windows.Forms) Las aplicaciones de Windows Forms crearán e instalarán una clase WindowsFormsSynchronizationContext como el contexto actual para cualquier subproceso que cree controles de UI. Esta SynchronizationContext usa los métodos ISynchronizeInvoke en un control de UI, la cual pasa los delegados al bucle de mensajes subyacente Win32. El contexto para WindowsFormsSynchronizationContext es un subproceso de UI único.

Todos los delegados en cola en WindowsFormsSynchronizationContext se ejecutan uno a la vez; los ejecuta un subproceso de UI específico en el orden en que se pusieron en cola. La implementación actual crea una clase WindowsFormsSynchronizationContext para cada subproceso de UI.

DispatcherSynchronizationContext (WindowsBase.dll: System.Windows.Threading) Las aplicaciones de WPF y Silverlight usa una clase DispatcherSynchronizationContext, la cual pone en cola a los delegados en el distribuidor del subproceso de UI con prioridad “Normal”. Esta SynchronizationContext se instala como el contexto actual cuando un subproceso inicia su bucle de distribuidor al llamar a Dispatcher.Run. El contexto para DispatcherSynchronizationContext es un subproceso de UI único.

Todos los delegados en cola en DispatcherSynchronizationContext se ejecutan uno a la vez; los ejecuta un subproceso de UI específico en el orden en que se pusieron en cola. La implementación actual crea una clase DispatcherSynchronizationContext para cada ventana del nivel superior incluso si todas comparten el mismo distribuidor subyacente.

Clase predeterminada (ThreadPool) SynchronizationContext (mscorlib.dll: System.Threading) La clase predeterminada SynchronizationContext es un objeto SynchronizationContext construido de manera predeterminada. Por convención, si un SynchronizationContext actual es nulo, entonces implícitamente tiene un SynchronizationContext predeterminado.

El SynchronizationContext predeterminado pone en cola sus delegados asincrónicos en ThreadPool, pero los ejecuta directamente en el subproceso de llamado. Por lo tanto, su contexto cubre todos los subprocesos de ThreadPool así como cualquier subproceso que llame a Send. El contexto “pide prestados” los subprocesos que llaman a Send, atrayéndolos a su contexto hasta que el delegado finaliza. En este sentido, el contexto predeterminado puede incluir cualquier subproceso en el proceso.

El SynchronizationContext predeterminado se aplica a los subprocesos de ThreadPool a menos que el código lo hospede ASP.NET. El SynchronizationContext predeterminado también se aplica implícitamente a subprocesos secundarios explícitos (instancias de la clase Thread), a menos que el subproceso secundario establezca su propio SynchronizationContext. De esta forma, las aplicaciones de UI por lo general tienen dos contextos de sincronización: el SynchronizationContext de UI que cubre el subproceso de UI y el SynchronizationContext predeterminado que cubre los subprocesos de ThreadPool.

Muchos componentes asincrónicos basados en eventos no funcionan como se espera con el SynchronizationContext predeterminado. Un ejemplo tristemente famoso es una aplicación de UI en que un BackgroundWorker inicia otro BackgroundWorker. Cada BackgroundWorker capta y usa el SynchronizationContext del subproceso que llama a RunWorkerAsync y posteriormente ejecuta su evento RunWorkerCompleted en ese contexto. En el caso de un BackgroundWorker único, este suele ser un SynchronizationContext basado en UI, de manera que RunWorkerCompleted se ejecuta en el contexto de UI captado por RunWorkerAsync (ver Figura 2).

image: A Single BackgroundWorker in a UI Context

Figura 2 BackgroundWorker único en un contexto de UI

Sin embargo, si BackgroundWorker inicia otro BackgroundWorker a partir de su controlador DoWork, entonces el BackgroundWorker anidado no capta el SynchronizationContext de UI. A DoWork lo ejecuta un subproceso de ThreadPool con el SynchronizationContext predeterminado. En este caso, el RunWorkerAsync anidado captará el SynchronizationContext predeterminado, entonces ejecutará su RunWorkerCompleted en un subproceso de ThreadPool en vez de un subproceso de UI (ver Figura 3).

image: Nested BackgroundWorkers in a UI Context

Figure 3 BackgroundWorkers anidados en un contexto de UI

De manera predeterminada, todos los subprocesos de aplicaciones en consola y Servicios de Windows solo tienen el SynchronizationContext predeterminado. Esto provoca error en algunos componentes asincrónicos basados en eventos. Una solución para esto es crear un subproceso secundario explícito e instalar un SynchronizationContext en ese subproceso, el cual puede entonces proporcionar un contexto para estos componentes. Implementar un SynchronizationContext va más allá del ámbito de este artículo, pero la clase ActionThread de la biblioteca Nito.Async (nitoasync.codeplex.com) se puede usar como una implementación de SynchronizationContext de propósito general.

AspNetSynchronizationContext (System.Web.dll: System.Web [internal class]) SynchronizationContext de ASP.NET se instala en los subprocesos del grupo de subprocesos conforme ejecutan código de página. Cuando un delegado se pone cola en un AspNetSynchronizationContext captado, restaura la identidad y la cultura de la página original y luego ejecuta el delegado directamente. El delegado se invoca directamente, aun cuando se ponga en cola “de manera asincrónica” al llamar a Post.

En sentido conceptual, el contexto de AspNetSynchronizationContext es complejo. Durante la duración de una página asincrónica, el contexto se inicia con solo un subproceso a partir del grupo de subprocesos de ASP.NET. Después de que las solicitudes asincrónicas han comenzado, el contexto no incluye ningún subproceso. Cuando las solicitudes asincrónicas se completan, los subprocesos del grupo de subprocesos que ejecutan sus rutinas de finalización entran al contexto. Estos pueden ser los mismo subprocesos que iniciaron las solicitudes pero es más probable que se trate de cualquier subproceso que se encuentre libre en el momento de completarse las operaciones.

Si se completan varias operaciones a la vez para la misma aplicación, AspNetSynchronizationContext asegurará que se ejecutan una a la vez. Se pueden ejecutar en cualquier subproceso, pero es subproceso tendrá la identidad y la cultura de la página original.

Un ejemplo común es un WebClient usado desde dentro de una página web asincrónica. DownloadDataAsync captará el SynchronizationContext actual y posteriormente ejecutará su evento DownloadDataCompleted en ese contexto. Cuando la página comience a ejecutarse, ASP.NET asignará uno de sus subprocesos para que ejecute el código en esa página. La página puede invocar DownloadDataAsync y luego volver; ASP.NET mantiene un recuento de las operaciones asincrónicas sobresalientes, de manera que entienda que esa página no está completa. Cuando el objeto WebClient haya descargado los datos solicitados, recibirá notificación sobre un subproceso del grupo de subprocesos. Este subproceso elevará DownloadDataCompleted en el contexto captado. El contexto permanecerá en el mismo subproceso pero asegurará que el controlador de eventos se ejecute con la identidad y cultura correctas.

Notas sobre las implementaciones de SynchronizationContext

SynchronizationContext proporciona un medio para escribir componentes que pueden funcionar dentro de muchos marcos. BackgroundWorker y WebClient son dos ejemplos igualmente cómodos en aplicaciones de Windows Forms, WPF, Silverlight, consola y ASP.NET. Sin embargo, existen algunos puntos que deben tenerse en cuenta al diseñar tales componentes reutilizables.

En términos generales, las implementaciones de SynchronizationContext no se pueden comparar con equidad. Esto significa que no hay equivalente a ISynchronizeInvoke.InvokeRequired. No obstante, esto no es un gran retraso; el código es más claro y fácil de comprobar si siempre se ejecuta dentro de un contexto conocido en vez de intentar controlar varios contextos.

No todas las implementaciones de SynchronizationContext garantizan el orden de la ejecución de los delegados ni su sincronización. Las implementaciones de SynchronizationContext basado en UI sí cumplen con estas condiciones, pero SynchronizationContext de ASP.NET solo proporciona sincronización. El SynchronizationContext predeterminado no garantiza ni el orden de la ejecución ni la sincronización.

No existe una correspondencia 1:1 entre las instancias y los subprocesos de SynchronizationContext. Sin embargo, WindowsFormsSynchronizationContext sí tiene una asignación a un subproceso de 1:1 (siempre que no se invoque SynchronizationContext.CreateCopy), pero esto no es verdadero para ninguna de las otras implementaciones. En general, es mejor no suponer que alguna instancia contextual se ejecutará en algún subproceso específico.

Por último, el método SynchronizationContext.Post no es necesariamente asincrónico. La mayoría de las implementaciones sí lo implementan de manera asincrónica, pero AspNetSynchronizationContext es una notable excepción. Esto puede provocar problemas inesperados de entrada reiterada. En la Figura 4, puede ver un resumen de estas distintas implementaciones.

Figura 4 Resumen de implementaciones de SynchronizationContext

  Subproceso específico para ejecutar delegados Exclusivo (los delegados se ejecutan uno a la vez) Ordenado (los delegados se ejecutan en orden de cola) Send puede invocar al delegado directamente Post puede invocar al delegado directamente
Windows Forms Si se llama de un subproceso de UI Nunca
WPF/Silverlight Si se llama de un subproceso de UI Nunca
Predeterminado No No No Siempre Nunca
ASP.NET No No Siempre Siempre

AsyncOperationManager y AsyncOperation

Las clases AsyncOperationManager y AsyncOperation en .NET Framework son contenedores livianos en torno a la abstracción SynchronizationContext. AsyncOperationManager capta el SynchronizationContext actual la primera vez que crea un AsyncOperation, con lo cual sustituye al SynchronizationContext predeterminado si el actual es nulo. AsyncOperation publica delegados de manera asincrónica en el SynchronizationContext captado.

La mayoría de los componentes asincrónicos basados en eventos usan AsyncOperationManager y AsyncOperation en su implementación. Estos funcionan bien para las operaciones asincrónicas que tienen un punto definido de finalización; es decir, la operación asincrónica comienza en un punto y finaliza con un evento en otro. Es posible que otras notificaciones asincrónicas no tengan un punto definido de finalización; pueden ser un tipo de suscripción, la cual comienza en un punto y luego continúa de manera indefinida. Para estos tipos de operaciones, SynchronizationContext se puede captar y usar directamente.

Los componentes nuevos no deben usar el patrón asincrónico basado en eventos. La Community Technology Preview (CTP) asincrónica de Visual Studio incluye un documento que describe el patrón asincrónico basado en tareas, en el cual los componentes devuelven objetos Task y Task<TResult> en lugar de elevar eventos a través de SynchronizationContext. Las API basadas en tareas son el futuro de la programación asincrónica en .NET.

Ejemplos de compatibilidad de la biblioteca para SynchronizationContext

Componentes sencillos como BackgroundWorker y WebClient son implícitamente portátiles por sí mismos, con lo cual ocultan la captación y uso de SynchronizationContext. Muchas bibliotecas tienen un uso más visible de SynchronizationContext. Al exponer las API mediante SynchronizationContext, las bibliotecas no solo ganan independencia de marcos, sino que además proporcionan un punto de extensibilidad para usuarios finales avanzados.

Además de las bibliotecas que analizaré ahora, el SynchronizationContext actual se considera parte de ExecutionContext. Todo sistema que capta ExecutionContext del subproceso capta el SynchronizationContext actual. Cuando ExecutionContext se restablece, SynchronizationContext por lo general también se restablece.

Windows Communication Foundation (WCF):UseSynchronizationContext WCF tiene dos atributos que se usan para configurar el comportamiento de servidor y cliente: ServiceBehaviorAttribute y CallbackBehaviorAttribute. Ambos atributos tienen una propiedad Boolean: UseSynchronizationContext. El valor predeterminado de este atributo es verdadero, lo cual significa que el SynchronizationContext actual se capta cuando se crea el canal de comunicación y este SynchronizationContext captado se usa para poner en cola los métodos contractuales.

Normalmente, este comportamiento es exactamente lo que se necesita: Los servidores usan el ynchronizationContext predeterminado y las llamadas de cliente usan el SynchronizationContext de UI adecuado. Sin embargo, esto puede ocasionar problemas cuando se desea una entrada reiterada, como un cliente que invoca un método de servidor que invoca una llamada de cliente. En este caso y otros similares, el uso automático de WCF de SynchronizationContext se puede deshabilitar al configurar UseSynchronizationContext en falso.

Esta es solo una breve descripción de cómo WCF usa UseSynchronizationContext. Consulte el artículo “Synchronization Contexts in WCF” (Contextos de sincronización en WCF) (msdn.microsoft.com/magazine/cc163321) que aparece en el número de noviembre de 2007 de MSDN Magazine para obtener más detalles.

Windows Workflow Foundation (WF): WorkflowInstance.SynchronizationContext WF hospeda el WorkflowSchedulerService usado originalmente y los tipos derivados para controlar cómo se programaron las actividades de flujo de trabajo en los subprocesos. Parte de la actualización de .NET Framework 4 incluyó la propiedad SynchronizationContext en la clase WorkflowInstance y su clase derivada WorkflowApplication.

SynchronizationContext se puede configurar directamente si el proceso de hospedaje crea su propia WorkflowInstance. SynchronizationContext también lo usa WorkflowInvoker.InvokeAsync, la cual capta el SynchronizationContext actual y lo pasa a su WorkflowApplication interna. Este SynchronizationContext se usa entonces para publicar el evento de finalización de flujo de trabajo así como las actividades de flujo de trabajo.

Biblioteca TPL: TaskScheduler.FromCurrentSynchronizationContext y CancellationToken.Register TPL usa los objetos de tarea como sus unidades de trabajo y los ejecuta a través de TaskScheduler. El TaskScheduler predeterminado actúa como el SynchronizationContext predeterminado, al poner en cola las tareas en ThreadPool. Existe otro TaskScheduler proporcionado por la cola de TPL que pone en cola tareas en un SynchronizationContext. La generación de informes de progreso con actualizaciones de UI se pueden hacer con una tarea anidada, como se muestra en la Figura 5.

Figura 5 Generación de informes de progreso con actualizaciones de UI

private void button1_Click(object sender, EventArgs e)
{
  // This TaskScheduler captures SynchronizationContext.Current.
  TaskScheduler taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
  // Start a new task (this uses the default TaskScheduler, 
  // so it will run on a ThreadPool thread).
  Task.Factory.StartNew(() =>
  {
    // We are running on a ThreadPool thread here.

  
    ; // Do some work.


  // Report progress to the UI.
    Task reportProgressTask = Task.Factory.StartNew(() =>
      {
        // We are running on the UI thread here.

        ; // Update the UI with our progress.
      },
      CancellationToken.None,
      TaskCreationOptions.None,
      taskScheduler);
    reportProgressTask.Wait();
  
    ; // Do more work.
  });
}

La clase CancellationToken se usa para cualquier tipo de cancelación en .NET Framework 4. Para integrarse con formularios de cancelación existentes, esta clase permite registrar un delegado que invocar cuando se solicita la cancelación. Cuando el delegado se registra, se puede pasar un SynchronizationContext. Cuando se solicita la cancelación, CancellationToken pone el delegado en cola en SynchronizationContext en lugar de ejecutarlo directamente.

Extensiones reactivas de Microsoft (Rx): ObserveOn, SubscribeOn y SynchronizationContextScheduler Rx es una biblioteca que trata los eventos como secuencias de datos. El operador ObserveOn pone eventos en cola a través de un SynchronizationContext y el operador SubscribeOn pone en cola las suscripciones a esos eventos a través de un SynchronizationContext. ObserveOn se usa comúnmente para actualizar la UI con eventos entrantes y SubscribeOn se usa para consumir eventos a partir de objetos de UI.

Rx también tiene su propia manera de poner en cola unidades de trabajo: la interfaz IScheduler. Rx incluye SynchronizationContextScheduler, una implementación de IScheduler que se pone en cola en un SynchronizationContext.

CTP asincrónico de Visual Studio: espera, ConfigureAwait, SwitchTo y EventProgress<T> La compatibilidad de Visual Studio en relación con transformaciones de código asincrónicas se anunció en el Congreso para desarrolladores profesionales de Microsoft 2010. De manera predeterminada, el SynchronizationContext actual se capta en un punto de espera y se usa para la reanudación después de la espera (de manera más precisa, capta el SynchronizationContext actual a menos que sea nulo, en cuyo caso capta el TaskScheduler actual):

private async void button1_Click(object sender, EventArgs e)
{
  // SynchronizationContext.Current is implicitly captured by await.
  var data = await webClient.DownloadStringTaskAsync(uri);

  // At this point, the captured SynchronizationContext was used to resume
  // execution, so we can freely update UI objects.
}

ConfigureAwait proporciona un medio para evitar el comportamiento de captación del SynchronizationContext actual; el paso falso del parámetro flowContext evita que SynchronizationContext se use para reanudar la ejecución después de la espera. También existe un método de extensión en instancias de SynchronizationContext llamado SwitchTo; esto permite que cualquier método de sincronización cambie a un SynchronizationContext al invocar SwitchTo y esperar el resultado.

El CTP asincrónico introduce un patrón común para informar el progreso de las operaciones asincrónicas: la interfaz IProgress<T> y su implementación EventProgress<T>. Esta clase capta el SynchronizationContext actual cuando se construye y eleva su evento ProgressChanged en ese contexto.

Además de esta compatibilidad, los métodos asincrónicos de devolución nula incrementarán el recuento de operaciones asincrónicas al inicio y lo disminuirán al final. Este comportamiento hace que los métodos asincrónicos de devolución nula actúen como operaciones asincrónicas de primer nivel.

Limitaciones y garantías

Comprender SynchronizationContext es útil para cualquier programador. Los componentes existentes entre marcos lo usan para sincronizar sus eventos. Es posible que las bibliotecas lo expongan para permitir una flexibilidad avanzada. Aquel codificador entendido en la materia que comprende las limitaciones y las garantías de SynchronizationContext puede escribir y consumir mejor esas clases.

Stephen Cleary se ha interesado en el multiproceso desde que oyó hablar del concepto por primera vez. Ha completado muchos sistemas de multitarea fundamentales para importantes clientes, entre los que se incluyen Syracuse News, R. R. Donnelley y BlueScope Steel. Expone regularmente en eventos de grupos de usuarios de .NET, BarCamps y Día de .NET cerca de su hogar en Northern Michigan, por lo general sobre un tema de multiprocesos. Mantiene un blog de programación en nitoprograms.com.

Gracias al siguiente experto técnico por su ayuda en la revisión de este artículo: Eric Eilebrecht