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 :

$ mkfifo queue

$ ls -l queue
prw-r--r-- 1 leandronsp leandronsp 0 Dec 31 23:59 queue

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:

# Abre o arquivo em modo somente leitura
File.open("queue", "r") do |file|
  # Lê o conteúdo do arquivo
  buffer = file.read(1024)

  # Exibe a mensagem recebida
  puts "Mensagem recebida: #{buffer}"
end

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