Computação paralela

É tudo uma questão de SynchronizationContext

Stephen Cleary

A programação multi-threaded pode ser muito difícil, e há uma grande quantidade de conceitos e ferramentas a serem aprendidos ao embarcar nessa tarefa. Para ajudá-lo, o Microsoft .NET Framework fornece a classe SynchronizationContext. Infelizmente, muitos desenvolvedores nem sabem da existência dessa útil ferramenta.

Independentemente da plataforma – seja ASP.NET, Windows Forms, Windows Presentation Foundation (WPF), Silverlight ou outras - todos os programas .NET incluem o conceito de SynchronizationContext, e todos os programadores de multithreading podem se beneficiar de seu entendimento e aplicação.

Necessidade do SynchronizationContext

Programas multi-threaded existiam muito antes do advento do .NET Framework. Esses programas sempre tiveram a necessidade de um thread para passar uma unidade de trabalho para outro thread. Os programas do Windows eram centralizados em loops de mensagens, portanto, muitos programadores usavam essa fila interna para passar unidades de trabalho em todas as direções. Cada programa multi-threaded que quisesse usar a fila de mensagens do Windows dessa maneira precisava definir sua própria mensagem e convenção do Windows para manipulá-la

Quando o .NET Framework foi lançado pela primeira vez, esse padrão comum foi estabelecido. Naquela época, o único tipo de aplicativo GUI com suporte do .NET era o Windows Forms. No entanto, os designers de estrutura anteciparam outros modelos e desenvolveram uma solução genérica. Nasceu o ISynchronizeInvoke.

A ideia por trás do ISynchronizeInvoke é que um thread de “origem” pode colocar um delegado na fila para um thread de “destino” e, opcionalmente, aguardar a conclusão do delegado. O ISynchronizeInvoke também forneceu uma propriedade para determinar se o código atual já estava executando o thread de destino. Nesse caso, não seria necessário colocar o delegado na fila. O Windows Forms forneceu a única implementação do ISynchronizeInvoke, e um padrão foi desenvolvido para criar componentes assíncronos, para a felicidade de todos.

A versão 2.0 do .NET Framework continha muitas alterações abrangentes. Uma das principais melhorias foi a introdução de páginas assíncronas na arquitetura do ASP.NET. Antes do .NET Framework 2.0, cada solicitação do ASP.NET precisava de um thread até que a solicitação fosse concluída. Esse uso de threads era ineficiente, porque a criação de uma página da Web sempre depende de consultas no banco de dados e de chamadas a serviços Web, e o manuseio de threads precisava esperar a conclusão de cada uma dessas operações. Com páginas assíncronas, o thread que estava tratando a solicitação podia começar cada uma das operações e, em seguida, retornar para o pool de threads do ASP.NET. Quando as operações eram concluídas, outro thread do pool de threads do ASP.NET concluía a solicitação.

No entanto, o ISynchronizeInvoke não era um bom ajuste para a arquitetura de páginas assíncronas do ASP.NET. Os componentes assíncronos desenvolvidos com o uso do padrão ISynchronizeInvoke não funcionavam corretamente em páginas do ASP.NET porque as páginas assíncronas do ASP.NET não estão associadas a um único thread. Em vez de colocar trabalho na fila para o thread original, as páginas assíncronas precisam apenas manter uma contagem das operações pendentes para determinar quando a solicitação de página poderá ser concluída. Depois de muito raciocínio e design cuidadoso, o ISynchronizeInvoke foi substituído pelo SynchronizationContext.

O conceito do SynchronizationContext

O ISynchronizeInvoke atendia a duas necessidades: determinar se a sincronização era necessária e colocar na fila uma unidade de trabalho de um thread para outro. O SynchronizationContext foi criado para substituir o ISynchronizeInvoke, mas depois do processo de design descobriu-se que essa não era uma substituição exata.

