Programação Paralela – Parte 4 – I/O Assícrono com Async/Await

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.

Advertisement

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s