O que aprendi na Rinha de Backend 2024 Q1


TL;DR Sempre há algo a se aprender e nesse caso foi SQL e banco de dados. Repositório onde implementei a solução descrita.

Neste primeiro post em meu recém nascido blog, gostaria de falar sobre os aprendizados que tive nesta segunda edição da Rinha de Backend. A Rinha de Backend é um pequeno desafio para implementar uma arquitetura com um balanceador de carga, duas instâncias de API e um banco de dados. Na segunda edição, o desafio foi focado em controle de concorrência.

Controle de concorrência

ChatGPT da OpenAI nos diz: Se refere às técnicas e mecanismos usados para garantir que múltiplos processos ou threads possam acessar recursos compartilhados (como dados, variáveis ou arquivos) de forma consistente e ordenada.

Wikipédia nos diz: Controle de concorrência garante que resultados corretos são gerados em operações concorrentes, enquanto retorna estes resultados o mais rápido possível.

Estes conceitos nos ajudam a entender o que seria avaliado nesta edição do desafio. Como a arquitetura da Rinha de Backend exige que tenhamos duas instâncias da API, as duas APIs podem tentar acessar e alterar os mesmos recursos ao mesmo tempo, um desenvolvedor com pouca experiência, fácilmente iria encontrar estados que não fazem nenhum sentido lógico, ou bugs “inexplicáveis”... Eu achei uma ideia incrível!

Uma forma muito simples de demonstrar um caso desse problema é escrevendo um programa que possui dois processos que modificam a mesma variável. Abaixo, um exemplo simples em C:

// gcc main.c -lpthread  && ./a.out
#include <stdio.h>
#include <pthread.h>

int number = 0;
void* add_one(void*) {
    for (int i = 0; i < 100000; i++)
        number++;
    return NULL;
}

int main() {
    pthread_t thread_1;
    pthread_create(&thread_1, NULL, add_one, NULL);

    pthread_t thread_2;
    pthread_create(&thread_2, NULL, add_one, NULL);

    // Wait threads to finish
    pthread_join(thread_1, NULL);
    pthread_join(thread_2, NULL);

    printf("Number: %d\n", number);
    return 0;
}

Atualmente trabalho como engenheiro de sistemas, boa parte do meu trabalho é lidar com problemas parecidos com o que a Rinha propõe. Incontáveis vezes já me deparei com bugs onde processos tentam ler e escrever partes da memória ao mesmo tempo, com funções que bloqueiam um recurso e nunca liberam, ou com dessincronia entre processos durante uma comunicação.

Quando li sobre o que seria o desafio, achei super interessante. Pois, dificilmente um desenvolvedor em início de carreira teria contato com este tipo de problema, ou se tivesse, seria resolvido por algum colega mais experiente, com o que chamo de mágicas do sênior (tópico para post futuro).

O bom, velho e sujo C

Como na edição passada, eu decidi implementar minha solução em C. Hoje a maior parte dos meus projetos são feitos em Python, C++ 11 e JavaScript. Não que eu ame essas linguagens, acredito que elas têm muitos problemas, especialmente C++ e JavaScript, mas elas são as que eu tenho maior afinidade.

Minha vontade de fazer a solução em C (não C++) veio única e exclusivamente do desafio de fazê-lo. Em C, você dificilmente utiliza bibliotecas externas e se for usar, você provavelmente terá que compilá-las. Já na biblioteca padrão C, dificilmente algo é alocado dinamicamente sem que você saiba, logo não há tipos dinâmicos, como listas, strings, dicionários, se você precisa de um deles, você terá que implementá-los (ou você está usando a linguagem errada).

Veja que isso não é uma deficiência da linguagem, a linguagem é assim por design, pois até hoje temos atualizações (o último padrão é o C17 com C2x em teste) e não há planos de termos tipos dinâmicos. C é feito para ser simples.

Isso faz com que o básico de C seja fácil de aprender. Se você é um programador, não levará mais que uma tarde para você dominar toda a sintaxe e ser capaz de implementar algo útil. O difícil de C não é a sintaxe ou as ferramentas da linguagem, mas sim entender como sua memória será lida, acessada e escrita.

Em um programa em C um pouco mais sofisticado, vez ou outra você estará se perguntando: “O que o compilador vai fazer com isso?” (o compilador nem sempre será seu amigo). Casos não definidos podem ser um problema (tópico para post futuro). Pode acontecer de você caçar um bug que não estará explicitamente no seu código, mas sim no binário que o compilador gerou, pois você assumiu algo que na verdade não é definido no padrão da linguagem e o compilador decidiu fazer o que quiser com aquelas linhas de código (inclusive removê-las) 🙂.

Mas e a segurança? Mas essa linguagem não é do século passado?

