Trabalhando com I/O
Antes de abordarmos I/O em Ruby, sugiro fortemente a leitura do módulo Trabalhando com I/O em C, onde eu trago conceitos importantes sobre I/O, suas limitações e funcionalidades de I/O não-bloqueante.
Para ilustrar como algumas operações de I/O são por natureza bloqueantes, vamos demonstrar a comunicação entre dois processos utilizando UNIX named pipes, ou FIFO (veja mais sobre arquivos FIFO no meu artigo).
Comunicação com FIFO
A primeira coisa que temos que fazer é criar um arquivo especial do tipo "pipe" com o comando mkfifo
:
A saída começando com "p" indica justamente que este arquivo é especial, ou seja, um pipe FIFO.
Reader
Vamos representar em Ruby o leitor da fila:
Ao executar o programa, note que ele fica bloqueado a espera que alguém escreva na fila.
Writer
Em outra janela do terminal, podemos escrever no FIFO utilizando o comando echo
:
Copy
Veja na janela do writer que a mensagem apareceu na saída padrão! Superb!
Outra característica importante pra notar aqui: o writer também fica bloqueado de escrever, caso não haja nenhum reader disponível.
Isto é o puro suco do I/O bloqueante! Entretanto, I/O bloqueante apresenta algumas limitações, como já abordado no módulo em C
I/O não-bloqueante em Ruby
Vimos no tópico anterior que a chamada File.open
por definição deixa o programa bloqueado até que os dados fiquem "prontos" no I/O durante a leitura. Para trabalharmos com I/O não-bloqueante em Ruby, não é muito diferente do que já vimos em C, pois basta chamar a syscall open
com as devidas flags de RDONLY
e NONBLOCK
.
Este simples programa irá terminar com a mensagem:
Por ser não-bloqueante, o programa não fica bloqueado e continua sua execução rumo ao fim do programa.
Meio óbvio, não?
Agora vamos a um exemplo com loop, simulando um """""""loop de eventos""""""":
Meo deos do céoooo, que dia maravilhoso!!!!!11
Contudo, esta solução ainda é bastante rudimentar e ineficiente para monitorarmos descritores de arquivos. Assim como em C, conseguimos em Ruby ter acesso à syscall select
do sistema operacional através do módulo IO
.
Monitorando I/O com select
Monitorar descritores de arquivos com select em Ruby é flocos com morango:
OMG! Que dia incrível!
Até agora, exploramos os recursos que Ruby oferece para realizar operações de I/O de forma não-bloqueante. Essa característica muda fundamentalmente a maneira como escrevemos código, afastando-nos da abordagem tradicional síncrona para adotar uma abordagem assíncrona.
Normalmente, escrevemos código de forma síncrona, onde as funções e rotinas são executadas em sequência e os dados necessários já estão disponíveis na memória. Com I/O não-bloqueante, perdemos essa certeza. Não sabemos quando os dados estarão prontos, o que nos obriga a estruturar o código para lidar com a disponibilidade futura das informações.
Essa mudança nos leva ao assincronismo, onde não esperamos pela conclusão de uma operação, mas configuramos o código para reagir quando a operação for concluída.
I/O assíncrono em Ruby
Uma vez que vimos formas de lidar com I/O não-bloqueante em Ruby e monitorar descritores, chegou o momento de explorar I/O assíncrono em Ruby.
Antes de avançar, vamos retomar o exemplo da leitura do FIFO de forma não-bloqueante com select:
O bloco entre a linha 13 e 16 indica que temos uma lógica arbitrária para lidar com a mensagem que chega no FIFO, no caso uma lógica simples que imprime no STDOUT a mensagem recebida.
Repare que esta lógica pode ser aplicada para qualquer I/O que fique pronto, portanto podemos levar esta lógica para outra estrutura de lazy evaluation, funcionando como um callback. Em Ruby, podemos recorrer ao uso de lambdas.
O problema dos callbacks
Embora callbacks sejam uma solução funcional para lidar com I/O assíncrono, eles podem rapidamente se tornar difíceis de gerenciar à medida que a complexidade do código cresce. Esse problema é conhecido como callback hell, onde o código tem um certo nível de indireção, tornando a lógica fica fragmentada e difícil de entender.
O problema começa a se tornar evidente:
Cada callback aninhado adiciona uma nova camada de indireção
O código fica menos intuitivo e mais difícil de depurar
O fluxo de controle não é óbvio, tornando a manutenção mais complexa
Para evitar o callback hell, temos de pensar numa solução que permita um certo tipo de controle sobre o código, trazendo mais direção, ou seja, permitir lidar com código assíncrono de maneira mais linear e retomar a execução do código de maneira mais explícita, de forma cooperativa.
Fibers to the rescue.
Escalonamento cooperativo com Fibers
Se você leu a primeira parte do guia e também o módulo em C, já está familiarizado com o conceito de escalonamento cooperativo.
Fibers são estrutuas leves de concorrência em Ruby que permitem que sejam pausadas e retomadas explicitamente no código. Diferente das Threads, as Fibers nunca são escalonadas preemptivamente, e portanto seguem um modelo cooperativo de concorrência.
Apesar de serem utilizadas para diversos propósitos, como por exemplo processamento de fluxo de dados e simulação de corrotinas, Fibers são muito úteis no contexo de I/O não-bloqueante pois trazem esta característica de cooperação, resolvendo o problema de indireção no código assíncrono.
No código de exemplo com callbacks, vamos refatorar para o uso de Fibers:
Primeiramente criamos uma fiber chamada monitor, que fica em loop infinito monitorando o I/O com select. Em seguida, temos o loop principal, que controla a execução da fiber de monitoramento.
Dentro da fiber monitor, quando não há dados disponíveis, o controle é devolvido ao loop principal com Fiber.yield
Ainda na fiber monitor, quando de fato há dados disponíveis, o controle também é devolvido ao loop mas é enviado junto os dados do I/O
No loop principal, é feita a exeução da Fiber (resume), e caso haja dados disponíveis, estes são enviados para o STDOUT com puts
Que dia M.A.R.A.V.I.L.H.O.S.O!
Com Fibers, o código fica mais linear, tornando o fluxo de execução mais próximo de um código síncrono, facilitando a leitura e manutenção. E temos também menos indireção, evitando o callback hell.
No entanto Fibers não eliminam completamente a complexidade do código assíncrono, mas ajudam a tornar a estrutura mais legível e menos fragmentada. Com o auxílio de um padrão de loop de eventos, podemos deixar o código ainda mais legível e fácil de entender.
Event Loop
Quando combinadas com event loops, Fibers fornecem um excelente modelo para I/O assíncrono em Ruby, sem precisar recorrer a threads pesadas.
Não é difícil criarmos nosso próprio event loop que fica registrando de escalonando Fibers de forma cooperativa.
Basicamente, o loop precisa de:
Uma estrutura de fila que guarda as fibers registradas
Um método para registrar fibers na fila
Outro método que consome fibers da fila e as executa enquanto estiverem em execução
Lembrando do modelo cooperativo: a fiber devolve o controle com yield, e o código fora da fiber (no caso o event loop) executa a fiber com resume
Agora, para adaptar nosso exemplo de leitura assíncrona do FIFO, podemos de forma super simples utilizar o loop de eventos:
Na linha 3, o método schedule cria e registra a fiber de monitoramento no loop de eventos. E na linha 20, o schedule cria e registra outra fiber que pode representar outra operação qualquer, tudo de forma assíncrona!
E pra finalizar, o método run da linha 26 é simplesmente o gatilho para iniciar o loop e escalonamento das fibers.
Lindo, não? Quem diria, e você aí pensando que loop de eventos era uma invenção super inovadora do Javascript no backend, né?
Escalonador de Fibers no Ruby 3+
Com o lançamento do Ruby 3, o core da linguagem introduziu um novo recurso: Fiber Scheduler, uma interface nativa para escalonamento de Fibers. Esse recurso permite que I/O assíncrono ocorra de forma transparente, sem necessidade de manipular diretamente event loops ou utilizar o select.
Esse avanço coloca Ruby mais próximo de outras linguagens que já possuem suporte nativo para concorrência assíncrona, como JavaScript (async/await) e Go (goroutines).
A implementação de um escalonador de Fibers permite que operações de I/O sejam automaticamente não bloqueantes quando usamos Fibers. Isto significa que, quando uma Fiber precisar esperar por I/O, o Ruby pode suspender automaticamente e permitir que outras tarefas rodem na mesmo thread.
Antes do Ruby 3, para conseguir esse comportamento, precisávamos criar nosso próprio event loop - como fizemos anteriormente -, mas agora, basta fornecer um escalonador customizado, e o runtime cuida do restante.
No próximo e último tópico de Ruby, iremos abordar algumas gems que lidam com concorrência em Ruby, incluindo Celulloid, parallel e Async.
Fiquem ligades!
Last updated