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?

Programação Paralela – Parte 2 – Locks em threads

O que é um lock?

Há situações em threads que precisamos fazer com que uma thread espera a outra, dado que ambas pretendem acessar um recurso que não pode ser acessado simultaneamente. Neste caso, utilizamos locks.

O problema é que sempre que uma thread espera por um lock, ela está consumindo recursos, gerando trabalho para o escalonador do sistema operacional e não está fazendo nada. Esse comportamento pode gerar problemas de desempenho.

Locks

Como explicado no post anterior (Programação paralela – Parte 1), threads compartilham o heap. Dessa maneira, eventualmente queremos evitar que as threads concorram pelos mesmos recursos. Para isso, existem objetos de sincronização, como no exemplo abaixo:

public void DoSomething()
{
    lock(this)
    {
         //Trecho que executará somente com uma thread de cada vez.
    }
}

O trecho protegido por lock, fará com que as threads não entrem simultaneamente no mesmo trecho. Existem diversos outros objetos de sincronização, inclusive que impedem que mais de um processo acesse o mesmo recurso simultaneamente. Eles ficam no namespace System.Threading.

Corrida de saco

corridadesaco

O grande problema de usarmos uma infinidade de threads com diversos pontos de sincronização (locks) é que nossas threads ficarão realizando uma espécie de corrida de sacos. Uma anda um pouquinho, mas precisa esperar a outra liberar um recurso, fazendo com que na média, tudo fique mais lento.

Para exemplificar, alterei o exemplo anterior, a parte que cada uma das threads executa, para o seguinte código:

        private void DequeueAndProcess(Queue<Job> queue)
        {
            while (true)
            {
                Job job = null;
                lock (queue)
                {
                    if (queue.Count > 0)
                        job = queue.Dequeue();

                    if (job == null)
                    {
                        Thread.Sleep(0);
                        continue;
                    }
                    job.JobDelegate();
                    job.JobEndedNotifier.Set();
                }
            }
        }

Neste caso, obtemos os seguintes tempos:

ProcessamentoParalelo-pt2-1

O exemplo é bastante exagerado, pois todo o processamento está sendo serializado dado que somente uma thread consegue processar o trecho protegido pelo lock, mas para fins didáticos, nos ajuda a entender porque muitas vezes o processamento multi-thread concorrendo por vários recursos fica pior do que o processamento single-threaded. A reposta é que além do custo do processamento, temos todo o custo de gerenciar as threads. Como desenvolvedor, não sentimos este custo, mas o sistema operacional sem dúvida sente.

Deadlocks

deadlock

Se deadlocks em bancos de dados incomodam, deadlocks em threads fazem você sentir saudades dos de banco de dados. O processo é o mesmo: duas threads que dependem de mais de um recurso tentam os obter forma inversa. O sintoma é bem diferente: as threads envolvidas no deadlock simplesmente congelam. Diferentemente do banco de dados, não existe um recurso que automaticamente escolhe uma thread como vítima, e automaticamente a mata. Você precisa desenvolver este mecanismo por sua conta.

Por sorte, deadlocks em threads são tão raros quanto um deadlock na vida real, mas como a foto acima ilustra, eles acontecem.

I/O

Como regra geral, handles também são recursos que não suportam concorrência. O mesmo mecanismo de forçar locks deve ser utilizado para evitar que duas threads tentem escrever no mesmo socket ao mesmo tempo, tentem escrever no mesmo arquivo ao mesmo tempo e assim sucessivamente. Em geral, utiliza-se mecanismos como o “lock” exemplificado acima.

Tipos thread-safe

Outra preocupação que devemos ter ao utilizar concorrência por objetos entre threads está relacionada ao tipo de dados utilizado. No MSDN há para cada tipo de dados observações sobre o tipo de dado ser thread-safe ou não (as vezes notas bastante confusas!).

Em geral, um tipo thread safe é aquele que você não precisa ter medo de acessar ou modificar em diferentes threads (Ex.: ConcurrentQueue). Tipos não thread-safe, você precisa explicitamente utilizar um lock (Ex.: Queue), o que pode gerar gargalos de desempenho, como apresentado anteriormente.

Notas importantes para aplicações Web

Aplicações web são em geral, multi-threaded sem muitas vezes nós percebermos. Geralmente cada novo request é servido numa nova thread, ou seja, se 10 usuários pedem para um servidor servir um request, é possível que tenhamos 10 threads no servidor. Utilizo as palavras geralmente e “é possível” porque o ASP.NET utiliza um mecanismo interno dele para determinar quando ou não utilizar uma nova thread, justamente para evitar contenções e baixo desempenho, conforme apresentado nesses posts. As vezes é melhor fazer um usuário esperar e os 10 que estão sendo atendidos serem bem atendidos, do que deixar 11 com péssima responsividade.

Sendo que aplicações web são essencialmente multi-thread, as mesmas preocupações devemos ter em relação a locks. Se dois requests tentam escrever num arquivo simultaneamente, um dos dois tomará uma exception. Se há alguma estrutura em memória compartilhada entre os requests (exemplos: singletons, instâncias únicas de serviço do Spring.NET, dados estáticos em classes), os mesmos problemas de concorrência estão sujeitos a acontecer.

Conclusão

Como diria um amigo: pensou que ia ser fácil, começou errado!

Processamento paralelo está longe de ser simples. É um conceito um tanto quanto complexo e na prática, debuggar aplicações multi-thread é algo bastante complexo. A própria existência do debugger, de uma linha de log, pode fazer com que as threads concorram em locais diferentes e o bug não se apresente.

Mas o recado mais importante é que não adianta pegar uma aplicação que não foi feita para ter paralelismo, que possui diversos pontos de compartilhamento de informações e sair gerando threads e entupindo de locks onde os problemas são apresentados. Muitas vezes esse processo torna a aplicação ainda menos eficiente do que era quando totalmente single-threaded.

É importante pensar numa arquitetura que suporte concorrência e escala.

Programação paralela – Parte 1 – Quantas threads?

Introdução

Recentemente, comecei a fazer alguns estudos referentes à parte de programação assíncrona do .NET 4.5. Para chegar no conceito de como funciona a parte de I/O assíncrono, achei interessante voltar um pouco e começar pelas raízes da programação paralela, com o objetivo de ilustrar claramente as diferenças e como esses conceitos se combinam. Daí nasceu essa série.

Nesse post abordarei o conceito básico de threads, trocas de contexto e como isso pode resultar em desempenho pior do que aplicações que não utilizam nenhum processamento paralelo.

Afinal, o que é uma thread?

Antes de explicar uma thread, acho melhor explicar primeiro o que é um processo. Um processo nada mais é do que uma instância de um executável, na memória do computador, ou seja, cada aplicação ou serviço executam num processo. Basta ver a lista do seu task manager. Lá você tem uma lista de processos. Se você executa duas vezes a mesma aplicação, tem 2 processos executando, apesar do executável ser o mesmo.

Um processo tem suas próprias áreas de memória, seu stack e seu heap (no caso do .NET, managed heap), ou seja, um processo não invade a área de memória do outro processo. Quem vem de .NET não sabe muito bem o que isso significa na prática, mas quem vem de linguagens não gerenciadas (como C++) sabe bem do que eu estou falando. Se por alguma razão, fizer um deslocamento que dá numa região da memória utilizada por outro processo, seu processo capota (ainda bem).

