Anti-padrão E/S síncrona

Bloquear o thread que realiza a chamada enquanto a E/S é concluída pode reduzir o desempenho e afetar a escalabilidade vertical.

Descrição do problema

Uma operação de E/S síncrona bloqueia o thread de chamada enquanto a E/S é concluída. O thread de chamada entra num estado de espera e não pode realizar trabalho útil durante este intervalo, perdendo recursos de processamento.

Exemplos comuns de E/S são:

  • Obter ou manter dados numa base de dados ou qualquer tipo de armazenamento persistente.
  • Enviar um pedido para um serviço Web.
  • Publicar uma mensagem ou obter uma mensagem de uma fila.
  • Escrever ou ler a partir de um ficheiro local.

Este anti-padrão ocorre normalmente porque:

  • Parece ser a forma mais intuitiva para realizar uma operação.
  • A aplicação precisa de uma resposta de um pedido.
  • A aplicação utiliza uma biblioteca que oferece apenas métodos síncronos para E/S.
  • Uma biblioteca externa realiza operações de E/S síncronas internamente. Uma única chamada de E/S síncrona pode bloquear uma cadeia de chamadas completa.

O seguinte código carrega um ficheiro para o armazenamento de blobs do Azure. Existem dois locais onde o código bloqueia enquanto aguarda pela E/S síncrona, o método CreateIfNotExists e o método UploadFromStream.

var blobClient = storageAccount.CreateCloudBlobClient();
var container = blobClient.GetContainerReference("uploadedfiles");

container.CreateIfNotExists();
var blockBlob = container.GetBlockBlobReference("myblob");

// Create or overwrite the "myblob" blob with contents from a local file.
using (var fileStream = File.OpenRead(HostingEnvironment.MapPath("~/FileToUpload.txt")))
{
    blockBlob.UploadFromStream(fileStream);
}

Eis um exemplo de espera de uma resposta de um serviço externo. O método GetUserProfile chama um serviço remoto que devolve um UserProfile.

public interface IUserProfileService
{
    UserProfile GetUserProfile();
}

public class SyncController : ApiController
{
    private readonly IUserProfileService _userProfileService;

    public SyncController()
    {
        _userProfileService = new FakeUserProfileService();
    }

    // This is a synchronous method that calls the synchronous GetUserProfile method.
    public UserProfile GetUserProfile()
    {
        return _userProfileService.GetUserProfile();
    }
}

Pode encontrar o código completo para ambos estes exemplos aqui.

Como resolver o problema

Substitua operações de E/S síncronas por operações assíncronas. Esta ação liberta o thread atual para continuar a realizar trabalho significativo, em vez de bloquear, e ajuda a melhorar a utilização de recursos de computação. Executar a E/S de modo assíncrono é particularmente eficaz para processar um aumento inesperado de pedidos de aplicações de cliente.

Muitas bibliotecas oferecem versões de operações síncronas e assíncronas dos métodos. Sempre que puder, utilize as versões assíncronas. Segue a versão assíncrona do exemplo anterior que carrega um ficheiro para o armazenamento de blobs do Azure.

var blobClient = storageAccount.CreateCloudBlobClient();
var container = blobClient.GetContainerReference("uploadedfiles");

await container.CreateIfNotExistsAsync();

var blockBlob = container.GetBlockBlobReference("myblob");

// Create or overwrite the "myblob" blob with contents from a local file.
using (var fileStream = File.OpenRead(HostingEnvironment.MapPath("~/FileToUpload.txt")))
{
    await blockBlob.UploadFromStreamAsync(fileStream);
}

O operador await devolve controlo ao ambiente da chamada enquanto a operação assíncrona é realizada. Depois desta instrução, o código atua como uma continuação que é executada quando a operação assíncrona é concluída.

Um serviço bem estruturado também deve disponibilizar operações assíncronas. Eis uma versão assíncrona do serviço Web que devolve perfis de utilizador. O método GetUserProfileAsync depende de ter uma versão assíncrona do serviço de Perfil de Utilizador.

public interface IUserProfileService
{
    Task<UserProfile> GetUserProfileAsync();
}

public class AsyncController : ApiController
{
    private readonly IUserProfileService _userProfileService;

    public AsyncController()
    {
        _userProfileService = new FakeUserProfileService();
    }

    // This is a synchronous method that calls the Task based GetUserProfileAsync method.
    public Task<UserProfile> GetUserProfileAsync()
    {
        return _userProfileService.GetUserProfileAsync();
    }
}

Para bibliotecas que não disponibilizam versões assíncronas de operações, poderá criar wrappers assíncronos em torno de métodos síncronos selecionados. Siga esta abordagem com cuidado. Apesar de poder melhorar a capacidade de resposta no thread que invoca o wrapper assíncrono, na realidade consome mais recursos. Pode ser criado um thread adicional e existe overhead associado ao sincronizar o trabalho feito por este thread. São apresentadas algumas desvantagens nesta mensagem de blogue: Devo expor wrappers assíncronos para métodos síncronos?

Eis um exemplo de um wrapper assíncrono em torno de um método síncrono.

// Asynchronous wrapper around synchronous library method
private async Task<int> LibraryIOOperationAsync()
{
    return await Task.Run(() => LibraryIOOperation());
}

