Trabalhando com I/O
Relembrando o que vimos na primeira parte do guia, uma operação de I/O pode bloquear a execução de uma thread no sistema operacional. Sendo a thread principal, neste caso o programa todo fica bloqueado.
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
O nome técnico para este tipo de comunicação é UNIX named pipes, ou pipes nomeados, cuja funcionalidade é prover uma estrutura de fila (daí o nome FIFO) onde:
um processo escreve mensagens na fila
outro processo lê mensagens na fila
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 C o leitor da fila:
Atenção para alguns detalhes:
o header fcntl inclui funções como
open
, que adiciona um determinado arquivo no filesystem na tabela de descritores de arquivos, a file descriptor table, e retorna o inteiro representando o fdo header unistd inclui funções como
read
eclose
, que são mais genéricas para leitura e fechamento de descritores de arquivos
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
:
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
Limitações do I/O bloqueante
Neste exemplo com FIFO, a operação de abertura do arquivo com open
é bloqueante. Enquanto não chega mensagem no arquivo, o programa fica bloqueado nesta operação pois o I/O ainda não está pronto.
E é isto que define uma operação bloqueante: quando o I/O ainda não está pronto, a nível de estruturas internas, dados disponíveis e buffers no sistema operacional.
Agora vamos imaginar um cenário onde precisamos ler do FIFO, depois ler do STDIN, ou mesmo ler de um socket TCP, e mais um monte de coisas no programa.
Bloquear o programa inteiro ou a thread inteira por conta de uma operação faz aumentar bastante a latência total do sistema, o que contribui para um throughput final bem baixo. Em outras palavras, nosso sistema não escala adequadamente consoante ao número de operações necessárias pra fazer em I/O.
Vai uma thread aí?
Não é incomum a utilização de threads para paralelizar operações em I/O bloqueante o que até faz sentido em alguns casos. Mas quando falamos de milhares de operações, podemos esbarrar nas limitações de threads, como já vimos diversas vezes neste guia.
Sem problemas, Leandro. Já sabemos que dá pra usar pool de threads, certo?
Certo?
Mais ou menos. Embora seja uma solução que funcione para muitos casos, a pool pode limitar a performance caso o sistema exija um número massivo de operações em I/O, pois ela trabalha com um número fixo de threads, lembra?
Pensando aqui, seria muito bacana se o sistema operacional permitisse que a leitura de um arquivo fosse "não-bloqueante", de forma que depois ele "avisasse" o programa que o arquivo já ficou pronto para leitura ou algo assim?
Esta técnica existe no sistema operacional, e é chamada de I/O não-bloqueante.
I/O não-bloqueante em C
Como vimos no tópico anterior, a função open
abre o descritor em modo bloqueante por padrão:
Mas é possível passar também uma flag chamada O_NONBLOCK
, que vai abrir o descritor em modo não-bloqueante, e isto serve pra qualquer tipo de descritor, seja um arquivo comum, socket, pipe (FIFO), etc.
Desta forma, a operação de I/O não impede o programa de continuar executando caso os dados não estejam imediatamente disponíveis. Mas aqui tem o pulo do gato...
Como a leitura não é bloqueante, a função retorna imediatamente. E o retorno pode ter dois valores possíveis:
os bytes a serem lidos, caso o I/O já esteja pronto
-1
, caso o I/O não esteja pronto. Sendo assim, para além do valor ser -1, a variável global errno também é configurada com o valorEAGAIN
ouEWOULDBLOCK
, que indicam que o I/O não está pronto e que é preciso fazer "polling" no descritor para saber quando fica pronto
E atenção para a frase "tentar novamente em instantes". O que queremos aqui é ficar em loop, verificando (polling) quando que de fato há bytes para serem lidos:
Dentro do loop, fazemos a leitura do descritor, e caso não esteja disponível, avisamos que não há nada na fila e a seguir é provocado um sleep de 1 segundo para não sobrecarregar a CPU com muitas voltas redundantes no loop.
Yay!
Contudo, esta solução ainda é bastante rudimentar e ineficiente para monitorarmos descritores de arquivos. Felizmente, o sistema operacional fornece algumas syscalls para resolver este problema de forma mais elegante, permitindo monitorar os descritores que estão sendo processados pelo sistema operacional.
Monitorando I/O com select
A syscall select permite monitorar múltiplos descritores de arquivo simultaneamente, sem que o programa precise verificar continuamente cada um deles de forma redundante (como no exemplo anterior). Isso melhora a eficiência, reduz o consumo de CPU e permite que o programa reaja apenas quando houver atividade em um ou mais descritores monitorados.
Para utilizar o select, precisamos primeiro definir o conjunto de descritores (fd_set) a serem monitorados, através das macros que estão na biblioteca sys/select.h:
FD_ZERO: para inicializar o conjunto
FD_SET: adiciona um descritor ao conjunto
FD_ISSET: verifica se um descritor está pronto para leitura/escrita
OMG! Que dia incrível!
O select traz a vantagem que permite monitorar múltiplos descritores de forma simples, pelo que também funciona em diversos sistemas operacionais.
Entretanto ele possui algumas limitações:
tempo de busca é linear, o que pode trazer problemas de performance para um grande número de descritores
possui limitação no número máximo de descritores
Monitorando I/O com epoll
epoll é uma interface mais eficiente que o select para monitorar um grande número de descritores, e foi introduzida no Linux 2.6.
Em vez de retornar todos os descritores para fazer uma busca linear nos que estão prontos, epoll retorna apenas os que estão de fato prontos para leitura/escrita, diminuindo bastante o overhead para um número muito grande de descritores.
Outra vantagem com relação ao select é que não há um limite de descritores, pois ele mantém um número dinâmico de descritores dentro do Kernel.
A mecânica de uso é muito parecida com o select:
A utilização de epoll apresenta uma performance significativamente superior ao select quando lidamos com um grande número de descritores de arquivo. Isso o torna uma escolha mais adequada para sistemas modernos e de alta escala.
Até agora, exploramos os recursos que o sistema operacional 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
Muitas vezes, há confusão entre I/O assíncrono e I/O não-bloqueante, ou mesmo com o uso de threads. No entanto, as threads não têm relação direta com o assincronismo em I/O. Podemos usar threads para multiplexar operações bloqueantes ou não-bloqueantes, mas o conceito de I/O assíncrono está relacionado à maneira como o código é estruturado para lidar com operações não-bloqueantes.
Em resumo, se você optar por I/O não-bloqueante, estará implicitamente adotando um estilo de programação assíncrono para lidar com a natureza imprevisível das operações.
Vamos ilustrar isso comparando um código síncrono com outro assíncrono em C:
O programa espera a leitura do arquivo ser concluída antes de continuar. A execução é bloqueante: o programa para no read até que a operação termine.
Já no código assíncrono, o programa continua a execução enquanto espera que a operação seja concluída. Quando os dados estão disponíveis, um callback é acionado. Um exemplo com o epoll:
Vamos analisar passo-a-passo este exemplo assíncrono com epoll:
criamos um callback
on_data_available
que representa a lógica quando o descritor estiver pronto (dados disponíveis)no código principal, temos:
a abertura do arquivo de forma não-bloqueante
configuração dos descritores com epoll
loop monitorando os eventos do epoll (loop de eventos)
dentro do loop, quando o dado fica disponível, chamamos o callback que foi registrado previamente
Isto, senhoras e senhores, é o puro extrato do assincronismo.
Agora vamos falar de uma limitação do epoll: só funciona no Linux. Outro desafio no uso do epoll também é que temos que configurar muita coisa manualmente e também temos de escrever nosso próprio "loop de eventos".
E isso dá trabalho
Libuv, o queridinho do NodeJS
Não ganhamos na loteria mas estamos com muita sorte, pois a biblioteca libuv resolve estes e outros problemas:
multi-plataforma (usa epoll no Linux, IOCP no Windows e kqueue no macOS)
tem um loop de eventos muito bem feito
traz suporte uma pool de threads para algumas operações específicas de modo a aumentar o throughput
permite tratamento de sinais
entre otras cositas más...
Vamos a um exemplo de código assíncrono com libuv, a título de curiosidade:
Repare como que o código fica mais simples, e não à toa o NodeJS utiliza o libuv como parte central de todo o assincronismo que o NodeJS fornece.
Calma jovem, mais pra frente no guia vou dedicar uma seção só pra falar de concorrência em NodeJS
Deixando seu código assíncrono, mas síncrono
Apesar do poder do assincronismo, a indireção que deixa no código dificulta muito a manutenção do sistema, porque lidamos com callbacks ou estruturas de eventos que fragmentam o fluxo lógico do programa. Callbacks e mais callbacks podem nos fazer entrar num cenário horrível na programação: o famigerado callback hell.
No entanto, podemos organizar o código assíncrono de forma que pareça síncrono aos olhos de quem está vendo o código, mesmo lidando com operações assíncronas internamente.
I/O assíncrono e escalonamento cooperativo
Essa abordagem é frequentemente implementada usando estruturas cooperativas como async/await, corrotinas ou geradores. Elas pausam a execução no ponto de espera, liberando o controle do programa enquanto aguardam o evento (como dados de I/O) e retomam automaticamente quando o evento ocorre.
Usando algumas técnicas é possível fazer isto em C, mas não vamos aprofundar muito pois estas técnicas são implementadas de forma muito mais robusta em linguagens de mais alto nível que C.
E é isto que vamos cobrir nos tópicos a seguir, em como implementações de linguagens de alto nível lidam com concorrência, seja a nível de forking, threading, sincronização, abstrações em runtime e I/O assíncrono.
Está gostando deste trabalho?
Se está gostando e considera colaborar para que mais trabalhos assim sejam feitos, não deixe de entrar para a lista de investidores premium. Qualquer apoio financeiro via PIX é mais que bem-vindo, inclusive compartilhar o guia é uma excelente forma de apoiar o projeto também!
Ou copia e cola:
Last updated