Um aspecto do SynchronizationContext é que ele fornece uma maneira de colocar uma unidade de trabalho na fila para um contexto. Observe que essa unidade de trabalho é colocada na fila para um contexto e não para um thread específico. Essa distinção é importante, porque muitas implementações do SynchronizationContext não são baseadas em um único thread específico. O SynchronizationContext não inclui um mecanismo para determinar se a sincronização é necessária, porque isso nem sempre é possível.

Outro aspecto do SynchronizationContext é que cada thread tem um contexto “atual”. O contexto de um thread não é necessariamente exclusivo, a instância de seu contexto pode ser compartilhada com outros threads. É possível que um thread altere seu contexto atual, mas isso é muito raro.

Um terceiro aspecto do SynchronizationContext é que ele mantém uma contagem das operações assíncronas pendentes. Isso permite o uso de páginas assíncronas do ASP.NET e qualquer outro host que precise desse tipo de contagem. Na maioria dos casos, a contagem é incrementada quando o SynchronizationContext é capturado, e a contagem é diminuída quando o SynchronizationContext capturado é usado para colocar uma notificação de conclusão na fila para o contexto.

Há outros aspectos do SynchronizationContext, mas eles são menos importantes para a maioria dos programadores. Os aspectos mais importantes são ilustrados na Figura 1.

Figura 1 Aspectos da API do 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);
}

As implementações do SynchronizationContext

O “contexto” real do SynchronizationContext não é claramente definido. Estruturas e hosts diferentes são livres para definir seu próprio contexto. Compreender essas diferentes implementações e suas limitações clarifica exatamente o que o conceito do SynchronizationContext garante ou não. Discutirei brevemente algumas dessas implementações.

WindowsFormsSynchronizationContext (System.Windows.Forms.dll: System.Windows.Forms) Os aplicativos do Windows Forms criarão e instalarão um WindowsFormsSynchronizationContext como o contexto atual de qualquer thread que crie controles da interface do usuário. Esse SynchronizationContext usa os métodos ISynchronizeInvoke em um controle da interface do usuário, que passa os delegados para o loop de mensagens subjacente do Win32. O contexto do WindowsFormsSynchronizationContext é um único thread da interface do usuário.

Todos os delegados na fila para o WindowsFormsSynchronizationContext são executados um de cada vez por um thread específico da interface do usuário na ordem em que são colocados na fila. A implementação atual cria um WindowsFormsSynchronizationContext para cada thread da interface do usuário.

DispatcherSynchronizationContext (WindowsBase.dll: System.Windows.Threading) Os aplicativos WPF e Silverlight usam um DispatcherSynchronizationContext, que coloca os delegados na fila para o dispatcher do thread da interface do usuário com prioridade “Normal”. Esse SynchronizationContext é instalado como o contexto atual quando um thread começa seu loop de Dispatcher chamando Dispatcher.Run. O contexto de DispatcherSynchronizationContext é um único thread da interface do usuário.

Todos os delegados na fila para o DispatcherSynchronizationContext são executados um de cada vez por um thread específico da interface do usuário na ordem em que são colocados na fila. A implementação atual cria um DispatcherSynchronizationContext para cada janela de nível superior, mesmo que todas compartilhem o mesmo Dispatcher subjacente.

(ThreadPool) SynchronizationContext padrão (mscorlib.dll: System.Threading) O SynchronizationContext padrão é um objeto SynchronizationContext construído por padrão. Por convenção, se um SynchronizationContext atual for nulo, ele terá um SynchronizationContext padrão implícito.

O SynchronizationContext padrão coloca na fila seus delegados assíncronos para o ThreadPool, mas executa seus delegados síncronos diretamente no thread de chamada. Portanto, seu contexto abrange todos os threads do ThreadPool, bem como qualquer thread que chame Send. O contexto “empresta” threads que chamam Send, trazendo-os para seu contexto até que o delegado seja concluído. Nesse sentido, o contexto padrão pode incluir qualquer thread no processo.

