Network programming: select, poll, epoll e kqueue

1. Introdução à Programação de Rede com I/O Multiplexado

1.1. O problema do bloqueio em sockets

Em modelos tradicionais de servidores de rede, cada conexão de cliente frequentemente exigia uma thread ou processo dedicado. O modelo fork() por conexão é simples, mas sofre com alto custo de criação de processos e overhead de contexto. O modelo thread por conexão reduz o custo, mas ainda enfrenta limitações de escalabilidade quando o número de conexões simultâneas cresce para milhares.

1.2. Conceito de I/O multiplexado

I/O multiplexado permite que um único processo monitore múltiplos descritores de arquivo (sockets, pipes, arquivos) e seja notificado quando um ou mais deles estiverem prontos para operações de leitura, escrita ou quando ocorrerem exceções. Isso elimina a necessidade de uma thread por conexão, reduzindo consumo de memória e overhead de contexto.

1.3. Visão geral das APIs

  • select(): a API mais antiga e portável (POSIX), mas limitada.
  • poll(): evolução do select, sem limite fixo de descritores, mas ainda ineficiente.
  • epoll: específica do Linux, escalável para milhares de conexões.
  • kqueue: específica de sistemas BSD e macOS, similar ao epoll em eficiência.

2. select(): a abordagem clássica e portável

2.1. Assinatura e manipulação de fd_set

#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

void FD_SET(int fd, fd_set *set);
void FD_CLR(int fd, fd_set *set);
int  FD_ISSET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);

2.2. Exemplo prático: servidor TCP com select()

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/select.h>

#define PORT 8080
#define MAX_CLIENTS 10

int main() {
    int server_fd, client_fd, max_fd, activity, i;
    struct sockaddr_in address;
    int addrlen = sizeof(address);
    int client_sockets[MAX_CLIENTS];
    fd_set readfds;

    // Criar socket do servidor
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);
    bind(server_fd, (struct sockaddr *)&address, sizeof(address));
    listen(server_fd, 3);

    // Inicializar array de clientes
    for (i = 0; i < MAX_CLIENTS; i++)
        client_sockets[i] = 0;

    while (1) {
        FD_ZERO(&readfds);
        FD_SET(server_fd, &readfds);
        max_fd = server_fd;

        for (i = 0; i < MAX_CLIENTS; i++) {
            int sd = client_sockets[i];
            if (sd > 0)
                FD_SET(sd, &readfds);
            if (sd > max_fd)
                max_fd = sd;
        }

        activity = select(max_fd + 1, &readfds, NULL, NULL, NULL);

        if (FD_ISSET(server_fd, &readfds)) {
            client_fd = accept(server_fd, (struct sockaddr *)&address,
                              (socklen_t *)&addrlen);
            for (i = 0; i < MAX_CLIENTS; i++) {
                if (client_sockets[i] == 0) {
                    client_sockets[i] = client_fd;
                    break;
                }
            }
        }

        for (i = 0; i < MAX_CLIENTS; i++) {
            int sd = client_sockets[i];
            if (FD_ISSET(sd, &readfds)) {
                char buffer[1024] = {0};
                int valread = read(sd, buffer, 1024);
                if (valread == 0) {
                    close(sd);
                    client_sockets[i] = 0;
                } else {
                    printf("Cliente: %s\n", buffer);
                    send(sd, buffer, strlen(buffer), 0);
                }
            }
        }
    }
    return 0;
}

2.3. Limitações do select

  • Limite de FD_SETSIZE: normalmente 1024 descritores.
  • Cópia de bitsets: o kernel precisa copiar os conjuntos de descritores entre espaço do usuário e kernel.
  • Varredura linear O(n): mesmo quando poucos descritores estão ativos, o kernel verifica todos.

3. poll(): evolução do select

3.1. Estrutura struct pollfd

#include <poll.h>

struct pollfd {
    int   fd;         /* descritor de arquivo */
    short events;     /* eventos de interesse */
    short revents;    /* eventos ocorridos (retornados) */
};

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

3.2. Exemplo prático: servidor TCP com poll()

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <poll.h>

#define PORT 8080
#define MAX_CLIENTS 10