Agora, o código de chamada pode aguardar no wrapper:

// Invoke the asynchronous wrapper using a task
await LibraryIOOperationAsync();

Considerações

  • As operações de E/S em que se espera que tenham uma vida curta e dificilmente causarão disputa, poderão ter um melhor desempenho enquanto operações síncronas. Um exemplo poderá ser a leitura de pequenos ficheiros numa unidade SSD. O overhead da emissão de uma tarefa para outro thread e a sincronização com esse thread quando a tarefa está concluída, pode superar os benefícios de E/S assíncronas. No entanto, estes casos são relativamente raros e a maioria das operações de E/S devem ser feitas de forma assíncrona.

  • Melhorar o desempenho da E/S pode fazer com que outras partes do sistema fiquem estranguladas. Por exemplo, desbloquear threads poderá resultar num volume maior de pedidos simultâneos para recursos partilhados, levando, por sua vez, à carência ou limitação de recursos. Se isso se tornar um problema, poderá ter de aumentar horizontalmente o número de servidores Web ou dividir arquivos de dados para reduzir a contenção.

Como detetar o problema

Periodicamente, para os utilizadores, a aplicação pode parecer que não responde. A aplicação poderá falhar com exceções de tempo limite. Estas falhas também podem devolver erros de HTTP 500 (Servidor Interno). No servidor, os pedidos de cliente recebidos poderão ser bloqueados até que um thread se torne disponível, resultando em comprimentos de fila de pedido excessivos, apresentados como erros de HTTP 503 (Serviço Indisponível).

Pode realizar os passos seguintes para ajudar a identificar o problema:

  1. Monitorize o sistema de produção e determine se os threads de trabalho bloqueados estão a estrangular o débito.

  2. Se os pedidos estão a ser bloqueados devido à falta de threads, aceda à aplicação para determinar as operações que poderão estar executar a E/S de forma síncrona.

  3. Realize o teste de carga controlada de cada operação que está a realizar a E/S síncrona, para saber se essas operações estão a afetar o desempenho do sistema.

Diagnóstico de exemplo

As secções seguintes aplicam estes passos para o exemplo de aplicação descrito anteriormente.

Monitorizar o desempenho do servidor Web

Para aplicações Web do Azure e funções da Web, é importante monitorizar o desempenho do servidor Web do IIS. Em particular, preste atenção ao comprimento da fila de pedido para estabelecer se os pedidos estão a ser bloqueados enquanto aguardam pelos threads disponíveis durante períodos de grande atividade. Pode recolher estas informações ao ativar o diagnóstico do Azure. Para obter mais informações, consulte:

Instrumente a aplicação para ver de que forma os pedidos são processados assim que são aceites. Rastrear o fluxo de um pedido pode ajudar a identificar se está a realizar chamadas de execução lenta e a bloquear o thread atual. A criação de perfis de threads também pode destacar pedidos que estão a ser bloqueados.

Testar a carga da aplicação

O gráfico seguinte mostra o desempenho do método GetUserProfile síncrono apresentado anteriormente, em diferentes cargas de até 4000 utilizadores em simultâneo. A aplicação é uma aplicação ASP.NET em execução numa função da Web do Serviço Cloud do Azure.

Performance chart for the sample application performing synchronous I/O operations

A operação síncrona é hard-coded para suspender durante dois segundos, para simular a E/S síncrona, pelo que o tempo de resposta mínimo é ligeiramente superior a dois segundos. Quando a carga atinge aproximadamente 2500 utilizadores em simultâneo, o tempo médio de resposta atinge um patamar, embora o volume de pedidos por segundo continue a aumentar. Tenha em atenção que o dimensionamento para estas duas medidas é logarítmico. O número de pedidos por segundo duplica entre este ponto e o fim do teste.

No isolamento, não está bem explícito a partir deste teste se a E/S síncrona é um problema. Numa carga mais pesada, a aplicação pode alcançar um ponto limite onde o servidor Web já não pode processar pedidos atempadamente, fazendo com que aplicações de cliente recebam exceções de tempo limite excedido.

Os pedidos recebidos são colocados em fila pelo servidor Web de IIS e entregues a um thread em execução no conjunto de threads ASP.NET. Uma vez que cada operação executa a E/S de forma síncrona, o thread está bloqueado até que a operação seja concluída. À medida que a carga de trabalho aumenta, eventualmente todos os threads ASP.NET no conjunto de threads são alocados e bloqueados. Nessa altura, quaisquer outros pedidos recebidos têm de aguardar na fila para um thread disponível. À medida que cresce o comprimento da fila, os pedidos começam a alcançar o tempo limite.

Implementar a solução e verificar o resultado

O gráfico seguinte mostra os resultados dos testes de carga da versão assíncrona do código.

Performance chart for the sample application performing asynchronous I/O operations

O débito é muito superior. Na mesma duração que o teste anterior, o sistema lida com sucesso a um aumento quase dez vezes superior no débito, conforme medido nos pedidos por segundo. Além disso, o tempo médio de resposta é relativamente constante e permanece aproximadamente 25 vezes mais pequeno do que o teste anterior.