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 :

$ 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 C o leitor da fila:

fifo-reader.c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

#define BUFFER_SIZE 1024

int main() {
    char buffer[BUFFER_SIZE];

    int fd = open("queue", O_RDONLY); // Abre o arquivo em modo somente leitura
    read(fd, buffer, BUFFER_SIZE); // Tentativa de leitura do arquivo
    printf("Mensagem recebida: %s", buffer);

    close(fd);
    return 0;
}

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 fd

  • o header unistd inclui funções como read e close , 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 :

$ echo Hello! > queue

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:

int fd = open("queue", O_RDONLY); // Bloqueante
read(fd, buffer, BUFFER_SIZE); // Bloqueia até haver dados no FIFO

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.

int fd = open("queue", O_RDONLY | O_NONBLOCK); // Não-bloqueante
ssize_t bytes_read = read(fd, buffer, BUFFER_SIZE); // Não bloqueia a leitura

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 valor EAGAIN ou EWOULDBLOCK , que indicam que o I/O não está pronto e que é preciso fazer "polling" no descritor para saber quando fica pronto

int fd = open("queue", O_RDONLY | O_NONBLOCK); // Não-bloqueante
ssize_t bytes_read = read(fd, buffer, BUFFER_SIZE);

if (bytes_read < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
    printf("Nenhum dado disponível no FIFO no momento. Tentar novamente em instantes");
}

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:

fifo-nb.c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>

#define BUFFER_SIZE 1024

int main() {
    char buffer[BUFFER_SIZE];

    int fd = open("queue", O_RDONLY | O_NONBLOCK); // Abre o arquivo em modo não-bloqueante
						   //
    while (1) { 
	ssize_t bytes_read = read(fd, buffer, BUFFER_SIZE);

	if (bytes_read > 0) {
	    printf("Mensagem recebida: %s", buffer);
	} else if (errno == EAGAIN || errno == EWOULDBLOCK) {
	    printf("Nenhum dado disponível no FIFO agora.\n");
	}

	sleep(1); // Espera 1 segundo
    }

    close(fd);
    return 0;
}

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.

Mensagem recebida: Hello
Mensagem recebida: World

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

io-select.c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/select.h>

#define BUFFER_SIZE 1024

int main() {
    char buffer[BUFFER_SIZE];

    // Abre o FIFO em modo não-bloqueante
    int fd = open("queue", O_RDONLY | O_NONBLOCK);
    printf("Monitorando FIFO...\n");

    while (1) {
        fd_set read_fds;
        FD_ZERO(&read_fds);

        // Adiciona o FIFO aos conjuntos de leitura
        FD_SET(fd, &read_fds);

        // Configura timeout opcional (1 segundo)
        struct timeval timeout;
        timeout.tv_sec = 1;
        timeout.tv_usec = 0;

        // Chama select para monitorar os descritores
        int ready = select(fd + 1, &read_fds, NULL, NULL, &timeout);
        if (ready == 0) {
            // Timeout: Nenhuma atividade
            printf("Nenhum descritor disponível. Continuando...\n");
            continue;
        }

        // Verifica se há dados no FIFO, ou seja se está pronto para leitura
        if (FD_ISSET(fd, &read_fds)) {
            ssize_t bytes_read = read(fd, buffer, BUFFER_SIZE);

            if (bytes_read > 0) {
                printf("Mensagem recebida: %s", buffer);
            } 
        }
    }

    close(fd);
    return 0;
}
Monitorando FIFO...
Nenhum descritor disponível. Continuando...
Nenhum descritor disponível. Continuando...
Mensagem recebida: hello!
Mensagem recebida: hello!

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:

epoll.c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/epoll.h>

#define BUFFER_SIZE 1024
#define MAX_EVENTS 10