int main() {
    int server_fd, client_fd, i;
    struct sockaddr_in address;
    int addrlen = sizeof(address);
    struct pollfd fds[MAX_CLIENTS + 1];

    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);
    bind(server_fd, (struct sockaddr *)&address, sizeof(address));
    listen(server_fd, 3);

    fds[0].fd = server_fd;
    fds[0].events = POLLIN;

    for (i = 1; i <= MAX_CLIENTS; i++)
        fds[i].fd = -1;

    while (1) {
        int activity = poll(fds, MAX_CLIENTS + 1, -1);

        if (fds[0].revents & POLLIN) {
            client_fd = accept(server_fd, (struct sockaddr *)&address,
                              (socklen_t *)&addrlen);
            for (i = 1; i <= MAX_CLIENTS; i++) {
                if (fds[i].fd == -1) {
                    fds[i].fd = client_fd;
                    fds[i].events = POLLIN;
                    break;
                }
            }
        }

        for (i = 1; i <= MAX_CLIENTS; i++) {
            if (fds[i].fd != -1 && (fds[i].revents & POLLIN)) {
                char buffer[1024] = {0};
                int valread = read(fds[i].fd, buffer, 1024);
                if (valread == 0) {
                    close(fds[i].fd);
                    fds[i].fd = -1;
                } else {
                    send(fds[i].fd, buffer, strlen(buffer), 0);
                }
            }
        }
    }
    return 0;
}

3.3. Comparação com select()

  • Sem limite fixo: pode monitorar qualquer número de descritores.
  • Sem cópia de bitsets: usa array de struct, mais eficiente.
  • Ainda varredura linear O(n): o kernel percorre todo o array de descritores.

4. epoll: escalabilidade no Linux

4.1. Criação e configuração

#include <sys/epoll.h>

int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

struct epoll_event {
    uint32_t     events;   /* Eventos (EPOLLIN, EPOLLOUT, etc.) */
    epoll_data_t data;     /* Dados associados (fd, ponteiro) */
};

4.2. Modos de operação

  • Level-triggered (padrão): notifica enquanto o descritor estiver pronto.
  • Edge-triggered: notifica apenas quando o estado muda (exige I/O não bloqueante).

4.3. Exemplo prático: servidor TCP com epoll

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/epoll.h>

#define PORT 8080
#define MAX_EVENTS 10

int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main() {
    int server_fd, epoll_fd, nfds, i;
    struct sockaddr_in address;
    int addrlen = sizeof(address);
    struct epoll_event ev, events[MAX_EVENTS];

    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    set_nonblocking(server_fd);
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);
    bind(server_fd, (struct sockaddr *)&address, sizeof(address));
    listen(server_fd, 3);

    epoll_fd = epoll_create1(0);
    ev.events = EPOLLIN;
    ev.data.fd = server_fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev);

    while (1) {
        nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);

        for (i = 0; i < nfds; i++) {
            if (events[i].data.fd == server_fd) {
                int client_fd = accept(server_fd,
                    (struct sockaddr *)&address, (socklen_t *)&addrlen);
                set_nonblocking(client_fd);
                ev.events = EPOLLIN | EPOLLET;
                ev.data.fd = client_fd;
                epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev);
            } else {
                char buffer[1024];
                int client_fd = events[i].data.fd;
                int valread = read(client_fd, buffer, sizeof(buffer));
                if (valread <= 0) {
                    close(client_fd);
                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL);
                } else {
                    send(client_fd, buffer, valread, 0);
                }
            }
        }
    }
    close(epoll_fd);
    return 0;
}

4.4. Vantagens do epoll

  • O(1): não depende do número total de descritores.
  • Callback-driven: apenas descritores ativos são retornados.
  • Edge-triggered: permite melhor desempenho com I/O não bloqueante.

5. kqueue: a alternativa BSD/macOS

5.1. Conceitos básicos

#include <sys/types.h>
#include <sys/event.h>
#include <sys/time.h>

int kqueue(void);
int kevent(int kq, const struct kevent *changelist, int nchanges,
           struct kevent *eventlist, int nevents,
           const struct timespec *timeout);

struct kevent {
    uintptr_t ident;   /* identificador (fd, pid, etc.) */
    int16_t   filter;  /* filtro (EVFILT_READ, EVFILT_WRITE, etc.) */
    uint16_t  flags;   /* flags (EV_ADD, EV_DELETE, EV_ONESHOT, etc.) */
    uint32_t  fflags;  /* flags específicas do filtro */
    intptr_t  data;    /* dados específicos do filtro */
    void     *udata;   /* dados do usuário */
};

5.2. Exemplo prático: servidor TCP com kqueue

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/event.h>
#include <sys/time.h>

#define PORT 8080
#define MAX_EVENTS 10

