Rust, Node.js e a raiz de todos os males

Eu costumo usar Node.js com certa frequência, tanto no trabalho quanto em coisas pessoais. É um script aqui, uma API ali, uma CLI acolá. Em parte porque eu gosto de escrever JavaScript; gosto de linguagens de programação com chaves e ponto-e-vírgula. Mas também porque é super prático, tem lib para tudo que é coisa que você queira fazer e a performance é geralmente entre aceitável e boa. Quero dizer, se você colocar na balança a velocidade de desenvolvimento, as ferramentas disponíveis e o footprint vs. a performance de execução, para muitos casos, Node.js é bom o bastante.

Não é de hoje esse meu “estranho” gosto por JavaScript, {} e ;.

Mas não me entenda mal, Node.js tem certamente seus downsides. O principal deles talvez nem seja técnico, mas sim o que meu amigo Phillip Calçado apresentou na GOTO 2013, que está relacionado à ausência de boas práticas de design de software ou simplesmente o emprego das menos recomendáveis. Acredito que, em muitos casos, não por desleixo, mas por pura falta de conhecimento mesmo, porque há coisa de 10 anos, quando Node.js veio ao mundo e começamos a usar JavaScript no servidor, o emprego de boas práticas de programação no browser ainda era insignificante. Isso de alguma forma foi parar no servidor.

Conheça as ferramentas do seu ofício

Além de aprender boas práticas de engenharia de software e design de código em diferentes paradigmas de programação (estruturado, oo e funcional, por exemplo), é importante que se aprenda também um pouco mais sobre como funcionam as plataformas em si. Neste caso, Node.js. Com certeza isso fará diferença na hora de implementar aplicações de verdade e operá-las em produção sem [tanta] dor de cabeça.

Em se tratando de Node.js, a coisa mais importante a se entender é o seu Event Loop, porque é onde a magia acontece ou a coisa engripa. Se você tem uma base de ciência da computação, vai logo se lembrar que event loop não é um conceito novo que o Node.js inventou. É na verdade um design pattern bem mais antigo. E provavelmente, você vai perceber que há ainda um outro design pattern na jogada, o reactor ⎼ que muitas vezes é tratado como se fosse a mesma coisa, mas não é.

Fonte: Building and understanding reactive microservices using Eclipse Vert.x and distributed tracing.
Fonte: Node.js event loop architecture.

Se você quiser aprender mais sobre o Event Loop do Node.js em especial, recomendo muito esta ótima série de artigos, este aqui e este outro aqui também.

Reconheça suas limitações

Superada as limitações de conhecimento de programação, da linguagem e da plataforma, chegamos então às verdadeiras limitações da plataforma.

Se você chegou até aqui, provavelmente vai concordar comigo que é uma generalização dizer que Node.js é single-threaded. Mas o fato é que, na prática, para todos os efeitos, isto é verdade. Por isso, deve-se fazer todo possível para não bloquear o event loop, do contrário, a aplicação vai ficar enroscada.

Isto faz com que Node.js não seja um bom candidato para rotinas com processamento pesado, com muitos cálculos e algoritmos complexos, pelo simples fato de que isso leva tempo para executar e bloqueia o event loop, o que no final das contas, acaba limitando o throughput da aplicação.

Temos algumas soluções aqui. Uma delas é escrever essas rotinas em outra linguagem mais poderosa, compilada, com suporte a multithreading, etc e executá-las totalmente apartadas. Outra solução é trazer o poder dessas linguagens para dentro da nossa aplicação Node.js.

Worker Threads parecem interessantes também. Mas tenho zero experiência com elas.

Peça ajuda

C++ Addons tornam possível escrever funções super otimizadas, multi-threaded e tudo mais, que podem ser usadas em Node.js de modo totalmente natural, como se fossem funções comuns do JavaScript.

Addons are dynamically-linked shared objects written in C++. The require() function can load addons as ordinary Node.js modules. Addons provide an interface between JavaScript and C/C++ libraries.

The Documentation [link]

Agora, o fato é que escrever em C++ não é uma tarefa simples. C++ é uma linguagem extraordinariamente poderosa, mas não é fácil de se domar. Quero dizer, programar bem em C++ não é coisa que se aprende em um mês ou dois de vídeos no YouTube.

É agora que recorremos à analogia das facas? C++ é a faca do sushiman, enquanto que JavaScript é a faquinha de rocambole.

Portanto, trata-se de uma otimização com custo alto.

