Ring Buffer, também chamado de Circular Buffer é uma estrutura de dados muito poderosa. Seu nome já traz o spoiler e entrega o ouro, afinal não deixa de ser um buffer, só que trabalhando em formato de anel/circular. Se você não faz ideia do que seja, vem comigo nessa viagem pois vamos dissecar o assunto e ainda compará-la ao processo de uberização dos objetos custosos.
Entendendo a estrutura
Ring Buffer poderia ser considerado o Uber das estruturas de dados. Trata-se de um buffer em estrutura circular ou de anel (ring). Tem ligeiras semelhanças com uma fila (FIFO lembra?), no entanto, ao invés de somente removermos o primeiro elemento da estrutura no momento do consumo, também devolvemos após seu uso, entrando no final do ring e ficando disponível para ser consumido novamente. É essa forma de trabalhar que se assimila com algo circular, um anel, daí o nome circular/ring buffer.
Essa coreografia é a cereja do bolo quando pensamos no emprego de ring buffers. Tem potencial para aumentar substancialmente sua capacidade de processamento e throughput, se usado da forma certa, podendo ser aplicado a alguns poucos cenários, mas que são absurdamente comuns e cotidianos e seu emprego pode produzir um enorme resultado positivo.
Uber vs Ring Buffer
Se você já usou Uber ou Taxi será capaz de entender relativamente rápido o poder do Ring Buffer, pois o Uber é exatamente uma implementação de Ring Buffer do mundo real, seus carros são os elementos do Ring.
Você não se preocupa em encontrar as chaves do carro 1, da mesma forma como não se preocupa com o tempo que leva para ligar o carro, tirá-lo da garagem, esperar o ar condicionado chegar à temperatura desejada, avaliar se há combustível, enfim, se está tudo pronto para sua viagem 2 . Você pede um carro 3, e recebe um carro pronto 4, com um motorista 5, e talvez precise esperar um pouco até que algum esteja disponível 6. Na medida que você acabou de usar o serviço, simplesmente deixa o carro seguir e atender outros passageiros 7.
- Acoplamento – Você não precisa saber como criar ou o que é necessário para criar uma instância do teu objeto. você simplesmente pede uma instância de um objeto disponível para uso.
- Warm Up e tempo de Inicialização são desprezados na medida que já estão disponíveis para uso.
- Semelhante a pedir um objeto do Buffer.
- Da mesma forma como se o objeto estiver disponível, será entregue para você.
- No Ring Buffer é comum haver um manager para gerenciar a alocação e principalmente a devolução da instância para o buffer.
- Da mesma forma como acontece quando todo o buffer está em uso.
- É a hora em que o manager libera o item do buffer para ser reaproveitado por outro consumidor.
Se você entendeu, ótimo, você sabe o que é um ring buffer ou um buffer circular.
Não fez sentido ainda? Precisa de um exemplo de uso?
Tudo bem, um grande problema de boa parte das grandes boas soluções em software é esse: Elas não ressaltam aos olhos à primeira vista. Se você não for perspicaz e estiver atento, ignorará solenemente aquele conceito, pois não enxerga utilidade prática.
Se você se identifica essa frase, fique mais atento, há ouro nas coisas mais simples que você possa imaginar.
Ring Buffer é muito usado em:
- Conexões
- SGDB’s
- Message Brokers
- Streaming Platforms
- gRPC
- Socket
- Alocação de threads (Thread Pool)
- Manipulação de arquivos
- Encoding de Mídia
Um tipo de objeto é candidato a usar um ring buffer quando:
- quando sua construção e inicialização custa caro demais.
- quando o objeto bufferizado pode ser reaproveitado por diversos consumidores (em momentos distintos).
- quando o custo da alocação prévia e manter esse recurso no buffer é desprezível ou irrelevante em função de seus benefícios.
Se você disse sim para todos os cenários acima, com certeza você é um forte candidato a usar ring buffers. Mas a análise se dá caso-a-caso.
Mas afinal, o que é um Ring Buffer?
É um buffer em forma de anel, ou seja cíclico.
Enquanto ao consumirmos uma fila, removemos o primeiro elemento no ato de consumo, reduzindo a quantidade de elementos da fila, sem nunca retroalimentá-la, em um ring buffer o que temos é um conjunto finito de elementos que são entregues um-a-um para cada consumidor, e após seu consumo esse elemento volta para o ring para ser reaproveitado por outro consumidor do ring.
A analogia com o Uber vem exatamente da tentativa de mostrar de forma clara como podemos ver isso no nosso dia-a-dia.
Mas porque você veio falar disso agora?
No Docker Definitivo, enquanto na turma 1 e 2 estamos lidando com Microsserviços, criando um microsserviço de pagamento, na turma 3 ainda estamos falando do Youtube Downloader. E surgiu a necessidade de fazer update do projeto. Ao realizar esse update para o .NET 5, surgiu uma questão: Coisas internas do client do RabbitMQ mudaram e fiquei órfão da minha implementação de pipeline, que não é mais compatível com a versão mais recente do client. Outro ponto é que eu tenho um passivo nessa abstração sobre o RabbitMQ que dura 6 anos.
Resolvi desacoplar tudo e criar como eu queria ter criado desde o início.
Uma das demandas para a integração com o RabbitMQ é suportar um throughput elevado, independente de quanto esse número alcance. A intenção é que a implementação seja um facilitador, não um limitador, por isso, poder trabalhar com Buffers de Models e Connections faz todo sentido como requisito.
Ao mesmo tempo um amigo, havia sinalizado uma questão que envolvia um custo muito grande pra publicar cada mensagem (depois ele me disse que era latência), mas quando ele me procurou eu saí atirando para todos os lados citando os:
Ofensores
- Latência
- Rede (instabilidade)
- Custo de criação da conexão e handshake
- Custo de criação do canal/model
- Roteamento
- Tamanho da mensagem
- Tamanho da fila
- Quantidade de conexões
- Quantidade de canais/models
- Persistência
- I/O e disco
Enfim, tudo que me vinha à cabeça como ofensor naquela hora.
E falei de algumas estratégias de mitigação:
- Cache de Mensagens grandes
- Trafegar somente ID’s de controle
- Pré-alocação de Connections
- Pré-alocação de Canais/Models
- Ambos remetem a ⭐Ring Buffers⭐, que foi dito também
- Aproximação dos servidores
- Revisão das rotas de rede
- Revisão da saúde e performance dos discos
Daí magicamente os assuntos se casaram. Eu tinha uma demanda por causa do Oragon que demandava uma solução por conta do Youtube Downloader. Lembrei que em todos esses anos eu nunca havia tocado no assunto, nem superficialmente. Então resolvi dar atenção a todos esses aspectos de uma vez só.
Então comecei um projeto no github, que ainda é um esboço. Fique de olho nos próximos posts, em breve surge novidade sobre o projeto. De cara eu fiz um teste de alocação de Connections e Canais/Models com RabbitMQ. Ou seja, eu criei connections e models mas não os usei para nada. Sequer publiquei 1 mensagem. A intenção desse teste é comparar a estratégia default, alocando recursos por demanda. Aliás é uma ótima estratégia e por isso é a estratégia default.
Mas no nosso caso, eu estava buscando reduzir os cursos de conexão e estabelecimento de um canal pronto para uso, trocando alocação dinâmica por pré-alocação, o que produz um custo também.
Benchmark
Ainda estou trabalhando para ter um benchmark não viciado, problema da maioria dos benchmarks. Nesse momento os números que eu tenho aqui são absurdos e discrepantes demais, numa ordem de grandeza muito maior que o esperado. Eu sinceramente acredito que tenha erros na forma como estou produzindo esse benchmark, portanto sequer vou divulgar agora. Mas eu comentei sobre ele no telegram.
Que tal amanhã mesmo mudar seu projeto em produção?
A primeira coisa a se notar é: Não faça otimizações prematuras.
Se você não sabe que tem um problema, ou você avalia se há um problema para diagnosticar e assim tratar (e ringbuffer pode te ajudar) ou você não tem um problema.
Buscar mais performance só faz sentido se você percebe que está faltando performance.
Mais performance não é um bom motivo em si. Afinal, você não está usando go, c, c++….
Sair alterando seus projetos sem um motivo plausível, é amadorismo.
Aliás, existem poucas implementações de ringbuffer thread safe aqui no mundo .NET. Eu estou trabalhando em uma, mas ainda não estou tão perto de finalizar.
Conclusão
Circular Buffer é uma estratégia que tem tradeoffs. Manter conexões por longos períodos pode não ser eficiente no teu cenário. Você precisa levar isso em conta.
Lembra que a estratégia que estou usando pré-aloca conexões de rede e canais. Se sua aplicação escalar horizontalmente, tenha em mente que pode alcançar o limite do teu serviço/servidor. Isso em geral acontece do lado do servidor para o qual sua conexão está estabelecida.
Tendo um problema que justifique, faz todo sentido usar Ring Buffer é uma estratégia bem válida, muito útil e com potencial para entregar performance extrema. Mas não é para todo mundo, nem é necessária em todos os casos.
No final do artigo você cita “Manter conexões por longos períodos pode não ser eficiente no teu cenário. Você precisa levar isso em conta.”
Imagine um cenário de pouca demanda, onde a justificativa principal para uso do RabbitMQ é o desacoplamento de API’s, e não a alta demanda, um cenário no qual mensalmente 4 ou 5 mensagens iriam ser enviadas para o RabbitMQ para realizações de processos internos diversos, e nesse cenário vem as dúvidas:
1) Vale a pena manter a conexão do produtor sempre aberta? Ou devido a baixa demanda seria melhor abrir e fechar a conexão a cada mensagem enviada?
2) No que se refere ao consumidor, manter a conexão do mesmo sempre aberta seria a melhor opção?
3) Leve em consideração que preciso manter uma única conexão sempre aberta, criar um singleton da classe de conexão com o RabbitMQ seria uma boa prática? Ou apenas criar uma classe que serviria como wrapper mantendo uma única instância da conexão com o RabbitMQ seria a melhor opção?
Novamente obrigado pela atenção, e parabéns pelo artigo, do mais alto nível, tenho aprendido bastante.