Como a solução foi feita em C, já consigo imaginar muitos programadores iniciantes (alguns nem tanto) falarem algo como: “C não é seguro, olhe esse relatório da NSA”, “C é antigo, você deveria utilizar Rust”. Boa parte deles não sabem sobre do que estão falando. Não me entenda mal, segurança de memória é algo importante. Mas sabe algo muito mais importante? Tornar linhas de código algo útil.

C existe para suprir a necessidade de uma linguagem simples, fácil de implementar e fácil de manter. Segurança de memória implica em uma linguagem mais complexa (veja Rust por exemplo).

Async ou não Async, éis a questão

Muito se fala sobre a velocidade do NodeJS e do V8. NodeJS é rápido, não há discussão nisso. Mas você já se perguntou como ele consegue ser tão rápido em comparação com Python ou Ruby? Bom, não vou entrar muitos detalhes aqui (tópico para post futuro), mas boa parte da velocidade vem do gerenciamento de tarefas assíncronas padrão do NodeJS.

Esse gerenciamento é implementado todo sob a biblioteca libuv (feita em C), que por baixo dos panos (no Linux) utiliza uma tecnologia de 2002 chamada epoll.

Desde o kernel Linux 5.1 (2019), a nova tecnologia io_uring para lidar com códigos assíncronos e leitura e escrita está disponível. Infelizmente, mudar de tecnologia em algo tão essencial no NodeJS provavelmente levará bastante tempo. Entretanto, podemos ter acesso a esta tecnologia utilizando a biblioteca C libiouring que está disponível nos repositórios das principais distribuições Linux.

Como a Rinha de Backend é sobre aprender algo novo, decidi utilizar io_uring para implementar minha submissão. Porém, também acredito que utilizar epoll também seria uma boa solução, bem como implementar utilizando threads.

Arquivos? A rinha não é sobre um problema de web?

Vale mencionar que tanto epoll ou io_uring lidam apenas sobre a leitura e escrita de arquivos. Alguns dos leitores podem não saber, mas a maior parte do tempo de processamento em um CRUD não é feito pela sua linguagem favorita, mas sim pelo kernel do seu sistema operacional efetuando a leitura e escrita de arquivos. No linux, um socket TCP (utilizado no HTTP) é interpretado como um arquivo do sistema operacional, portanto, mandar uma requisição ou resposta HTTP, não é nada mais do que ler e escrever um arquivo. Por isso, as bibliotecas e linguagens que tem o conceito de async-io, operam esse conceito sob a perspectiva de arquivos.

Just use Postgres for everything

Um dos meus artigos favoritos na internet é o blog post de Stephan Schmidt: Just Use Postgres for Everything. O que eu mais gosto deste post é o quão simples pode ser uma solução, quando você domina uma tecnologia. Para os que leem em inglês, acredito que é um post imprescindível para qualquer desenvolvedor.

Bom, sobre a escolha do banco de dados há muito o que falar. Just Use Postgres for Everything.

SQL e concorrência

Ao começar a implementar minha solução para a Rinha, eu logo pensei: Não dá pra ficar fazendo várias chamadas ao Banco de Dados, irei criar uma função no banco e fazer só uma chamada.

Ingênuo foi eu por achar que isso seria suficiente. Para minha surpresa, pode ocorrer problemas de concorrência mesmo se tudo ocorrer no banco de dados. Claro, que não será tão catastrófico quanto um problema de concorrência em uma API multithread que você está desenvolvendo, mas a ordem das operações pode sim não corresponder com o que foi solicitado.

Eu passei a maior parte do meu tempo na Rinha aprendendo sobre SQL e concorrência, e percebi que o banco de dados é uma aplicação que faz milagres, porém, saber o básico de concorrência e paralelismo ajuda muito a entender os conceitos de lock de tabelas e linhas, concorrência otimista e pessimista entre outros conceitos.

Mesmo tendo alguma experiência anterior com problemas de concorrência, foi muito gratificante me aprofundar no assunto especificamente em banco de dados e hoje acredito que sou um engenheiro melhor tecnicamente do que antes.

A uns dias atrás no meu trabalho, tive um problema bem cabuloso de concorrência em banco de dados, ao invés de ir chorar pro DBA, eu mesmo consegui resolver. Não foi a melhor solução, mas foi o necessário para o momento.

Após essa minha jornada de aprendizado, mais do que nunca, acredito que um bom conhecimento em SQL é essencial. Confesso que por muito tempo deixei esse conceito de lado e foquei bastante em outras áreas de conhecimento. Não que eu me arrependa, mas, se eu pudesse voltar no tempo, teria dado uma importância um pouco maior.

Você pode ver a solução no repositório no GitHub

Mas e Rust????

Ok, ok... Já entendi. Mas esse assunto fica para um próximo post...