Introdução
Na parte 3 desta série, vimos como o I/O assíncrono pode ser mais eficiente, liberando a CPU da thread que espera por I/O para fazer outra atividade, aumentando o desempenho geral da aplicação.
Vimos também como essa implementação pode ser complexa. O objetivo deste post é mostrar como as novas features de async/await, implementadas no framework 4.5 podem simplificar este processo.
Utilizando o async/await
No exemplo anterior, utilizamos um método que faz uma grande escrita em disco, seguida de um grande processamento. Vamos recriar este exemplo, utilizando o padrão async/await.
[TestMethod] public void BigAsyncIOTest2() { Stopwatch watch = new Stopwatch(); watch.Start(); for (int i = 0; i < 10; i++) DoBigWriteAsync().Wait(); watch.Stop(); Console.WriteLine("Total elapsed (ms): " + watch.ElapsedMilliseconds); } private async Task DoBigWriteAsync() { FileStream fs = new FileStream("file1.txt", FileMode.Create, FileAccess.ReadWrite); byte[] buffer = new byte[100 * 1024 * 1024]; //Não faça isso. Aloca 100Mb de memória. Task t = fs.WriteAsync(buffer, 0, buffer.Length); Task t2 = t.ContinueWith(new Action((task) => { fs.Flush(); fs.Close(); })); BubbleSort.DoBigBubbleSort(10000, null); await t; }
O método DoBigWriteAsync teve um modificador “async” iniciado e um “await t” inserido no final. O método WriteAsync implementado pela classe Stream, provê os mesmos mecanismos. Na verdade em nível mais baixo da API é ele quem faz o tratamento da chamada de I/O assícrono.
Na prática, o que acontece nesta implementação é que cada vez que WriteAsync é chamado, uma continuação é inserida (para dar o Flush e o Close na Stream) e o BubbleSort é iniciado. Somente quando ocorre o “await t”, é aguardado o término do I/O para que o método devolva o resultado.
Na prática, o tempo que o método ficou parado com “await” não travou a thread da aplicação e sim a disponibilizou para executar outras atividades enquanto o I/O está em andamento. Aí está toda a mágica e a simplicidade do async/await. Você escreve um método com a mesma característica e simplicidade que escreve um método sícrono, porém, o framework não travará a thread.
Qual o desempenho?
Pelos testes muito simples executados nesta série, podemos perceber que há um overhead em relação ao método assíncrono puro (utilizado na parte 2 da série). O método executou em 13835 milisegundos, contra 9111 milisegundos da implementação pura. Meu palpite é que este overhead ocorre devido à mecânica da Task Parallel Library que exploraremos em seguida.
Quais as vantagens de usar?
Praticamente todas as API’s de I/O no framework estão sendo reescritas para suportar este padrão, entre elas: Acesso HTTP, Sockets, Web Services, Banco de dados (DataReader), etc. A lista completa está em: http://msdn.microsoft.com/pt-br/library/hh191443.aspx.
Você pode fazer, com baixo esforço, sua aplicação sofrer muito menos com problemas de responsividade e espera por I/O. Somente no exemplo acima, partimos de 20 segundos numa implementação totalmente síncrona para 14 segundos, basicamente trocando keywords nos métodos.
Como funciona?
Sempre que um método assíncrono (Ex.: WriteAsync) é chamado, o mesmo inicia a operação de I/O e cria uma Task que somente conclui quando o retorno do callback avisando que a operação de I/O terminou. Ou seja, quando o callback termina, a task é marcada como concluída. Nenhuma nova thread (worker thread) é criada neste processo. Este é um dos pontos que mais me gera confusão, pois apesar da classe Task fazer parte do namespace System.Threading, não significa que sempre que uma task é criada a mesma é executada numa thread separada.
Pelo próprio Task Parallel Library, toda nova task, é criada numa TaskFactory padrão, que utiliza um TaskScheduler padrão. O TaskScheduler padrão do framework é um ThreadPoolTaskScheduler. Ele tem um comportamento que a cada nova Task ele delega para seu thread pool a sua execução. Em outras palavras, ele tenta utilizar alguma thread livre do seu thread pool. Se não possui nenhuma, cria uma. Se possui threads demais (maior que a quantidade de cores da máquina), ele aguarda alguma task finalizar e pega uma das threads já criadas.
Com este comportamento, ele consegue manter o maior paralelismo possível, com a menor quantidade de trocas de contexto possível. Esperto, né?
Esse comportamento também proporciona um outro comportamento desagradável que é o fato de não sabermos exatamente em que thread a Task será executada, o que pode trazer potenciais problemas de locks, deadlocks, ou problemas com código não thread-safe sendo executado dentro de uma task. É o preço.
Por isso o overhead imposto acima. Numa execução simples, com a máquina com pouco processamento (cenário deste teste), há um incremento do tempo devido ao overhead imposto pelo processo de escalonamento das tasks. Num cenário de alta carga, o task scheduler deve valer seu preço.
Em contrapartida, sempre que executamos uma operação de I/O assíncrono no modelo async/await, esta task é enfileirada no TaskScheduler. Se ela está aguardando conclusão, a thread que originalmente estaria esperando pega outra Task e a executa. Essa outra task pode ser outra operação de I/O, um pedaço de processamento paralelo utilizado numa expressão PLINQ (parallel LINQ) ou qualquer outro processamento do framework que esteja encapsulado numa task.
Era isso.