O SynchronizationContext padrão é aplicado aos threads do ThreadPool a menos que o código seja hospedado pelo ASP.NET. O SynchronizationContext padrão também é aplicado implicitamente aos threads filho (instâncias da classe Thread) a menos que o thread filho defina seu próprio SynchronizationContext. Assim, os aplicativos da interface do usuário têm dois contextos de sincronização: o SynchronizationContext da interface do usuário que cobre o thread da interface do usuário e o SynchronizationContext que cobre os threads do ThreadPool.

Muitos componentes assíncronos baseados em eventos não funcionam conforme esperado com o SynchronizationContext padrão. Um exemplo infame é um aplicativo da interface do usuário onde um BackgroundWorker começa outro BackgroundWorker. Cada BackgroundWorker captura e usa o SynchronizationContext do thread que chama RunWorkerAsync e, posteriormente, executa seu evento RunWorkerCompleted nesse contexto. No caso de um único BackgroundWorker, esse normalmente é um SynchronizationContext baseado na interface do usuário, portanto, o RunWorkerCompleted é executado no contexto da interface do usuário capturado pelo RunWorkerAsync (consulte a Figura 2).

Figura 2 Um único BackgroundWorker em um contexto da interface do usuário

No entanto, se o BackgroundWorker começar outro BackgroundWorker a partir de seu identificador DoWork, o BackgroundWorker aninhado não capturará o SynchronizationContext da interface do usuário. O DoWork é executado por um thread do ThreadPool com o SynchronizationContext padrão. Nesse caso, o RunWorkerAsync aninhado capturará o SynchronizationContext padrão e, portanto, executará seu RunWorkerCompleted em um thread do ThreadPool em vez de em um thread da interface do usuário (consulte a Figura 3).

Figura 3 BackgroundWorkers aninhados em um contexto da interface do usuário

Por padrão, todos os threads em aplicativos de console e dos Serviços do Windows precisam ter apenas o SynchronizationContext padrão. Isso provoca falha em alguns componentes assíncronos baseados em eventos. Uma solução para isso é criar um thread filho explícito e instalar um SynchronizationContext nesse thread, o que pode fornecer um contexto para esses componentes. A implementação de SynchronizationContext está além do escopo deste artigo, mas a classe ActionThread da biblioteca do Nito.Async (nitoasync.codeplex.com) pode ser usada como uma implementação do SynchronizationContext para finalidade geral.

AspNetSynchronizationContext (System.Web.dll: System.Web [classe interna]) O SynchronizationContext do ASP.NET é instalado em threads do pool de threads conforme executam código de página. Quando um delegado é colocado na fila para um AspNetSynchronizationContext capturado, ele restaura a identidade e a cultura da página original e, em seguida, executa o delegado diretamente. O delegado é invocado diretamente, mesmo que seja colocado na fila “assincronamente” por meio de chamada de Post.

Conceitualmente, o contexto de AspNetSynchronizationContext é complexo. Durante o tempo de vida de uma página assíncrona, o contexto começa com apenas um thread do pool de threads do ASP.NET. Depois do início das solicitações assíncronas, o contexto não inclui nenhum thread. Quando as solicitações assíncronas são concluídas, os threads do pool de threads que executam suas rotinas de conclusão entram no contexto. Esses podem ser os mesmos threads que iniciaram as solicitações, mas mais provavelmente serão quaisquer threads que estejam livres na hora da conclusão das operações.

Se várias operações forem concluídas de uma vez para o mesmo aplicativo, o AspNetSynchronizationContext garantirá que elas sejam executadas uma de cada vez. Elas podem executar em qualquer thread, mas esse thread terá a identidade e a cultura da página original.