Então o que é uma thread? Eu costumo explicar uma thread como “uma linha de execução separada” dentro de um mesmo processo. Neste caso, o heap é compartilhado por todas as threads do processo, e cada thread tem sua stack separada. De forma bem simplista, isso significa que se você criar uma instância de uma classe e acessá-la de duas diferentes threads, elas estarão no mesmo espaço de memória o que pode gerar efeitos indesejados em sua aplicação, porém as variáveis locais utilizadas não serão compartilhadas. O post Tipos Escalares e Tipos Complexos ou Tipos por Valor e Referência pode ajudar no entendimento destes conceitos.

O básico da idéia de threads está em:

        public void DoSomething()
        {
            //Algum processamento.
        }

        public void DoSomethingInParallel()
        {
            Thread t = new Thread(DoSomething);
            t.Start();
            DoSomething();
        }

No exemplo acima, ao executar “DoSomethingInParallel”, sua máquina executará duas vezes, simultaneamente, o método “DoSomething”, uma na thread principal (sempre que um novo processo inicia, ele tem uma thread principal) e outra numa thread secundária criada ao executar new Thread, passando como parâmetro um delegate do tipo ThreadStart criado a partir do método DoSomething.

Qual é o limite de threads?

Aqui as coisas começam a ficar complicadas. Não há um limite exato de threads, ou seja, pode-se criar centenas de threads num computador. Talvez a pergunta mais apropriada seja: até onde é eficiente paralelizar um processamento?

Não há uma resposta exata para essa pergunta, mas, existem algumas informações que podem nos ajudar a chegar nesse número mágico.

O limite máximo para execução em paralelo numa máquina está relacionado com o número de cores do processador da máquina, ou seja, numa máquina de 4 cores, 4 threads são executadas em paralelo. As demais, o sistema operacional acaba realizando escalonamento do processamento, ou seja, deixa uma executar um pouquinho, passa para a outra e assim sucessivamente, da mesma forma que ele faz com os processos.

O grande problema é que esse escalonamento é o que chamamos de troca de contexto, ou seja, toda vez que uma thread vai executar, o sistema operacional precisa pegar o stack daquela thread (que pode ter estar fora do cache e até mesmo em disco), recolocar na memória e reexecutar essa thread. Isso obviamente tem um custo.

A resposta para a pergunta acima é: É eficiente deixar o número de threads equivalente ao número de cores processando, sem que haja espera nessas threads. O que chamamos de espera é: espera por carregar itens da memória, operações de I/O, etc. Abordarei esse tema em mais detalhe (espera) em posts futuros.

Um pouco de prática

Para ilustrar o funcionamento das threads, criei um exemplo. A idéia é processar 400 vezes um bubble sort de uma matriz de 1000 itens. A idéia do código é criar worker threads que ficam esperando um determinado dado numa fila em memória, sempre que um dado destes é colocado, ela processa este dado, até que o trabalho todo termine. O código abaixo é o código executado por cada thread.

        private void DequeueAndProcess(ConcurrentQueue<Job> queue)
        {
            while (true)
            {
                Job job = null;
                if (!queue.TryDequeue(out job))
                {
                    Thread.Sleep(0);
                    continue;
                }
                job.JobDelegate();
                job.JobEndedNotifier.Set();
            }
        }        

Cada Job acima é uma estrutura que recebe um JobDelegate (delegate que faz o trabalho quando executado) e um JobEndedNotifier (ManualResetEvent utilizado para avisar o método que cria as threads que o trabalho terminou). Usei uma queue sincronizada justamente para evitar contenção (espera) entre as threads.

Em seguida, um método que cria um número de threads, mede o tempo de execução (Stopwatch) e aguarda o término da execução de todo o trabalho.

        private void CreateJobsAndProcessInParallel(int numberOfThreads)
        {
            ConcurrentBag<int> threadIDs = new ConcurrentBag<int>();
            Stopwatch watch = new Stopwatch();
            watch.Start();
            ConcurrentQueue<Job> queue = new ConcurrentQueue<Job>();
            List<Thread> list = new List<Thread>();
            List<ManualResetEvent> eventList = new List<ManualResetEvent>();
            for (int i = 0; i < numberOfThreads; i++)
            {
                Thread t = new Thread(() => { DequeueAndProcess(queue); });
                list.Add(t);
                t.Start();
            }
            for (int i = 0; i < numberOfJobs; i++)
            {
                ManualResetEvent e = new ManualResetEvent(false);
                eventList.Add(e);
                ThreadStart t = new ThreadStart(() =>
                {
                    BubbleSort.DoBigBubbleSort(bubbleSize, threadIDs);
                });
                queue.Enqueue(new Job(t, e));
            }
            foreach (ManualResetEvent e in eventList)
                e.WaitOne();
            foreach (Thread t in list)
                t.Abort();

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

O método DoBigBubbleSort apenas executa o bubble sort e guarda uma lista de quantas diferentes threads foram usadas no processamento.

Em seguida, executei o exemplo acima com 1, 2, 4, 100, 200, 400 threads, na minha máquina que possui 4 cores (2 cores físicos, 4 processadores lógicos). Os resultados estão abaixo:

ProcessamentoParalelo-pt1-2

ProcessamentoParalelo-pt1-1

Este gráfico nos mostra algumas características muito interessantes:

  • O processamento com duas threads é mais do que a metade do processamento com uma thread só. Na prática isso significa que apesar de paralelizar o processamento, a um custo para gerenciar as threads, além dos demais processos que executam no sistema operacional
  • O processamento com 4 threads é um pouquinho melhor do que o processamento com 2 threads, apesar da máquina ter somente 2 cores físicos. Acredito que esse número seja explicável com a existência de 4 processadores lógicos na máquina (ver hyper threading).
  • O mais interessante aqui é a partir de algo entre ~150 threads o processamento paralelo começa a ficar pior do que o processamento numa thread só. Isso é uma demonstração prática do custo de troca de contexto entre threads

Usando Task Parallel Library

Um outro exemplo, usando a TPL (Task Parallel Library):

        public void TaskBasedTest()
        {
            ConcurrentBag<int> threadIDs = new ConcurrentBag<int>();
            Stopwatch watch = new Stopwatch();
            watch.Start();
            List<Task> l = new List<Task>();
            for (int i = 0; i < numberOfJobs; i++)
                l.Add(Task.Factory.StartNew(() => { BubbleSort.DoBigBubbleSort(bubbleSize, threadIDs); }));
            foreach(Task t in l)
                t.Wait();
            watch.Stop();
            Console.WriteLine("Total elapsed (ms): " + watch.ElapsedMilliseconds);
            Console.WriteLine("Number of threads used: " + threadIDs.Distinct().Count());
        }

O exemplo acima, teve o seguinte tempo de execução:

ProcessamentoParalelo-pt1-3

Como vemos no código, não fazemos qualquer controle do número de threads. Apenas disparamos a execução e contamos quantas diferentes threads foram utilizadas no processamento. No exemplo acima, foram 5.

Como a TPL funciona? Qual é a mágica?

Sempre que executamos um Task.Factory.StartNew, estamos criando uma nova Task na TaskFactory “default”. A TaskFactory default utiliza uma implementação interna de um TaskScheduler (é uma classe abstrata). A implementação default é um ThreadPoolTaskScheduler.

Internamente, o ThreadPoolTaskScheduler delega a execução das tasks para um thread pool que procura manter sempre executando o número de threads correspondente ao número de processadores da máquina. Na prática quando recebe uma task, baseada em quantas threads estão executando, ele checa se deve ou não iniciar uma nova thread ou utilizar uma das que estão ociosas. Espertinho, não?

Na prática, quando usamos PLINQ (Parallel LINQ), Parallel.For, e outros, estamos delegando para este TaskScheduler a lógica de como quebrar o processamento em múltiplas threads, como visto no exemplo acima, um processo um tanto quanto complexo, que a API consegue nos abstrair de uma forma bem eficiente.

Código fonte do exemplo

O código ainda está uma bagunça, mesmo assim, subi para o GitHub: https://github.com/ericlemes/ParallelProgramming.

Linguagens compiladas, interpretadas e byte-code

Introdução

Recentemente, comecei a aprender Python. Tudo começou num bate-papo no café do TDC 2013, com o @juanplopes (thanks!). Estávamos discutindo sobre linguagens, ele mencionou o Python e eu imediatamente falei sobre minha “birra” com linguagens interpretadas. Então o Juan comentou de algumas alternativas de JIT-Compiler para o Python.

Esse post nasceu para falar de duas coisas: 1) Minha birra, 2) JIT-Compiler para o Python.

