DEV Community

Tulio Calil
Tulio Calil

Posted on

Desmistificando Concorrência e Consistência em Sistemas Distribuídos

Recentemente, como parte do meu mestrado em Computação Aplicada na UTFPR, mergulhei num artigo chamado "Comprehending Concurrency and Consistency in Distributed Systems" (Nitin Naik, IEEE ISSE 2021). A discussão foi bem legal e, como já tem um certo tempo que não posto nada, decidi trazer isso para cá. Vamos falar um pouco sobre concorrência e consistência e ver alguns exemplos aplicados ao mundo real.

Concorrência vs. Paralelismo (Não, não são a mesma coisa)

Concorrencia VS Paralelismo

  • Concorrência: É sobre lidar com várias coisas ao mesmo tempo. As tarefas se sobrepõem no tempo (estão em andamento), mas não rodam necessariamente no exato mesmo milissegundo. O objetivo aqui é esconder a latência.

  • Paralelismo: É sobre fazer várias coisas no exato mesmo milissegundo. O objetivo é aumentar o throughput computacional, e isso exige hardware (múltiplos núcleos).

De uma forma mais simples: concorrência é iniciar várias coisas, mas ir fazendo um pouco de cada. Quando a CPU fica ociosa esperando algo (como uma requisição de rede), eu troco o contexto e vou para outra tarefa, fazendo isso o tempo todo. Imagina você tentando fazer o almoço e faxinar a casa: você coloca uma panela com água para ferver; enquanto ela ferve, você pega a vassoura e começa a limpar; quando a água estiver fervendo, você para de varrer e coloca o macarrão, e assim por diante.

Já o paralelismo depende necessariamente de hardware. Aqui, nós vamos pegar todo o sistema (ou partes dele) e executar em outro core da CPU. Com isso, conseguimos executar as coisas no mesmo milissegundo. Voltando à analogia das tarefas domésticas, aqui é como se você tivesse um clone seu (ou outra pessoa) para te ajudar: você começa a fazer o almoço, e, no exato mesmo instante que você começou a cozinhar, a outra pessoa começa a varrer a casa.

Vamos para um exemplo prático disso com Node.js. Vou criar uma função com um loop que gira 2 bilhões de vezes. Em cada volta, ela pega a variável sum e soma com o valor de i. A ideia é apenas criar uma função que gere uma alta carga de processamento na CPU (uma tarefa CPU-bound).

Vamos ao primeiro código. Vamos rodar isso de forma sequencial:

const ITERATIONS = 2_000_000_000; 

function heavyCPUTask() {
    let sum = 0;
    for (let i = 0; i < ITERATIONS; i++) {
        sum += i; 
    }
    return sum;
}

console.log("Sequentially (One after the other)...");
console.time("Sequential Time");

heavyCPUTask(); 
heavyCPUTask(); 

console.timeEnd("Sequential Time");
Enter fullscreen mode Exit fullscreen mode

Rodando isso, temos: Sequential Time: 3.250s

Agora, vamos tentar fazer isso usando concorrência:

const ITERATIONS = 2_000_000_000; 

function heavyCPUTask() {
    let sum = 0;
    for (let i = 0; i < ITERATIONS; i++) {
        sum += i; 
    }
    return sum;
}

async function startTest() {
    console.log("Concurrently (Promises on the same Thread)...");
    console.time("Concurrent Time");

    await Promise.all([
        new Promise(resolve => { heavyCPUTask(); resolve(); }),
        new Promise(resolve => { heavyCPUTask(); resolve(); })
    ]);

    console.timeEnd("Concurrent Time");
}

startTest();
Enter fullscreen mode Exit fullscreen mode

Sim, as Promises são a forma padrão de rodar código em concorrência no JS. Para esse código, temos o seguinte resultado: Concurrent Time: 3.191s.

Podemos perceber que o ganho de tempo aqui foi quase nulo em comparação ao sequencial, mas por quê? Lembra que eu falei que a gente "mascara a latência" quando a CPU fica ociosa? Então, nessa função a CPU está fritando fazendo cálculos. Não há pausas. O Event Loop (a thread principal do Node) fica totalmente bloqueado. O único ganho real aqui foi disparar as tarefas de uma vez.

Caso essa função fosse algo como uma requisição HTTP ou um acesso a um banco de dados (onde o Node apenas "espera" a resposta), teríamos uma diferença absurda de tempo entre o sequencial e a concorrência.

O Node.js, por padrão, trabalha em uma única thread, mas ele tem total suporte para mudarmos isso. Vamos usar o módulo nativo Worker Threads, para atingir o paralelismo real:

const { Worker, isMainThread, parentPort } = require('worker_threads');

const ITERATIONS = 2_000_000_000; 

function heavyCPUTask() {
    let sum = 0;
    for (let i = 0; i < ITERATIONS; i++) {
        sum += i; 
    }
    return sum;
}


