Programação Paralela – Parte 3 – Operações de I/O e Locks

Introdução

Na parte 1 desta série, observamos que muitas vezes aumentar a quantidade de threads da aplicação sem nenhum critério pode causar pior desempenho do que manter a aplicação numa única thread. Na parte 2, como os locks também podem gerar problemas potenciais de performance. E qual a relação destes dois assuntos com I/O (input/output ou entrada/saída)?

O objetivo deste post é explicar esta relação.

O que são operações de I/O

Sempre que uma aplicação acessa algum recurso que está em algum periférico do computador (fora da CPU ou fora da memória), uma operação de I/O é realizada. Ou seja, praticamente tudo. Operações de I/O ocorrem o tempo todo nas aplicações.

Exemplos de operações de I/O são acesso a disco e acesso a rede. Se acesso a rede é uma operação de I/O, obviamente tudo que implica comunicação, seja com banco de dados utilizando o driver do fornecedor, requests HTTP, TCP, UDP, ou seja, qualquer coisa que sai para rede.

Operações de I/O síncronas geram espera nas threads

Sempre que uma operação de I/O é feita, há uma espera na thread. Explicando de uma forma mais detalhada, se sua aplicação faz um acesso a disco de forma síncrona, no momento em que o sistema operacional vai no disco, a thread está parada esperando o cabeçote do HD se mover até o local correto (seek time), realizar a leitura e devolver as informações. Existem diversos mecanismos de cache para minimizar o movimento físico do HD, mas usei um exemplo forte para que seja possível imaginar a razão da lentidão.

Em máquinas modernas esse tempo é praticamente imperceptível para humanos (fica nos milisegundos), mas do ponto de vista do computador, são vários ciclos de CPU desperdiçados que poderiam estar sendo usados para outras operações enquanto a CPU está aguardando a resposta do periférico.

Um exemplo de I/O síncrono:

        public void SynchronousIOTest()
        {
            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.            
            fs.Write(buffer, 0, buffer.Length);
            fs.Flush();
            fs.Close();
        }

A idéia do código acima é que no “Write”, a thread fica esperando o disco escrever 100Mb antes de continuar.

Isso executa bem rápido. Na minha máquina demorou ~1 segundo. É válido lembrar que em servidores, com diversas aplicações acessando o disco simultaneamente, isso pode piorar significativamente.

A mesma idéia, de forma assíncrona.

        public void AsyncIOTest()
        {
            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.            
            fs.BeginWrite(buffer, 0, buffer.Length, null, null);
            fs.Flush();
            fs.Close();
        }

A única coisa que mudou foi o “BeginWrite” no lugar do Write. O tempo cai para 10ms. Óbvio que a implementação acima está errada, mas ela serve para ilustrar que no primeiro exemplo, a thread espera toda a operação de I/O enquanto no segundo exemplo, ela continua a execução mesmo que a operação de I/O não esteja completa.

Numa implementação correta de I/O assíncrono deveria haver o tratamento do callback. Na verdade, a API de I/O “avisa” sua aplicação quando a operação de I/O é concluída, sendo que entre o BeginWrite e a chamada do callback, você pode usar a CPU para o que bem entender.

Em outras palavras, a espera por I/O funciona exatamente como um lock na thread. No exemplo acima, usei uma escrita em disco, mas uma escrita em rede funciona exatamente da mesma maneira. Sim, toda vez que você executa o seu SqlCommand, ou enche o seu DataSet com dados, ou chama seu querido Entity Framework, NHibernate, por debaixo dos panos, essa espera por I/O está acontecendo e está travando a thread (ressalvas para quem já utiliza as novas API’s de I/O).

Afinal, como funciona o I/O assíncrono?

É uma excelente pergunta. É algo que funciona muito próximo de magia negra. A explicação simples é que sempre que ocorre uma operação de I/O, a CPU pede para o periférico realizar a operação e continua processando. CPU’s são máquinas sequenciais e não param nunca. CPU ociosa é CPU jogada fora.

Quando o periférico termina de fazer o que tem que fazer, ele gera uma interrupção (quem já configurou jumper na unha, já ouviu falar de IRQ), avisando a CPU que ele terminou.

Essencialmente, o Windows encapsulou muito bem isso numa API chamada I/O completion ports. Essa API tem a idéia de permitir que, lá do nível dos dispositivos, passando pelos drivers (kernel mode) até o código que executa em user mode, a thread que originalmente pediu a operação de I/O receba uma notificação de que ela terminou.