Um resuminho sobre linguagens compiladas, interpretadas e byte-code

Não vou me aprofundar nesse tema, que dá muita discussão, mas a idéia é só fazer uma diferenciação básica entre os 3 mundos.

Linguagens interpretadas em geral carregam todo o seu interpretador na memória, e conforme vão executando, traduzem as instruções de sua linguagem para instruções em linguagem de máquina. Um exemplo disso, é o velho VB (falo do VB6 ou ASP clássico, não do VB.NET). Quando você faz uma página ASP, simplesmente coloca ela no servidor e ela “funciona”. Já o VB clássico, é um pouco diferente. Como ele produz um “EXE” ele faz você pensar que é compilado, mas na verdade é o interpretador do VB, com teu código praticamente todo embutido como resource dentro dele. O antigo Clipper era assim também. Já o dBase (!) era um interpretador puro mesmo.

Linguagens compiladas, na sua etapa de compilação, traduzem o código fonte em linguagem de máquina. O binário (linguagem de máquina) não passa por toda a etapa de interpretação em tempo de execução, o que faz com que a execução seja muito mais rápida. O C, C++, Object Pascal (Delphi) são assim. Em geral, linguagens compiladas também são “não gerenciadas”, o que significa na prática que você deve controlar a gestão da sua memória por conta própria, o que traz muito controle e muito trabalho para o desenvolvedor.

A idéia do byte-code, veio para achar um meio-termo entre os dois mundos. O C#, VB.NET e Java são assim. Em tempo de compilação, você traduz da linguagem nativa (C#, Java) para uma linguagem intermediária. Assim são os assemblies .NET e os .class no java. Em tempo de execução, o framework traduz as instruções de byte-code para linguagem de máquina, porém, faz isso uma vez só. Esse é o processo do “JIT-Compiler” (just-in-time compiler). Em geral, a primeira execução é lenta pois passa pela compilação, mas nas etapas subsequentes, o tempo é mais ou menos parecido com uma linguagem compilada. Linguagens com byte-code em geral são “gerenciadas”, ou seja, possuem um garbage collector e cuidam de sua própria gestão de memória, o que por um lado diminui o trabalho do desenvolvedor, mas também diminui o controle do desenvolvedor sobre a gestão da memória (característica desejável para alguns tipos de aplicação).

Qual o problema com linguagens interpretadas?

O desempenho. Desconheço casos práticos em que linguagens interpretadas conseguem ser mais rápidas que compiladas e byte-code. O overhead de interpretar as instruções e traduzir em tempo de execução é muito grande.

Comparando entre as compiladas e byte-code, a discussão é mais complexa. O pessoal de compiladas (C++) costuma justificar que em casos extremos consegue fazer otimizações que os byte-codes não conseguem, e o pessoal de byte-code justifica que os ambientes “aprendem” conforme a execução e podem melhorar conforme o processo roda mais.

Eu percebo que na prática, JIT é mais lento que linguagem nativa no caso médio, porém, o custo vem no trabalho que dá fazer gestão de memória em linguagens compiladas. Acho que ambas tem suas aplicações.

Um micro-benchmark

Micro-benchmarks são péssimos e legais ao mesmo tempo. Péssimos porque não dá pra avaliar o desempenho de uma linguagem/ambiente num único SO, com um único exemplo bobo. Legais porque ajudam a ter uma idéia.

Resolvi fazer um exemplo muito simples. Um bubble sort (algoritmo popularmente conhecido por ser uma porcaria e gastar muitos recursos computacionais) e fazer o coitado ordenar uma matriz que está em ordem decrescente (pior caso). Assim ele gera O(n^2) comparações. Implementei o mesmo algoritmo em Python, C# e C++.

As implementações seguem:

C#


    public static class BubbleSort2
    {
        public static void Sort<T>(IList<T> list) where T: IComparable
        {
            bool swap = true;
            while (swap)
            {
                swap = false;
                for (int i = 1; i < list.Count; i++)
                {
                    if (list[i].CompareTo(list[i - 1]) < 0)
                    {
                        T tmp = list[i];
                        list[i] = list[i - 1];
                        list[i - 1] = tmp;
                        swap = true;
                    }
                }
            }
        }
    }

        [TestMethod]
        public void TestBigBubbleSort()
        {
            List<int> l = new List<int>(20000);
            for (int i = 20000; i >= 0; i--)
                l.Add(i);
            BubbleSort2.Sort(l);
        }

C++

#include <vector>

inline void Swap(std::vector<int> *l, int p1, int p2){
	int tmp = l->at(p1);
	l->at(p1) = l->at(p2);
	l->at(p2) = tmp;
}

void Sort(std::vector<int> *l){
	bool switched = true;
	while (switched){
		switched = false;
		for (int i = 1; i < l->size() - 1; i++){
			if (l->at(i) < l->at(i - 1)){
				Swap(l, i, i - 1);
				switched = true;
			}
		}
	}
}

namespace AlgorithmsTests
{		
	TEST_CLASS(UnitTest1)
	{
	public:
		
		TEST_METHOD(TestBigBubbleSort)
		{
			std::vector<int> l = std::vector<int>();			
			for (int i = 20000; i >= 0; i--)
				l.push_back(i);
			Sort(&l);

		}

	};
}

Python


def swap(list, p1, p2):
    tmp = list[p1]
    list[p1] = list[p2]
    list[p2] = tmp       

def bubble_sort(list):
    switched = True
    while switched :
        switched = False
        for i in range(len(list) - 1) :
            if i + 1 > len(list) - 1 :
                break
            if list[i] > list[i + 1] :
                swap(list, i, i + 1)
                switched = True
                
            
import unittest
from algorithms.core import bubble_sort

class BubbleSortTests(unittest.TestCase):
        
    def test_big_bubble_sort(self):
        l = [x for x in range(20000, 0, -1)]
        bubble_sort.bubble_sort(l)
    