int main() {
    int server_fd, kq, client_fd, nev, i;
    struct sockaddr_in address;
    int addrlen = sizeof(address);
    struct kevent change, events[MAX_EVENTS];

    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);
    bind(server_fd, (struct sockaddr *)&address, sizeof(address));
    listen(server_fd, 3);

    kq = kqueue();

    EV_SET(&change, server_fd, EVFILT_READ, EV_ADD, 0, 0, NULL);
    kevent(kq, &change, 1, NULL, 0, NULL);

    while (1) {
        nev = kevent(kq, NULL, 0, events, MAX_EVENTS, NULL);

        for (i = 0; i < nev; i++) {
            if (events[i].ident == server_fd) {
                client_fd = accept(server_fd,
                    (struct sockaddr *)&address, (socklen_t *)&addrlen);
                EV_SET(&change, client_fd, EVFILT_READ, EV_ADD, 0, 0, NULL);
                kevent(kq, &change, 1, NULL, 0, NULL);
            } else if (events[i].filter == EVFILT_READ) {
                char buffer[1024];
                int client_fd = events[i].ident;
                int valread = read(client_fd, buffer, sizeof(buffer));
                if (valread <= 0) {
                    close(client_fd);
                } else {
                    send(client_fd, buffer, valread, 0);
                }
            }
        }
    }
    close(kq);
    return 0;
}

5.3. Diferenças chave do kqueue

  • Suporte a múltiplos tipos de eventos: sockets, arquivos, sinais, processos, timers.
  • API mais flexível: permite monitorar mudanças em diretórios, notificações de processo filho.
  • Integração nativa: no kernel BSD, oferece baixa latência.

5.4. Comparação com epoll

  • Semântica similar: ambos são O(1) e orientados a eventos.
  • kqueue mais flexível: suporta mais tipos de filtros nativamente.
  • epoll mais difundido: no ecossistema Linux, há mais ferramentas e documentação.

6. Estratégias Avançadas e Padrões de Uso

6.1. I/O não bloqueante com buffers parciais

Ao usar edge-triggered com epoll ou kqueue, é essencial configurar sockets como não bloqueantes e realizar loops de leitura/escrita até que EAGAIN ou EWOULDBLOCK seja retornado.

6.2. Timeouts e temporizadores

Com kqueue, é possível adicionar timers usando EVFILT_TIMER. Com epoll, timers podem ser implementados via timerfd_create() ou controlando o parâmetro timeout de epoll_wait().

6.3. Tratamento de erros

  • EPOLLHUP e EPOLLERR: indicam fechamento ou erro na conexão.
  • EV_EOF no kqueue: indica fim do arquivo ou fechamento da conexão.

7. Portabilidade e Escolha da API

7.1. Estratégias de abstração

Bibliotecas como libevent e libuv abstraem as diferenças entre plataformas, permitindo usar select, poll, epoll ou kqueue transparentemente. Para código nativo, use #ifdef __linux__ para epoll e #ifdef __APPLE__ ou #ifdef __FreeBSD__ para kqueue.

7.2. Benchmarks conceituais

  • select/poll: adequados para dezenas a centenas de conexões.
  • epoll/kqueue: necessários para milhares de conexões simultâneas.
  • Edge-triggered: oferece melhor desempenho, mas exige cuidado com I/O parcial.

7.3. Considerações finais

  • Use select() para scripts simples e portabilidade máxima.
  • Use poll() quando precisar de mais descritores que FD_SETSIZE.
  • Use epoll no Linux e kqueue em BSD/macOS para aplicações de alta

performance.

A escolha da API correta depende do ambiente alvo, da escala do projeto e dos requisitos de latência. Para servidores modernos que precisam sustentar dezenas de milhares de conexões simultâneas, epoll (Linux) e kqueue (BSD/macOS) são as únicas opções viáveis. Em cenários de middleware ou ferramentas portáteis, investir em uma abstração como libevent ou libuv reduz significativamente o custo de manutenção.

8. Conclusão

A multiplexação de I/O é uma técnica fundamental para construir servidores de rede eficientes em C. Cada API apresentada — select, poll, epoll e kqueue — representa um degrau evolutivo na capacidade de gerenciar múltiplas conexões com cada vez menos sobrecarga.

  • select oferece portabilidade máxima, mas sofre com limites rígidos e desempenho O(n).
  • poll elimina o limite de FD_SETSIZE, porém ainda realiza varredura linear.
  • epoll e kqueue revolucionam o modelo com notificações orientadas a eventos e complexidade O(1), sendo indispensáveis para sistemas de alta concorrência.

Dominar essas APIs permite ao programador C construir desde pequenos utilitários de rede até servidores robustos que sustentam milhões de conexões simultâneas. O conhecimento de como e quando aplicar cada uma delas é uma habilidade essencial no desenvolvimento de sistemas de rede modernos.