if (!isMainThread) {
    const result = heavyCPUTask();
    parentPort.postMessage(result);
} else {
    function runInWorker() {
        return new Promise((resolve) => {
            const worker = new Worker(__filename);
            worker.on('message', resolve);
        });
    }

    async function startTest() {
        console.log("Parallel (Multi-Core)...");
        console.time("Parallel Time");

        await Promise.all([
            runInWorker(),
            runInWorker()
        ]);

        console.timeEnd("Parallel Time");
    }

    startTest();
}
Enter fullscreen mode Exit fullscreen mode

O código ficou um pouco mais complexo, mas agora conseguimos ter um processo principal (main) que pede ao sistema operacional para criar um novo Worker para cada tarefa (nesse caso, duas), alocando-as em núcleos físicos diferentes do processador.

Com isso, temos o seguinte resultado: Parallel Time: 1.661s.
Uma quebra de tempo absurda em comparação com as outras duas abordagens.

Resultado concorrencia vs paralelismo

O Pesadelo da Consistência

Se a concorrência é gerenciar o tempo da CPU, a consistência é gerenciar a "verdade" dos dados entre várias máquinas. E manter todo mundo concordando custa caro e é um trabalho árduo.

consistencia

Imagine um banco de dados distribuído entre vários nós. Sempre que recebemos uma nova alteração (como o a=10 na imagem acima), precisamos replicar esse novo valor para os demais nós da rede.

Isso cria um grande dilema arquitetural: enquanto essa replicação acontece pela rede, o que fazemos se uma nova consulta bater em um nó que ainda está desatualizado? Nós liberamos o acesso a esse dado velho ou travamos a leitura até que tudo esteja sincronizado?

Pensando nisso, o autor do artigo discute duas visões principais de consistência:

  • Consistência Forte: A atualização trava as consultas até que todos os nós do sistema estejam com o dado idêntico. O foco é a exatidão. Exemplo: O sistema bancário via PIX. Ninguém quer ver um saldo desatualizado. O sistema prefere ficar mais lento (latência maior) do que entregar um dado errado.

  • Consistência Fraca / Eventual: A atualização é aceita, o sistema devolve o "OK" na hora e sincroniza o resto dos servidores no background. O foco é a alta performance, assumindo o risco de o sistema retornar um dado desatualizado (stale data) temporariamente. Exemplo: As visualizações do YouTube. Se você ver "10.000 views" no servidor do Nordeste e seu amigo em São Paulo ver "9.500" no mesmo segundo, não tem problema ler um dado desatualizado (stale data). Eventualmente, os dois servidores se alinham.

O Foco da Consistência

Além de pensarmos no tempo (quando a sincronização acontece), o artigo traz uma outra pergunta para a arquitetura: o escopo. De quem é a perspectiva de consistência que realmente importa no nosso sistema?

consistencia

Para responder a isso, a arquitetura se divide em duas categorias:

  • Consistência Centrada em Dados (System-Wide): Aqui, o foco é o banco de dados e o sistema como um todo. A regra é: qualquer cliente, de qualquer lugar do mundo, que acessar o sistema precisa enxergar as operações acontecendo exatamente na mesma ordem. O problema disso é que como a atualização precisa ser garantida em todas as réplicas do sistema, a sobrecarga (overhead) operacional e de rede é altíssima. É a escolha ideal para sistemas com alta concorrência de escritas. Pense no Google Docs. Se você e um colega digitam no mesmo parágrafo ao mesmo tempo, o sistema precisa ordenar essas operações e forçar que a tela de todos os 10 usuários conectados no documento mostre as letras aparecendo na exata mesma ordem no mesmo milissegundo. O mundo inteiro enxerga a mesma "fonte da verdade".

  • Consistência Centrada no Cliente (Client-Specific): Aqui, nós tiramos o peso das costas do sistema global e focamos na sessão individual do usuário. O sistema garante a consistência apenas para a pessoa que acabou de realizar a ação. Diferentes clientes podem ver a ordem das atualizações de formas diferentes temporariamente. Claramente isso reduz drasticamente a sobrecarga dos servidores, pois o sistema não precisa coordenar uma atualização global imediata. É perfeito para sistemas de leitura massiva. Um exemplo é a foto de perfil do WhatsApp, se você atualiza a sua foto, o aplicativo se comunica com o servidor e garante que a sua tela exiba a foto nova imediatamente. Para você (o cliente), a consistência é perfeita. Mas o sistema não força essa replicação imediata no celular dos seus amigos. Eles podem continuar vendo sua foto antiga por mais algum tempo. A arquitetura prioriza a experiência do cliente ativo sem "fritar" a rede global.

O Preço da Perfeição

O autor deixa uma coisa muito clara: NÃO EXISTE BALA DE PRATA. Nem todo sistema distribuído precisa/deve ter um nível perfeito de concorrência e consistência forte. O custo disso é inviável na maioria dos projetos.

Considerando o custo absurdo da consistência forte em sistemas distribuídos sob alta carga, fica a provocação que encerrou meu seminário no mestrado:

Até que ponto nós, como engenheiros, podemos sacrificar a exatidão e entregar dados temporariamente desatualizados (stale data) em nome da performance, sem quebrar a confiança do usuário final no produto?

E você, no seu projeto atual, está pagando o preço da consistência forte ou abraçou o caos controlado da consistência eventual?

Top comments (0)