Rust entra no jogo

Rust também não é uma linguagem simples de aprender. Sua curva de aprendizagem é íngreme. Bem íngrime. No entanto, ela é mais segura para quem está aprendendo do que C++, com toda certeza.

Com Rust é possível escrever código com performance compatível com C++, porém com memory safety e fearless concurrency, para usar os jargões da linguagem. O que, neste caso, tornaria o custo de otimização de um programa Node.js que chegou ao seu limite mais acessível.

Será que isto é possível? A resposta é sim. É possível escrever extensões nativas para Node.js em Rust já faz bastante tempo. Mas eu, só há umas duas semanas me dei conta disto e acabei descobrindo uma ferramenta que torna isto muito, muito, fácil mesmo.

O que Neon faz é oferecer um conjunto de ferramentas e bindings para facilitar escrever código em Rust, gerar uma biblioteca nativa e usar em Node.js como se fosse uma função JavaScript qualquer, exatamente como seria com C ou C++.

Diferente de algumas soluções em que se usa apenas FFI para fazer chamadas a bibliotecas nativas, que obviamente poderiam ser escritas em qualquer linguagem, Neon faz bind direto na API da V8, para interagir com JavaScript. O que para eles é um problema, porque quando a API da V8 muda, eles precisam mudar também. Por isso há uma iniciativa de implementar o bind via N-API.

Anatomia de uma extensão Neon

Para ter uma ideia de como é escrever uma extensão em Rust com a ajuda de Neon, fiz um projeto de teste, um contador de palavras básico. O processo foi muito simples e sem enroscos. Fiquei realmente surpreso.

Uma das coisas que me agradou bastante é que a ferramenta cria uma estrutura de projeto padronizada, bem organizada, onde você tem um diretório para a biblioteca Node.js e um para a biblioteca nativa em Rust.

Fonte: https://github.com/leandrosilva/verbum-counter

No diretório lib, você tem um arquivo index.js, que é o ponto de entrada da biblioteca, e que faz nada mais nada menos do que importar e exportar a biblioteca nativa em Rust.

Fonte: https://github.com/leandrosilva/verbum-counter/blob/master/lib/index.js

Já no diretório native, você tem um diretório padrão de projetos Cargo.

Fonte: https://github.com/leandrosilva/verbum-counter/tree/master/native

Em src, você tem um arquivo lib.rs onde a magia acontece.

Fonte: https://github.com/leandrosilva/verbum-counter/blob/master/native/src/lib.rs

Okay. Neste caso, a magia não é tão encantadora assim.

O que acontece, aqui, é que eu exporto uma função que cria um tarefa de contagem de palavras, agenda ela para executar num futuro próximo e passa adiante o callback que recebeu do código JavaScript.

Esta função, depois, é usada no código JavaScript sem que fique aparente que ela é uma função externa, implementada em Rust.

Versão em puro JavaScript vs. com Addon em Rust.

Cuidado com a otimização prematura

Foi uma experiência bem legal escrever essa extensão e provavelmente devo escrever mais no futuro. Mas a lição mais importante que gostaria de deixar, aqui, neste post, é que você deveria procurar aprender cada vez mais sobre a sua profissão e suas ferramentas de trabalho, tanto as que usa hoje, quanto as que poderá usar amanhã ou depois, antes de qualquer outra coisa.

Em outras palavras, não é porque é possível e relativamente fácil escrever extensões nativas em Rust e usar transparentemente em Node.js, que você vai sair correndo reescrevendo tudo para fazer seu programa voar. Antes de partir para uma abordagem destas é preciso ter certeza de que você empregou o melhor design possível e esgotou todos os recursos “normais” da plataforma. Porque trazer para o jogo uma nova linguagem não é algo gratuito, muito embora pareça ser ⎼ e traz também consigo novos problemas.

Por exemplo, no caso deste contador de palavras, a versão que usa a biblioteca nativa em Rust (que inclusive usa Rayon para paralelismo) performa pior do que a versão em JavaScript puro, quando o arquivo texto não é grande o bastante. Isto porque a mudança de contexto entre JavaScript e Rust tem seu custo.

Há um limiar a partir do qual uma otimização mais hardcore é realmente necessária. Até que se ultrapasse esse limiar, o melhor que se tem a fazer é fazer o melhor com o que se tem nas mãos.

Como disse Donald Knuth: “A otimização prematura é a raiz de todos os males”.