if __name__ == '__main__':
    unittest.main()
       

Tempos de execução

Linguagem Tempo (ms)
C# 7.000
C++ 874
Python 109.199

Entenderam a razão da minha bronca com linguagens interpretadas?

Curiosidade inútil: Compilando em Debug, o C# demora 10s e o C++ em 1 minuto!

PyPy para o resgate

O PyPy é um JIT-Compiler para o Python. Neste ambiente, temos o seguinte resultado:

Linguagem Tempo (ms)
C# 7.000
C++ 874
Python (CPython) 109.199
Python (PyPy) 4.393

Como vemos no exemplo, apresenta um tempo de execução melhor que o C#. Interessante, né?

Resta saber se é um ambiente de execução maduro, mas é o suficiente para despertar meu interesse pela linguagem. Breve poderemos ter mais posts por aqui.

Integração Contínua

Objetivo

Recentemente, numa das listas de discussão que participo (.NET Architects), surgiu a discussão sobre integração continua. Fiquei um pouco surpreso. Apesar de integração contínua ser uma prática um tanto quanto antiga (a primeira referência que conheço do assunto é do livro do Kent Beck, de 1999), muita gente ainda se assusta ao falar do tema.

Essa foi minha motivação para escrever um pouco sobre integração contínua (CI – Continuous Integration) aqui.

O que é integração contínua

Primeiro vamos tentar entender o que “não é” integração contínua. Antigamente (assim espero), os desenvolvedores costumavam trabalhar em “silos”. Cada um pegava um pedaço do sistema pra fazer, fazia no seu canto e colocava-se um tempo de “integração” nos cronogramas. Esse tempo era para fazer os ajustes necessários para o lego todo se encaixar. Por exemplo: Um fazia a tela, o outro fazia o servidor, o outro as procedures de banco.

É claro que muita coisa dava errada. Nem sempre os contratos eram definidos em detalhes, muitos aspectos eram deixados de lado e na hora de integrar tudo, era uma longa dor de cabeça.

A proposta da prática de integração contínua é que ao contrário de segurar as alterações e subi-las somente no final do projeto e integrar tudo ao mesmo tempo, as pessoas devem constantemente integrar o seu trabalho ao restante do time. Lembro-me no livro original do Beck que ele tinha a proposta inclusive das pessoas fazerem o build numa máquina separada da sua máquina de desenvolvimento, justamente para evitar o famoso “na minha máquina funciona”, que gerou um dos posts mais divertidos sobre o assunto: The Works on My Machine Certification Program

Em resumo, a idéia da prática é (tradução das principais partes do wikipedia):

  • Use um repositório de controle de versão: Não, não vale uma pasta na rede compartilhada, um pen-drive com o código fonte. É um software de controle de versão que gerencie concorrência, mantenha histórico, permita que você administre branches, etc.
  • Automatize o Build: Sim, montar o seu pacote, exatamente como deveria ser entregue para seu usuário final deveria ser um processo de “um passo”. Apertar um único botão, e do outro lado deveria sair um pacote pronto, com todas as dependências resolvidas
  • Faça o build auto-testável: É uma excelente idéia juntar seus testes unitários ou mesmo de aceitação com o seu processo de build
  • Todo commit deve disparar um build: Cada vez que qualquer coisa é alterada no repositório de controle de versão, um build deve ser disparado, obviamente, disparando todo o processo de empacotamento e testes, e claro, se você já estiver neste nível de maturidade, deploy
  • Mantenha o build rápido
  • Todos devem ver o resultado do último build

Como implementar

Felizmente, hoje existem ferramentas open source fantásticas (em sua maioria melhores do que pagas) para implementar todo esse processo.

Para controle de versão, sugiro consultar a última pesquisa sobre controle de versão e também o post sobre gestão de configuração e versões.

Para a automação e criação do servidor de integração contínua, sugiro os seguintes passos:

Instale um servidor de integração contínua

Suba uma instância do seu servidor favorito. Os mais populares são:

  • Jenkins: Open-source, mais popular na comunidade Java, mas serve para buildar qualquer tipo de código fonte
  • Team city: Da JetBrains, free até uma certa quantidade de projetos. Ferramenta fantástica, muito intuitiva
  • CruiseControl.NET: Começou com a ThoughtWorks, agora parece que a comunidade open source assumiu. Foi meu favorito durante anos, mas ainda demandava muita configuração “na mão”

Existem outros também, como o próprio Team Foundation Server, Rational Team Concert, mas em geral não são tão simples e populares como os supra-citados, que suportam uma série de ferramentas de controle de versão. Aliás, esse é um ponto importantíssimo. Verifique se o servidor suporta o controle de versão que você usa.

Se você usa cvs, svn, git, garanto que não terá muito sofrimento em configurar qualquer um deles. Se você usa, TFS, VSS, RTC e outros, o máximo que posso dizer é: boa sorte. Esses controles de versão tem uma interface de linha de comando muito pobre, o que torna bem chato fazer qualquer tipo de automação.

Crie um script de build

Se você vem de plataforma Microsoft, usar o MSBuild será “natural” para você. Veja o post MSBuild in a nutshell sobre como criar os builds.

O ant é o irmão mais velho, da comunidade Java.

Esses “toolkits” de build nada mais são do que ferramentas para automatizar a execução de atividades de linha de comando. Se você não sabe trabalhar com linha de comando, aqui está uma excelente motivação para aprender. Será inevitável trabalhar direito com linha de comando para mexer com automação de build.

Os toolkits também permitem criar “tasks” customizadas, de forma que você pode utilizar todo seu conhecimento no seu framework favorito para fazer as maluquices mais improváveis em builds.

A dicas sobre automação de builds:

  • Comece de forma muito simples e depois vá tornando mais completo. Primeiro, faça o fonte compilar. Já dá para ter ganhos absurdos só fazendo isso
  • Mesmo que seu projeto não tenha testes, deixar a “infra-estrutura” pronta para receber os testes é uma boa. Por infra estrutura digo, a casca do projeto de testes e a mecânica para invocar os testes no build. A medida que o time percebe que nunca mais um teste ficará vermelho, porque o build quebra, a integração contínua pode ser uma forte aliada para alavancar os testes unitários
  • Empacotamento: O build não é somente compilar, ele também significa reunir todas as dependências necessárias para rodar a aplicação. Isso engloba: scripts de base de dados, binários externos, instaladores, etc.
  • Métricas: O MSBuildCodeMetrics é um projeto que comecei justamente com esse intuito. A idéia é chegar num nível de automação que se alguém commitar um método com mais e X linhas ou com complexidade ciclomática superior a Y, o build quebra.
  • Manutenção de numeração de versão: Quando se ganha experiência, o processo de criar as “tags” corretamente e carimbar as versões, pode ser todo feito no servidor de integração contínua. A série automatizando o versionamento no build (Parte 1 e Parte 2) falam um pouco sobre esta idéia.

Como os builds rodam server-side, é muito importante ser disciplinado na hora de montar o servidor de build. Eu por exemplo, não instalo Visual Studio em servidor de build, justamente para que os builds quebrem ao menor sinal de que alguma dependência “estranha” foi inserida na aplicação.

Notificação do build