Um exemplo comum é um WebClient usado a partir de uma página da Web assíncrona. O DownloadDataAsync capturará o SynchronizationContext atual e, posteriormente, executará seu evento DownloadDataCompleted nesse contexto. Quando a execução da página for iniciada, o ASP.NET alocará um de seus threads para executar o código daquela página. A página pode chamar DownloadDataAsync e retornar. O ASP.NET mantém uma contagem das operações assíncronas pendentes e, portanto, entende que a página não está concluída. Quando tiver baixado os dados solicitados, o objeto WebClient receberá uma notificação em um thread do pool de threads. Esse thread gerará DownloadDataCompleted no contexto capturado. O contexto permanecerá no mesmo thread, mas garantirá que o manipulador de eventos seja executado com a identidade e a cultura corretas.

Observações sobre implementações de SynchronizationContext

O SynchronizationContext fornece um meio de criar componentes que podem funcionar dentro de muitas estruturas diferentes. O BackgroundWorker e o WebClient são dois exemplos que funcionam muito bem no Windows Forms, no WPF, no Silverlight, no console e em aplicativos ASP.NET. No entanto, há alguns pontos que precisam ser lembrados ao criar esses componentes reutilizáveis.

Em termos gerais, as implementações de SynchronizationContext não são igualmente comparáveis. Isso significa que não existe um equivalente a ISynchronizeInvoke.InvokeRequired. No entanto, essa não é uma tremenda desvantagem. O código é mais limpo e mais fácil de verificar se for executado sempre dentro de um contexto conhecido em vez de tentar manipular vários contextos.

Nem todas as implementações do SynchronizationContext garantem a ordem de execução de delegados ou a sincronização de delegados. As implementações do SynchronizationContext baseadas na interface do usuário atendem a essas condições, mas o SynchronizationContext do ASP.NET fornece apenas sincronização. O SynchronizationContext padrão não garante a ordem da execução ou a sincronização.

Não existe uma correspondência 1:1 entre as instâncias e os threads do SynchronizationContext. O WindowsFormsSynchronizationContext tem um mapeamento 1:1 para um thread (desde que o SynchronizationContext.CreateCopy não seja invocado), mas isso não é verdadeiro para nenhuma das outras implementações. Em geral, é melhor não assumir que qualquer instância do contexto será executada em qualquer thread específico.

Finalmente, o método SynchronizationContext.Post não é necessariamente assíncrono. A maioria das implementações o implementam assincronamente, mas o AspNetSynchronizationContext é uma exceção notável. Isso pode provocar problemas de reentrada inesperados. Um resumo dessas diferentes implementações pode ser visto na Figura 4.

Figura 4 Resumo das implementações do SynchronizationContext

  Thread específico usado para executar delegados Exclusivo (os delegados são executados um de cada vez) Ordenado (os delegados são executados na ordem da fila) Send pode invocar delegado diretamente Post pode invocar delegado diretamente
Windows Forms Sim Sim Sim Se for chamado do thread da interface do usuário Nunca
WPF/Silverlight Sim Sim Sim Se for chamado do thread da interface do usuário Nunca
Padrão Não Não Não Sempre Nunca
ASP.NET Não Sim Não Sempre Sempre

AsyncOperationManager e AsyncOperation

As classes AsyncOperationManager e AsyncOperation no .NET Framework são wrappers leves em torno da abstração do SynchronizationContext. O AsyncOperationManager captura o SynchronizationContext atual na primeira vez que cria uma AsyncOperation, substituindo um SynchronizationContext padrão se o atual for nulo. A AsyncOperation posta delegados assincronamente para o SynchronizationContext capturado.

A maioria dos componentes assíncronos baseados em eventos usam o AsyncOperationManager e o AsyncOperation em sua implementação. Esses funcionam bem para operações assíncronas que têm um ponto de conclusão definido, isto é, a operação assíncrona começa em um ponto e termina com um evento em outro ponto. Outras notificações assíncronas talvez não tenham um ponto de conclusão definido. Podem ser um tipo de inscrição, que começa em um ponto e continua indefinidamente. Para esses tipos de operações, o SynchronizationContext pode ser capturado e usado diretamente.