int main() {
    char buffer[BUFFER_SIZE];

    // Abre o FIFO em modo não-bloqueante
    int fd = open("queue", O_RDONLY | O_NONBLOCK);

    // Inicializa o epoll (similar ao FD_ZERO do select)
    int epoll_fd = epoll_create1(0);

    // Configura o descritor no epoll
    struct epoll_event event;
    event.events = EPOLLIN; // Monitorar para leitura
    event.data.fd = fd;
    // Adiciona o descritor ao epoll (similar ao FD_SET)
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event);

    printf("Monitorando FIFO com epoll...\n");

    struct epoll_event events[MAX_EVENTS];

    while (1) {
        // Aguarda pelos descritores prontos com timeout de 1 segundo
        int ready = epoll_wait(epoll_fd, events, MAX_EVENTS, 1000);

        if (ready == 0) {
            printf("Nenhum descritor disponível. Continuando...\n");
            continue;
        }

        // Itera pelos descritores prontos e realiza as devidas leituras
        for (int i = 0; i < ready; i++) {
            if (events[i].events & EPOLLIN) {
                ssize_t bytes_read = read(events[i].data.fd, buffer, BUFFER_SIZE);

                if (bytes_read > 0) {
                    printf("Mensagem recebida: %s\n", buffer);
                }
            }
        }
    }

    close(fd);
    close(epoll_fd);
    return 0;
}

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:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

#define BUFFER_SIZE 1024

int main() {
    char buffer[BUFFER_SIZE];

    // Abrindo um arquivo para leitura síncrona
    int fd = open("example.txt", O_RDONLY);

    // Lendo o conteúdo do arquivo
    ssize_t bytes_read = read(fd, buffer, BUFFER_SIZE);
    
    if (bytes_read > 0) {
        printf("Dados lidos: %s\n", buffer);
    } 

    close(fd);
    return 0;
}

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:

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/epoll.h>

#define BUFFER_SIZE 1024
#define MAX_EVENTS 10

// Callback para tratar dados prontos para leitura
void on_data_available(int fd) {
    char buffer[BUFFER_SIZE];
    ssize_t bytes_read = read(fd, buffer, BUFFER_SIZE);
    
    if (bytes_read > 0) {
        printf("Callback: Dados lidos: %s\n", buffer);
    } 
}

int main() {
    // Abre o arquivo em modo não-bloqueante
    int fd = open("example.txt", O_RDONLY | O_NONBLOCK);

    // Cria o epoll
    int epoll_fd = epoll_create1(0);

    // Configura o descritor no epoll
    struct epoll_event event;
    event.events = EPOLLIN; // Monitorar para leitura
    event.data.fd = fd;

    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event)
    struct epoll_event events[MAX_EVENTS];
    
    // Loop de eventos do epoll
    while (1) {
        int ready = epoll_wait(epoll_fd, events, MAX_EVENTS, 1000);

        if (ready == 0) {
            printf("Nenhum dado disponível, continuando...\n");
            continue;
        }

        for (int i = 0; i < ready; i++) {
            if (events[i].events & EPOLLIN) {
                // Chama o callback para processar os dados
                on_data_available(events[i].data.fd);
            }
        }
    }

    close(fd);
    close(epoll_fd);
    return 0;
}

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:

io-libuv.c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <uv.h>

#define BUFFER_SIZE 1024

// Callback chamado quando a leitura é concluída
void on_data_available(uv_fs_t* req) {
    if (req->result > 0) {
        printf("Conteúdo do arquivo: %s\n", (char*)req->bufs->base);
    } 

    // Libera recursos
    uv_fs_req_cleanup(req);
    free(req->bufs->base);
    free(req);
}

int main() {
    // Inicializa o loop de eventos
    uv_loop_t* loop = uv_default_loop();

    uv_fs_t* open_req = malloc(sizeof(uv_fs_t));
    uv_fs_t* read_req = malloc(sizeof(uv_fs_t));

    // Abre o arquivo de forma assíncrona
    uv_fs_open(loop, open_req, "example.txt", O_RDONLY, 0, NULL);

    if (open_req->result >= 0) {
        char* buffer = malloc(BUFFER_SIZE);
        uv_buf_t iov = uv_buf_init(buffer, BUFFER_SIZE);

        // Lê o arquivo de forma assíncrona
        read_req->bufs = &iov;
        uv_fs_read(loop, read_req, open_req->result, &iov, 1, -1, on_data_available);
    }

    uv_fs_req_cleanup(open_req);
    free(open_req);

    uv_run(loop, UV_RUN_DEFAULT);
    return 0;
}

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:

00020126850014BR.GOV.BCB.PIX013638ee4bde-574b-4197-b10f-68742087b00b0223Gratidão pelo cafezinho5204000053039865802BR5925Leandro Freitas Maringolo6009SAO PAULO62140510qrN6Ov1wRl63041A3C

Last updated