Mais importante do que o build quebrar ou ter sucesso, é o time saber disso! Não adianta nada o build quebrar e ninguém corrigir. Por isso, é imprescindível instalar um notificador.

Os servidores de integração contínua, geralmente mandam e-mails. Infelizmente desenvolvedores em geral não dão muita atenção para e-mails corporativos.

Eu recomendo a instalação de um notificador associado ao “system tray”. O CruiseControl.NET tem o “CCTray”, que faz isso muito bem. O Jenkins tem o hudsonTracker ou o próprio CCTray que funciona no Jenkins (particularmente uso o CCTray).

Vale a pena sair instalando de máquina em máquina o notificador.

As etapas da implantação da integração contínua

Primeiro vem a etapa da negação. Os tipos de #mimimi mais comuns que você ouvirá serão:

  • A ferramenta de controle de versão fez merge errado
  • Na minha máquina funciona
  • O servidor de build está com problema

Paciência. Vale a pena pegar caso a caso e mostrar um a um o que o desenvolvedor fez de errado. Uma vez que ele entenda, ele não vai querer passar a mesma vergonha duas vezes.

Depois da etapa da negação, vem a etapa da aceitação. Nessa etapa, as pessoas não criticam mais a estabilidade e a necessidade do processo de build, mas também não contribuem. O build fica lá quebrado, e ninguém se interessa por arrumar. É nessa hora que vale a pena discutir um pouco sobre “propriedade coletiva de código”. Se o build quebra, é problema de todos.

É importante combinar algumas regras para fazer a prática pegar. Já vi castigos físicos (não recomendado, porque eu preciso ser politicamente correto num blog), colocar dinheiro num pote (Ex.: R$1 a cada quebra de build e o dinheiro é usado depois para comprar guloseimas) e em último caso a autorização para voltar qualquer commit que quebra o build. Quebrou o build? Alguém vai lá e volta o commit e o cara tem o trabalho de se virar para colocar as coisas no lugar.

Depois da fase da aceitação, vem a fase da dependência. Desligue o servidor de build por um dia. Se ninguém reclamar, você ainda não atingiu seu objetivo. Minha experiência mostra que uma vez que o time ganhe a maturidade para trabalhar com integração continua, ele não consegue mais trabalhar sem. É a regra básica para convivência no condomínio.

Vantagens e Desvantagens

Já fiquei me perguntando várias vezes as desvantagens de trabalhar com integração contínua, e não encontrei. Realmente é uma prática, na minha opinião, básica, essencial para qualquer projeto de desenvolvimento de software.

E as vantagens? São inúmeras. Algumas delas:

  • Você pode chegar todo dia para trabalhar, atualizar o código, tendo a certeza que o fonte vai compilar e você pode começar o seu dia
  • A automação de deploy se torna viável, pois você consegue construir sua aplicação, com qualquer versão, em qualquer momento do tempo
  • Suas dependências ficam mais fáceis de monitorar. Você conhecerá todos os binários de que depende, todas as ferramentas que precisa instalar para sua aplicação funcionar, e obviamente, se alguém quebrar a regra, descobrirá rápido (porque o servidor de build não tem o componente não-autorizado instalado)
  • Seu time vai aprender a subir código fonte somente quando as coisas estiverem minimamente estáveis. Os ganhos de produtividade nisso são enormes. Ninguém fica parado esperando o outro corrigir a besteira que fez no código
  • Você ganha um “evento” para fazer qualquer automação no momento que alguém subiu alguma alteração. Com isso, consegue fazer valer uma série de regras como: métricas, consistência de scripts de banco, formatação de código, e o que mais achar necessário

Era isso.

Controle de Versão – Parte 2

Introdução

Hoje recebi um e-mail do meu grande amigo e ex-colega de trabalho Godoy (@godoy42), mago supremo do tunning de queries e macumbas avançadas em banco de dados. Não acompanho de perto, mas fico imaginando o que ele anda aprontando na área de BI.

Ele viu os dados da pesquisa, baixou e montou dois gráficos muito bacanas com os resultados. Seguem abaixo.

Satifação com o sistema atual

Aqui, cada resposta com ferramenta atual igual a desejada, soma um ponto de satisfação, cada resposta diferente, um ponto de insatisfação. Resultado expresso em percentual:

GraficosGodoy1

Ferramenta desejada para substituição

GraficosGodoy2

GraficosGodoy3

Origem dos problemas por ferramenta

GraficosGodoy4

Controle de Versão – Resultados

Objetivo

Estou passando por um processo de avaliação de processos e ferramentas de SCM e ALM (pra variar um pouco, já que por praticamente todas as empresas que já passei, a discussão volta) e buscando referências sobre o assunto. Infelizmente a maioria das referências que encontrei falava somente sobre qual melhor controle de versão, sem comentar muito sobre tamanho de time, como organizar os branches, experiência da equipe, etc., além de não conter informações sobre algumas ferramentas mais novas.

Outra coisa que quis colocar na pesquisa é não só o que está sendo usado e como as pessoas trabalham atualmente, mas também o que elas gostariam de estar usando e como gostariam de trabalhar. Gostei do resultado.

De repente, pensei… por que não fazer uma pesquisa? Rapidamente consegui algumas respostas e informações bem interessantes que seguem abaixo. Os dados são os originais do Google Docs. Apenas não publiquei o link original, por que ao contrário da minha expectativa, a grande maioria se identificou com e-mail, que não acho correto republicar.

Perfil

No total, foram 140 respostas em uma semana de pequisa.

SCM2013-1

SCM2013-2

SCM2013-3

SCM2013-4

SCM2013-5

Freqüência de entrega e percepção de problemas

SCM2013-10

Ferramenta

SCM2013-6

SCM2013-7

SCM2013-11

SCM2013-12

Organização do repositório

SCM2013-8

SCM2013-9

Integração contínua e continuous delivery

SCM2013-13

SCM2013-14

SCM2013-15

SCM2013-16

Comparativo entre as ferramentas

SCM2013-17

  • Respostas válidas: Total de respostas diferentes de “Não conheço”.
  • Aprovação: Total de respostas “O melhor” e “bom” em relação às respostas válidas

Comentários da pesquisa

Durante a realização da pesquisa, surgiram alguns questionamentos que acho válido listar aqui.

Perfil de empresas

Colocar qual é o perfil da empresa que a pessoa trabalha (consultoria, fábrica de software, produto, etc), tamanho da empresa, etc.

Por que TFS está separado em várias versões?

Ao usar o TFS 2008 e conversar com pessoas que usam a versão 2012, muita coisa mudou, o que pode-se dizer que muda até o conceito da ferramenta (de lock para share, por exemplo). Por saber disso de antemão, separei as versões.

TFS é lock ou share?

Como o TFS tem uma idéia de lock “compartilhado”, deu essa confusão. No meu entendimento, se tem lock (mesmo compartilhado) e os arquivos ficam com aquele chatíssimo efeito de read-only pra poder usar (dizem que no 2012 isso foi melhorado), pra mim é lock. Por isso a pergunta é binária. Muitas pessoas consideraram share. Na próxima pesquisa, considero uma opção de “lock compartilhado”.

Opções entre bom e ruim

Optei por não manter, justamente pra fazer a pessoa sair do muro e a pesquisa ser mais assertiva.

Incluir cenários

