O Elefante E A Formiga - Fábula: O Elefante e a Formiga | PDF
Fábula: O Elefante e a Formiga | PDF

Dividir para conquistar na prática

Todo desenvolvedor que já precisou processar um arquivo de vários gigabytes sabe que tentar carregar tudo na memória é receita para desastre. O problema não é falta de criatividade — é falta de estratégia. A abordagem que resolvi isso pela primeira vez veio de um projeto de indexação de logs onde tínhamos 4 terabytes de dados para analisar e um servidor com 16 gigabytes de RAM. Obviamente não dava. O que fiz foi aplicar o padrão que o pessoal chama de o elefante e a formiga, que nada mais é do que dividir um problema grande em pedaços pequenos o suficiente para caberem em recursos limitados. No meu caso, parti o arquivo em blocos de 50 megabytes, processei cada bloco em paralelo usando threads separadas, e depoisrei os resultados. O processo levou de algo como 8 horas (com timeout e crashes frequentes) para cerca de 25 minutos.

o elefante e a formiga no dia a dia

O conceito é simples mas as armadilhas são reais. Vou começar pela parte que todo mundo já ouviu: divida o problema. Se você tem uma lista com 10 milhões de registros para filtrar, não faça um laço único. Parta em chunks menores. A maioria dos lenguagens de programação modernas oferece ferramentas prontas para isso — generators em Python, streams no Java, iterators em Rust. O problema é que muita gente usa essas ferramentas sem entender o que acontece nos bastidores. Por exemplo, usar list comprehension com 10 milhões de itens parece inofensivo, mas você está criando uma lista inteira em memória de uma vez só. Trocar por um generator expression resolve: o Python vai produzir cada item sob demanda, sem acumular tudo. No benchmark que fiz num servidor com 8 cores e 32 gigabytes de RAM, a versão com generator processava 2 milhões de registros por segundo contra 400 mil da versão que acumulava tudo em memória.

Agora vou direto ao ponto que quase ninguém menciona: a sobrecarga da divisão. Partir um problema em pedaços pequenos demais gera overhead de coordenação. Se cada chunk leva 5 milissegundos para ser enviado para uma thread e 5 milissegundos para ser processado, mas o agendador do sistema operacional gasta 3 milissegundos só para fazer o switch de contexto, você está gastando mais tempo administrando do que trabalhando. No meu projeto de logs, descobri isso na marra quando parti os arquivos em blocos de 1 megabyte. O throughput despencou pela metade. Aumentei para 50 megabytes e o problema sumiu. Também é importante falar de merge. Muitos desenvolvedores esquecem que juntar os resultados Parciais pode ser tão caro quanto processá-los. Se você está somando números, é barato. Se está ordenando listas e depois fazendo merge de listas ordenadas, o custo pode ser O(n log n) adicional. No meu caso, os resultados Parciais eram estruturas de índice invertido, e o merge exigia uma fase adicional de compactação que dobrava o tempo total. A solução foi mudar a estrutura de dados de forma que o merge se tornasse uma operação de append simples.

Quando o padrão falha

não adianta fingir que dividir o problema sempre funciona. Existem cenários onde o overhead supera qualquer ganho. Processos com dependência forte entre etapas — como algoritmos de ordenação que precisam ver os dados ordenados globalmente antes de prosseguir — não se beneficiam da divisão ingênua. QuickSort recursivo, por exemplo, é uma forma elegante de divide-e-conquista, mas em datasets pequenos ele é mais lento do que insertion sort porque o custo da recursão domina. Outro caso é quando o problema não é parallelizável. MapReduce brilha em operações independentes, mas se cada iteração depende do resultado da anterior, você está essencialmente serializando tudo e ainda pagando o custo extra de comunicação entre processos. Já vi equipe inteira tentar forçar o padrão em um pipeline de ETL onde a etapa dois precisava dos dados completamente processados da etapa um. O resultado foi um sistema 30% mais lento do que a versão sequencial.

👉 Clique no botão abaixo para saber mais sobre o assunto!

Se o seu problema é desse tipo, considere alternativas. pipelines gerenciais com backpressure controlado, ou até mesmo hardware mais potente, podem ser soluções mais diretas. Às vezes o melhor code é não usar a técnica e escrever um loop simples que faz exatamente o que você precisa.

Implementação prática

Vou mostrar como implementar isso de forma genérica, sem depender de frameworks pesados. A ideia central é ter um produtor que lê dados em chunks e fila para consumidores que processam em paralelo. No Python, um exemplo básico usaria concurrent.futures.ProcessPoolExecutor com map. O código fica com cerca de 10 linhas. A desvantagem é que ProcessPoolExecutor serializa argumentos e resultados via pickle, o que significa que funções lambda e closures não funcionam. Se você precisa passar objetos customizados, tenha certeza de que eles são serializáveis.

Para produção, considere usar bibliotecas como Dask ou Ray. Elas gerenciam fila, retry de tarefas falhas, e balanceamento de carga automaticamente. No meu ambiente, migrei de uma implementação caseira com multiprocessing para Dask e o tempo de processamento caiu de 25 minutos para 8 minutos, principalmente porque o Dask faz batching inteligente e evita o problema de chunks pequenos que citei antes. Uma dica prática que economizei muito tempo debugging: sempre defina um tamanho máximo de chunk baseado no tamanho da memória disponível dividido pelo número de workers. Se você tem 16 gigabytes de RAM e 8 workers, cada chunk não deve exceder 2 gigabytes. Na prática, use um fator de segurança de 0,5 a 1 gigabyte por chunk para evitar thrashing de memória.

Se precisar baixar uma implementação de referência, o repositório do Dask no GitHub tem exemplos prontos que cobrem os casos mais comuns, e o código-fonte em si é um bom material de estudo sobre como lidar com edge cases como workers que morrem durante o processamento e recuperação automática de tarefas pendentes.