Novos componentes não devem usar o padrão assíncrono baseado em eventos. O CTP (Community Technology Preview) assíncrono do Visual Studio inclui um documento que descreve o padrão assíncrono baseado em tarefas, no qual os componentes retornam objetos Task e Task<TResult> em vez de gerar eventos por meio do SynchronizationContext. As APIs baseadas em tarefas são o futuro da programação assíncrona no .NET.

Exemplos de suporte à biblioteca do SynchronizationContext

Componentes simples, como o BackgroundWorker e o WebClient são implicitamente portáveis por si próprios, ocultando a captura e o uso do SynchronizationContext. Muitas bibliotecas têm um uso mais visível do SynchronizationContext. Por meio da exposição de APIs que usam o SynchronizationContext, as bibliotecas não apenas obtêm independência de estrutura, como também fornecem um ponto de extensibilidade para usuários finais avançados.

Além das bibliotecas que discutirei agora, o SynchronizationContext atual é considerado como parte do ExecutionContext. Qualquer sistema que capture o ExecutionContext de um thread captura o SynchronizationContext atual. Normalmente, quando o ExecutionContext é restaurado, o SynchronizationContext também é restaurado.

WCF (Windows Communication Foundation):UseSynchronizationContext O WCF tem dois atributos que são usados para configurar o comportamento do servidor e do cliente: ServiceBehaviorAttribute e CallbackBehaviorAttribute. Esses dois atributos têm uma propriedade booliana: UseSynchronizationContext. O valor padrão desse atributo é true, o que significa que o SynchronizationContext atual é capturado quando o canal de comunicação é criado, e esse SynchronizationContext capturado é usado para colocar os métodos de contrato na fila.

Normalmente, esse comportamento é exatamente o que é necessário: Os servidores usam o SynchronizationContext padrão, e os retornos de chamada de clientes usam o SynchronizationContext da interface do usuário adequada. No entanto, isso pode provocar problemas quando a reentrada é desejada, como um cliente que invoca um método de servidor que invoca um retorno de chamada do cliente. Nesse e em casos semelhantes, o uso automático do WCF do SynchronizationContext pode ser desabilitado com a configuração de UseSynchronizationContext como false.

Esta é apenas uma descrição breve de como o WCF usa o SynchronizationContext. Consulte o artigo “Contextos de sincronização no WCF” (msdn.microsoft.com/magazine/cc163321) na edição de novembro de 2007 da MSDN Magazine para obter mais detalhes.

WCF (Windows Workflow Foundation): WorkflowInstance.SynchronizationContext Originalmente, os hosts do WF usavam o WorkflowSchedulerService e tipos derivados para controlar como as atividades do fluxo de trabalho eram agendadas nos threads. Parte do upgrade do .NET Framework 4 incluía a propriedade SynchronizationContext na classe WorkflowInstance e sua classe derivada WorkflowApplication.

O SynchronizationContext poderá ser definido diretamente se o processo de hospedagem criar sua própria WorkflowInstance. O SynchronizationContext também é usado pelo WorkflowInvoker.InvokeAsync, que captura o SynchronizationContext atual e passa-o para seu WorkflowApplication interno. Em seguida, esse SynchronizationContext é usado para postar o evento de conclusão do fluxo de trabalho bem como atividades do fluxo de trabalho.

TPL (Task Parallel Library, Biblioteca paralela de tarefas) TaskScheduler.FromCurrentSynchronizationContext e CancellationToken.Register A TPL usa objetos de tarefas como suas unidades de trabalho e os executa por meio de um TaskScheduler. O TaskScheduler padrão funciona como o SynchronizationContext padrão, colocando as tarefas na fila para o ThreadPool. Há outro TaskScheduler fornecido pela fila da TPL que coloca tarefas na fila para um SynchronizationContext. Um relatório de andamento com atualizações da interface do usuário pode ser criado com uma tarefa aninhada, conforme mostrado na Figura 5.