Ótima idéia. Não tinha pensado nisso. Vou incluir nas próximas.

Razões para não mudar de ferramenta e organização

Essa eu mesmo achei que faltou, depois que mandei a pesquisa. Como vemos nos resultados, tem um gap muito grande entre o que as pessoas fazem e o que elas gostariam de fazer. Minha idéia é entender a razão deste gap: Política, Financeira (tempo/prazo), Preguiça, sei lá.

Incluir notas explicativas

Por incrível que pareça, muitas pessoas tiveram dúvidas sobre o que é integração contínua, continuous delivery, estressar um pouco as políticas de versionamento. É algo a melhorar.

Avaliar crashs

Incluir perguntas sobre ocorrência de quebras no controle de versão e a capacidade de recuperação de cada um deles.

Nível de conhecimento em cada ferramenta

Incluir se a pessoa se sente senior, junior ou pleno em cada ferramenta que avaliou. Gostei da idéia e vou incluir nas próximas.

ALM, critérios de gestão de releases (patch/full), etc.

Não pretendo incluir, não porque o assunto não merece, mas porque gostaria de manter essa pesquisa com a idéia apenas de controle de versão, porque acho que já tem assunto suficiente para debate aqui. Os outros, acredito merecerem tópicos só pra isso.

Links externos

Durante essa pesquisa, seguem alguns links que encontrei ou me enviaram (tks Beccari!) e acho válido compartilhar:

Dados

Caso alguém queira tirar/buscar outras conclusões, estou publicando o Excel com as respostas, sem os comentários e os e-mails. Segue o link: Controle de Versão-2013.

Agradecimentos

Meus sinceros agradecimentos a todos que participaram e contribuiram com a pesquisa. Não imaginava que conseguiria num tempo tão rápido um conjunto de respostas no mínimo representativo.

Pesquisa sobre Controle de Versão

Estou realizando uma pesquisa sobre o uso de controle de versão.

Peço 10 minutos do seu tempo para ajudar a melhor entender as ferramentas e práticas atualmente usadas. Peço que repliquem para seus amigos desenvolvedores para criarmos uma massa bem crítica de respostas.

Aceito sugestões!

Segue o link do Google Docs: https://docs.google.com/forms/d/1big0uigPO8sStHVRApOMptsnmLclIDaOWRWk5BsCXSQ/viewform

Levantamento de requisitos e SCRUM

Introdução

Um dos posts mais visitados no blog, já bem antigo, é sobre levantamento de requisitos.

Neste post, eu falo um pouco das técnicas que usava para levantar bons requisitos, sempre com uma visão muito focada em metodologias fortemente apoiadas em documentos.

O objetivo deste post é mostrar como a visão ágil me fez pensar de forma diferente em relação a requisitos.

Por que levantamento de requisitos?

Neste ponto, continuo mantendo meu ponto de vista do artigo anterior. Não adianta sair construindo algo que não se sabe exatamente o que é e ainda acredito que a grande maioria dos problemas na área de desenvolvimento de software está em compreender de forma errada os requisitos.

As técnicas de elicitação de requisitos servem para promover o exercício e o entendimento da necessidade do cliente antes de sair construindo.

No meu ponto de vista, nada disso muda nas metodologias ágeis.

As críticas à documentação exaustiva de requisitos

Todas as vezes que eu tentava implementar a filosofia descrita no artigo anterior sobre levantamento de requisitos, sempre sofria algumas críticas. As mais comuns eram:

  • Como você tem certeza que documentou tudo?
  • Como você tem certeza que nada vai mudar?

Minhas respostas mais comuns sempre foram: você não tem certeza que documentou tudo, mas vamos pensar que você conseguiu cobrir 70-80% das necessidades. É bem melhor do que os 0% que tinha antes.

Sobre as mudanças, não existe certeza. A técnica apenas permite melhor rastreabilidade das mudanças. Sempre que um requisito muda, o exercício de replanejar e rediscutir o escopo deve ser realizado.

A receptividade à mudança no modelo Waterfall

Quando trabalhamos no modelo waterfall, geralmente todo o levantamento de requisitos vem antes. No início do projeto, mas após ser dada a data do final do projeto. Sabemos que temos, por exemplo, 2 meses pra levantar requisitos, que o projeto deve ser entregue em 6, mas ainda não fizemos um levantamento exaustivo de requisitos.

Nessa fase, fazemos o levantamento exaustivo e colocamos num grande documento, ou em um grande conjunto de casos de uso e pedimos a validação do cliente. O cliente valida, muitas vezes sem se aprofundar nos documentos e sem ver absolutamente nada construído.

Começamos a fase de construção e geralmente quando começamos a mostrar algo funcionando para o cliente, já estamos próximos do fim da fase de construção, ou no início da fase de homologação.

Não é raro neste momento observamos a reclamação clássica do cliente: “preciso de mudanças, porque quando vemos o software materializado é que começamos a entender o que realmente precisamos”.

Isso gera um tamanho desconforto na equipe. O desconforto ocorre porque o exercício de mudança no modelo cascata é muito grande. Sempre que um requisito muda, deveríamos rever o cronograma e renegociar o prazo. Toda mudança no cronograma deveria passar por esse ciclo de reestimar, reamarrar todas as dependências das atividades e renegociar um prazo, mas tudo isso é tão lento e as mudanças são tão constantes no escopo dos projetos que é muito fácil perder o controle. Acabamos deixando a maioria das coisas registradas como “issues” ou “melhorias” que ou entrarão numa fase 2, ou acabam sendo construídas no meio da fase de teste.

O que acontece é que a data final, cravada em pedra. raramente muda e as mudanças de escopo são absorvidas ao custo de um alto stress e baixa qualidade.

Trabalhando por contrato

O grande risco que se corre ao utilizar uma documentação de requisitos extensa é trabalhar por contrato. Na prática, o cliente pede uma mudança e logo a equipe aponta: Mas nós fizemos o que está no documento e você aprovou.

Na minha opinião, é justo. A equipe fez o trabalho dela, mas o cliente é atendido desta maneira?

Qual é nosso objetivo? Não errar ou atender o cliente?

Incertezas

A grande sacada do SCRUM está em lidar melhor com as incertezas. E existe algo mais incerto do que um projeto de software? A maioria dos novos softwares desenvolvidos são inovações tecnológicas, uso de tecnologias modernas, implementar um novo processo, uma nova forma de trabalho.

No modelo cascata, partimos do princípio que sabemos tudo de antemão. É algo como querer prever o futuro. Uma grande verdade é desprezada durante o decorrer do projeto: As pessoas aprendem sobre o domínio de negócio, sobre o produto durante o projeto.

Se logo durante as primeiras entregas, conseguimos identificar que muitas das premissas tanto de negócio quanto de TI estavam erradas, por que insistimos em continuar pelo mesmo caminho? Por que não aproveitamos esse feedback para corrigirmos a rota?

Como o SCRUM lida com incertezas

Quando fazemos uma estimativa de complexidade (planning poker) para entregar uma estória, não temos lá muitos detalhes sobre o que de fato ela faz, tampouco como construiremos ela.