O post do Stephen Cleary “there is no thread” explica passo a passo como isso acontece, sofrendo diversos “empréstimos” de threads existentes até notificar a thread original. O ponto que ele detalha é que não existe uma worker thread parada, esperando a operação de I/O terminar. A thread fica executando até que seja notificada pelo sistema operacional que o I/O terminou.

Esperto, né?

Dá muita diferença?

Para exemplificar a diferença entre o I/O síncrono e assíncrono, montei o exemplo abaixo. A idéia é realizar 10 vezes um processamento que combina o uso de recursos de CPU (de novo o grande e gastão BubbleSort) com I/O.

Exemplo síncrono:

        [TestMethod]
        public void BigSyncIOTest()
        {
            Stopwatch watch = new Stopwatch();
            watch.Start();

            for (int i = 0; i < 10; i++)
            {                
                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.            
                fs.Write(buffer, 0, buffer.Length);
                fs.Flush();
                fs.Close();

                BubbleSort.DoBigBubbleSort(10000, null);
            }

            watch.Stop();
            Console.WriteLine("Total elapsed (ms): " + watch.ElapsedMilliseconds);
        }

O exemplo síncrono é realmente bem simples. Escreve 100Mb em disco, depois faz um bubble sort numa matriz invertida de 10000 elementos.

O exemplo assíncrono é mais complexo (implementado desta maneira propositalmente com o APM para fins didáticos). O método BeginWrite recebe dois parâmetros adicionais, um AsyncCallback e um object para manter estado.

A idéia deste modelo de programação é que o método passado como callback é chamado quando a operação de I/O termina.

A classe utilizada para manter estado, segue essa estrutura:

    public class State
    {
        public int Current
        {
            get;
            set;
        }

        public FileStream FileStream
        {
            get;
            set;
        }
    }

O método principal:

        private ManualResetEvent ev;

        [TestMethod]
        public void BigAsyncIOTest()
        {
            Stopwatch watch = new Stopwatch();
            watch.Start();

            ev = new ManualResetEvent(false);

            WriteFinishedCallback(null);
            ev.WaitOne();

            watch.Stop();
            Console.WriteLine("Total elapsed (ms): " + watch.ElapsedMilliseconds);
        }

        public void WriteFinishedCallback(IAsyncResult ar)
        {
            State state = null;
            if (ar == null)
            {
                state = new State();
                state.Current = 0;
            }
            else{
                state = (State)ar.AsyncState;
                state.FileStream.EndWrite(ar);
                state.FileStream.Flush();
                state.FileStream.Close();
                state.Current++;
                if (state.Current == 10)
                {
                    ev.Set();
                    return;
                }
            }            

            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.            
            fs.BeginWrite(buffer, 0, buffer.Length, WriteFinishedCallback, state);
            state.FileStream = fs;

            BubbleSort.DoBigBubbleSort(10000, null);

        }      

Vejam que um objeto de sincronização é necessário. Ele será acionado de dentro do método WriteFinishedCallback, que será chamado da primeira vez do método principal e será chamado recursivamente até completar 10 vezes. Realmente é bem chatinho implementar esse modelo e debugar então, fora de questão. Debugar esse tipo de código assíncrono é uma tarefa das mais ingratas.

O “payload” do processamento é o mesmo. O mesmo bubble sort gastão, e a mesma escrita em disco. Os resultados:

  • Síncrono: 21781 milisegundos
  • Assíncrono: 9111 milisegundos

A explicação é simples. No modelo assíncrono, enquanto a CPU espera I/O ela fica ordenando a matriz. No modelo síncrono, ela espera toda a operação de I/O terminar para então começar a processar o bubble sort. Fica menos da metade do processamento total, utilizando uma única thread.

Já imaginou isso escalado para toda uma aplicação, fortemente orientada a banco de dados?

Anúncios

Deixe um comentário

Preencha os seus dados abaixo ou clique em um ícone para log in:

Logotipo do WordPress.com

Você está comentando utilizando sua conta WordPress.com. Sair / Alterar )

Imagem do Twitter

Você está comentando utilizando sua conta Twitter. Sair / Alterar )

Foto do Facebook

Você está comentando utilizando sua conta Facebook. Sair / Alterar )

Foto do Google+

Você está comentando utilizando sua conta Google+. Sair / Alterar )

Conectando a %s