Figura 5 Relatório de andamento com atualizações da interface do usuário

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

A classe CancellationToken é usada para qualquer tipo de cancelamento no .NET Framework 4. Para integração com formulários de cancelamento existentes, essa classe permite registrar um delegado a ser invocado quando o cancelamento for solicitado. Quando o delegado é registrado, um SynchronizationContext pode ser passado. Quando o cancelamento é solicitado, o CancellationToken coloca o delegado na fila para o SynchronizationContext em vez de executá-lo diretamente.

Rx (Reactive Extensions, Extensões reativas) da Microsoft: ObserveOn,SubscribeOn e SynchronizationContextScheduler A Rx é uma biblioteca que trata eventos como fluxos de dados. O operador ObserveOn coloca eventos na fila por meio de um SynchronizationContext, e o operador SubscribeOn coloca as assinaturas na fila para esses eventos por meio de um SynchronizationContext. Normalmente, o ObserveOn é usado para atualizar a interface do usuário com eventos recebidos, e o SubscribeOn é usado para consumir eventos de objetos da interface do usuário.

A Rx também tem sua própria maneira de colocar unidades de trabalho na fila: a interface IScheduler. A Rx inclui o SynchronizationContextScheduler, uma implementação do IScheduler que coloca na fila para um SynchronizationContext.

CTP assíncrona do Visual Studio: await, ConfigureAwait,SwitchTo e EventProgress<T> O suporte do Visual Studio para transformações de código assíncronas foi anunciado na Microsoft Professional Developers Conference 2010. Por padrão, o SynchronizationContext atual é capturado em um ponto de espera, e esse SynchronizationContext é usado para continuar depois da espera (mais precisamente, ele captura o SynchronizationContext atual a menos que ele seja nulo. Nesse caso, ele capturará o TaskScheduler atual):

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

O ConfigureAwait fornece uma maneira de evitar o comportamento de captura do SynchronizationContext padrão. Passar false para o parâmetro flowContext impede que o SynchronizationContext seja usado para continuar a execução após a espera. Também existe um método de extensão nas instâncias do SynchronizationContext chamado de SwitchTo. Isso permite que qualquer método assíncrono seja alterado para um SynchronizationContext diferente por meio da invocação de SwitchTo e a espera do resultado.

A CTP assíncrona introduz um padrão comum para relatórios de andamento de operações assíncronas: a interface IProgress<T> e sua implementação EventProgress<T>. Essa classe captura o SynchronizationContext quando ele é construído e gera seu evento ProgressChanged naquele contexto.

Além desse suporte, os métodos assíncronos de retorno nulo incrementarão a contagem de operações assíncronas em seu início e a diminuirão em seu final. Esse comportamento faz os métodos assíncronos de retorno nulo funcionarem como operações assíncronas de nível superior.

Limitações e garantias

A compreensão do SynchronizationContext é útil para qualquer programador. Componentes entre estruturas existentes o usam para sincronizar seus eventos. Bibliotecas podem expô-lo para permitir flexibilidade avançada. Codificadores experientes que compreendem as limitações e garantias do SynchronizationContext podem criar e consumir melhor essas classes.

Stephen Cleary se interessa por multithreading desde a primeira vez que ouviu sobre o conceito. Concluiu muitos sistemas multitarefa críticos para os negócios para clientes importantes, como a Syracuse News, a R. R. Donnelley e a BlueScope Steel. Faz palestras regularmente em eventos de grupos de usuários do .NET, BarCamps e Day of .NET perto de sua casa no norte de Michigan, normalmente sobre um tópico de multithreading. Mantém um blog de programação no nitoprograms.com.

Agradecemos ao seguinte especialista técnico pela revisão deste artigo: Eric Eilebrecht