Quando essa história entra numa sprint, é que começa o trabalho de levantamento de requisitos, porém, ele é específico para aquela história, para aquela funcionalidade. Não é um exercício longo e extenso para todo o projeto. Se nesse momento, observamos que a estória não está madura o suficiente para ser construída, temos a chance de repriorizar, ou mesmo de definir a meta da sprint como um protótipo ou uma prova de conceito para definir uma melhor abordagem para lidar com esse requisito.

Neste momento, as mesmas técnicas utilizadas para a elicitação de requisitos podem ser usadas, porém, o foco é somente para aquela estória.

Muitas pessoas confundem e acham que o modelo ágil significa desprezar toda a técnica e a documentação e sair aplicando um eXtreme Go Horse. Na verdade o que a metodologia propõe é um constante exercício de valor e priorização: O que é mais importante ser feito primeiro? O que retorna mais valor para o cliente?

Se percebemos que o que retorna mais valor para o cliente é o requisito mais incerto, devemos sim começar a construí-lo primeiro, para rapidamente ajudarmos o cliente a materializar a idéia e assim dar o conforto necessário para continuar a construção ou partir para uma abordagem diferente.

Com isso, entrega-se dentro do horizonte de uma sprint um produto funcionando e a melhor materialização dos requisitos existentes, dadas as restrições de prazos e recursos. Para a próxima sprint, qualquer alteração é bem-vinda. Os requisitos são revistos, são aprimorados e o feedback é constantemente utilizado para construir um melhor produto. Dessa forma, o SCRUM aproveita o aprendizado que ocorre durante o projeto.

Que artefatos usar para documentar?

Particularmente eu acho uma documentação de produto mais valiosa do que uma documentação de projeto. Qual a diferença entre as duas coisas?

A documentação de produto, descreve como o software, a aplicação funciona. Ela é mantida e evolui junto com o software. A documentação de projeto, geralmente um caso de uso, representa o que precisa ser feito naquele projeto. Terminou o projeto ela tem pouco valor, porque a linguagem nela utilizada está muito longe de ser uma documentação de sistema.

O ponto é, se os ciclos são extremamente curtos (2 a 4 semanas) pra fazer um produto funcionando, precisamos de uma documentação de projeto?

Muitas vezes documentações simples e rápidas como protótipos feitos à mão, uma descrição da funcionalidade na página wiki é mais que suficiente para atingir o objetivo da sprint.

A prática que acho válida é sempre manter em cada sprint atividades para realizar a documentação de produto, ou seja, pegar as principais abstrações e questões técnicas e documentar e também criar o manual do usuário para a nova funcionalidade construída. A melhor ferramenta pra isso na minha opinião é a Wiki, por sua natureza rápida e colaborativa.

Conclusão

A única certeza que eu tenho em projetos de software é que os requisitos mudam. Continuo achando que as técnicas de elicitação de requisitos são importantíssimas para construir software com melhor assertividade, mas defendo que a documentação não deve ser mais importante do que software funcionando.

A própria filosofia de constante inspeção e receptividade às mudanças ajuda a mitigar o risco dos requisitos mal levantados, da documentação ambígua e da dificuldade de comunicação nos projetos. O principal benefício, na minha opinião, é utiliza o aprendizado que ocorre durante o projeto.

A ausência de “time boxes” na metodologia waterfall e o trabalho por contrato ajudam a perpetuar e insistir em requisitos que já não traduzem a necessidade do usuário, contribuindo para a criação de um ambiente de trabalho de conflitos e desprezando todo o aprendizado que é adquirido durante um projeto.

Enfim, Ágil

Introdução

Lembro a primeira vez que tive contato com alguma metodologia ágil, em 2004. Naquele momento, a primeira prática que mais me trouxe resultado foi a integração contínua, mas o XP também falava de uma série de outras coisas. No meu post sobre TDD, relatei as tentativas de incorporar esta prática e o tempo que levou até entender o real benefício dela (no meu caso, fui bem cabeça-dura).

Das práticas mais confusas, a parte de estimativas do XP talvez seja a mais revolucionária. Mudar a idéia que eu já tinha na na época de cronograma, planejamento, levantamento detalhado de requisitos era muito pra mim. Quando eu percebi que a metodologia não mais daria aquela “certeza” de que a coisa aconteceria no prazo, logo conclui que o mercado nunca iria aderir àquilo, pois o modelo de comercialização seria muito complexo de implementar. Mas sempre achei que a XP descreve o que melhor funciona no mundo de software.

7 anos depois

Um bom tempo depois, numa das empresas que trabalhei, houve uma tentativa de implementação de SCRUM. Eu tinha lido muito pouco sobre o assunto, dada a conclusão precipitada que tive sobre a dificuldade de comercialização do SCRUM. Como trabalhava numa empresa que tinha um viés de desenvolvimento de produto (apesar das customizações por muitas vezes fazerem a empresa funcionar como uma consultoria que vendesse projeto a preço fechado), havia uma preocupação com a qualidade e com o acompanhamento dos projetos.

Foi engraçado ver tanta parede com post-it pregado e diversas reuniões, mas não consegui ver o entusiasmo nas pessoas e uma mudança cultural profunda. Depois entendi o que aconteceu. Foi mais uma daquelas situações que um processo é totalmente personalizado pra encaixar na empresa, o processo não funciona e posteriormente a coloca-se a culpa no processo. No Scrum inventaram até um nome pra isso: Scrum, but…

E o cascata, funcionava?

Eu sempre fui um defensor de trabalhar com projetos em fases menores no cascata (entregas menores e mais frequentes) e de levantamento de requisitos exaustivos (meu post, levantamento de requisitos mostra isso) e de fato consegui alguns resultados interessantes, mas minha experiência me mostrava que algumas coisas não iam tão bem assim.

O custo para montar um cronograma completo no início do projeto era enorme. Era de semanas a meses pensando atividade a atividade, quem iria fazer. Não foram poucas as vezes que “desenvolvedores virtuais” aparecerem no cronograma, contando que em algum momento eles seriam contratados, treinados e entrariam no projeto funcionando e jogando.

Sempre que alguma mudança ocorria no meio do projeto (o que acontecia com frequência, porque em desenvolvimento de software, requisitos mudam!) era um esforço enorme pra replanejar.

Numa empresa que trabalhei, percebi uma das maiores falhas do levantamento de requisitos exaustivo no início do projeto. Eu trabalhava para capturar os requisitos, escrevia casos de usos detalhados e os usuários validavam. Quando apresentávamos o software, ficava uma discussão quase contratual, pois o cliente alegava que não era o que ele precisava e o desenvolvedor alegava que era o que estava escrito. O cliente desejava melhorias no que foi entregue, porém, não existia o “espaço” no cronograma para tais melhorias, e voltava a discussão ao cronograma. Os dois estavam certos. Nova mudança, novo replanejamento, o que significava outro ciclo infinito de reuniões para discutir o cronograma, alinhar expectativas.

De fato, foram semanas discutindo cronograma e o que fazer. O tempo de alinhamento versus construção era desproporcional. Perdia-se mais tempo discutindo o que fazer do que de fato fazendo. O resultado final foi uma equipe frustrada, a consequente debandada e a paralisação de uma série de projetos.

Enfim, ágil

Recentemente, após uma mudança de área, passei a trabalhar com a abordagem ágil. Começamos com mais um Scrum But, ou seja, aplicando o Scrum somente na fase de desenvolvimento do projeto, e aos poucos fomos mudando para um modelo de inserir o usuário nas reuniões, nas discussões e estimulá-lo a exercer um papel de product owner.

Confesso que eu comecei meio cético no projeto, remetia ao passado, à brincadeira de mexer post-its e não conseguia entender como isso ia me ajudar a fazer software melhor. Eu recebi um release planning praticamente pronto, e fomos para nosso primeiro sprint planning.

Foi um pouco assustador, um time, pela primeira vez junto, num projeto que poucos tinham contexto discutindo como as coisas seriam feitas. Não conhecíamos o método, não conhecíamos o projeto e fomos com a cara e a coragem. Discutimos as atividades, estimamos (de forma errada, pois não tínhamos as estórias estimadas e sim as atividades). Erramos nas estimativas das primeiras sprints, mas já começávamos a observar as dificuldades. Na retrospectiva, apontamos as dificuldades, melhoramos o processo e assim seguimos.

Oito sprints depois (sprints e 2 semanas), já trabalhamos como um time muito mais integrado, estamos acertando nas estimativas (usamos os dados históricos), estimamos as estórias e o time está bastante satisfeito com o design da aplicação. Estamos fazendo TDD, Integração contínua, pair programming pois conseguimos nos organizar para tal, e apesar de um “Scrum but”, a coisa vem caminhando muito bem. O que de fato mudou?

Aprendizados

Pessoas

Trabalhar em equipe não é uma coisa fácil. As cerimônias de sprint planning, daily meetings e retrospectivas promovem isso. É praticamente impossível chegar ao fim dessas cerimônias sem interagir. Os medos, dificuldades aparecem e a sujeira sai debaixo do tapete.

Inspeção

O Burndown Chart e o Kanban, são ferramentas fantásticas. Elas vão constantemente mostrando ao time o seu progresso. Como foi o time quem deu as estimativas e se comprometeu com a sprint, o incômodo é generalizado. Enfim, o processo promove uma forma de você visualizar o que realmente está acontecendo, ou seja, se o time irá conseguir atingir a meta ou não.

O Kanban, recomendo que seja físico. Ele exerce uma pressão psicológica violenta quando mostra o real andamento do projeto, além do fato de ajudar a definir o foco, ou seja, estar constantemente mostrando o que é prioritário e o que precisa ser feito.

Após essas sprints, adotei o Kanban como ferramenta de planejamento para outras atividades (não relacionadas a desenvolvimento de software). É muito mais fácil visualizar e ajustar prioridades movendo post-its do que num Excel, além do fato que o Kanban é compartilhado e evita aquele tipo de reunião em que a equipe fica batendo papo enquanto uma das pessoas fica digitando no Excel.

Empowerment

O processo do Scrum ajuda a equipe a obter compromissos que pode cumprir e a dar poder para o time trabalhar. Isso automaticamente faz com que o time busque os recursos para ser mais performático.

Daily Meetings

Os daily meetings são muito importantes. Primeira regra é realizá-los dentro do tempo (15 minutos). Os grandes benefícios do daily meeting pra mim estão no alinhamento e no “pega-lazy”. Quando existe um membro do time que não está jogando para o time, o daily meeting rapidamente mostra isso. A pessoa fica enroscada na atividade e não apresenta o impedimento no daily meeting durante, 3, 4, 5 dias. Geralmente no último dia vem com alguma desculpa do porque não conseguiu realizar a atividade.

Peer pressure

No Scrum, não existem adividades com “donos”. O time é dono das atividades, logo, o time atinge o objetivo ou não. Nesse cenário, começa a ocorrer a pressão entre os pares. No cenário que eu citei acima, do daily meeting, o próprio time começa a se mobilizar para expelir o descomprometido do time. Assim como na existência de um problema, o time começa a buscar os mecanismos para resolvê-lo, pois ninguém quer perder a meta.

Líder facilitador

Observamos que num ambiente ágil não cabe a figura tradicional do chefe. Com o time se auto organizando e entendendo seu papel, o líder passa a ser um facilitador. Ele precisa desenvolver grande habilidade para mediar conflitos, buscar recursos e criar condições para o time trabalhar.

Datas?

É interessante ver como a metodologia tradicional de gestão de projetos criou uma cultura de cravar datas na pedra e exercer cobranças até que a atividade saia no prazo (usando a qualidade para pagar o preço). O Scrum, quando transparece as atividades que precisam ser realizadas, o esforço estimado e a velocidade do time, começa a trazer mais ferramentas que ajudam a visualizar se o objetivo será ou não atingido.

Aquelas situações que visualizamos do gerente perguntar o percentual de conclusão da tarefa, e este percentual ser reportado na progressão: 50%, 75%, 90%, 91%, 92%, 93%, 93.5% perdem o sentido. Como disse anteriormente, o Scrum tira a sujeira debaixo do tapete.

O que começa a ficar evidente é que o responsável pelo projeto precisará de fato aprender a negociar prioridade, escopo ou recursos para a realização do projeto, invés de somente exercer cobranças.

Mudanças de escopo

Mudanças de escopo são bem-vindas. No Scrum, a priorização do backlog simplifica a inclusão de novas estórias e repriorização das mesmas, assim como simplifica a medição dos impactos causados pela mudança. Se você ainda tem uma data alvo, e as mudanças não param de acontecer, o Scrum sem dúvida irá ajudá-lo a mostrar as mudanças e repriorizações e também quando estas impedirão a data alvo de serem atingidas.

Exercício constante de valor agregado

O processo, quando começa a sensibilizar os envolvidos do que está acontecendo (transparência) e mostrar o real custo para se entre

Conclusão

Se você tentou implementar o Scrum em seu time e fracassou, certifique-se se você não entortou o processo. Minha experiência com TDD e com Scrum me mostram isso.

Apesar de ainda estar trabalhando com uma variação de Scrum, é muito interessante observar como o jeitão de trabalhar é muito mais alinhado com trabalho em time, o que nos mostra que a soma dos membros do time é muito maior do que a contribuição individual. Transparência também é um valor fortemente promovido pelo processo.

O processo é feito de uma forma que promove a melhoria contínua, o que me faz acreditar que ainda há muito o que aprender e aprimorar, mas os resultados imediatos são evidentes.

O processo obviamente não ensina as pessoas a desenvolver software. É importante se apoiar em outras disciplinas e processos de engenharia de software (como as práticas de XP) para obter bons resultados. Também não é a bala de prata e vai resolver todos os problemas, mas de uma coisa eu não tenho dúvida: o Scrum cria um ambiente muito mais favorável à resolução de problemas, ao trabalho em equipe e ao desenvolvimento de software de qualidade.

Outra coisa muito importante é que cada vez mais processos ágeis como o Scrum e o Lean estão sendo adotados para desenvolvimento de produtos e não só por empresas de software.

Parece que realmente o Scrum veio para ficar. A discussão ainda de qual processo é melhor, waterfall ou Scrum para gestão de projetos (não somente projeto de software) ainda será muito promovida, e até aqui minha opinião é muitos simples: Projetos com alto grau de certeza terão melhores resultados com waterfall. Projetos com alto grau de incerteza terão melhores resultados com ágil.

Seguir

Obtenha todo post novo entregue na sua caixa de entrada.

Junte-se a 414 outros seguidores

%d blogueiros gostam disto: