capa-como-criar-load-balancer-em-c

Construa o seu próprio Load Balancer usando sockets em C

Imagine o cenário: sua API finalmente viraliza, o tráfego dispara e, de repente, o seu servidor único não aguenta a carga e cai. Em aplicações que precisam atender milhares ou até milhões de requisições simultâneas, garantir alta disponibilidade e desempenho consistente deixou de ser um diferencial para se tornar uma necessidade fundamental. Neste artigo, você vai entender o que é um load balancer, por que utilizá-lo, como os sockets em C funcionam como base de comunicação em rede e, finalmente, como construir o seu próprio balanceador de carga do zero utilizando a linguagem C.

O que é um load balancer ?

Um load balancer, ou balanceador de carga, é um componente de infraestrutura responsável por distribuir o tráfego de rede recebido entre múltiplos servidores de backend. Em vez de direcionar todas as requisições para um único servidor, o que inevitavelmente levaria à sobrecarga e à degradação do serviço, o balanceador atua como um intermediário inteligente que divide esse trabalho de forma equilibrada.

Além disso, o load balancer funciona como uma camada de abstração entre o cliente e os servidores que efetivamente processam as requisições. O cliente nunca sabe com qual servidor está falando de fato, ele apenas se comunica com o endereço do balanceador, e este decide, com base em algum algoritmo de distribuição, para qual servidor a requisição deve ser encaminhada. Esse comportamento é chamado de proxy reverso, e é exatamente o modelo que será implementado ao longo deste artigo.

Para criar seu load balancer, você precisará de uma IDE para programar! Se ainda não tem, veja como instalar o codeBlocks no linux.

O que são sockets em C ?

Para construir um load balancer funcional, é preciso entender o mecanismo que permite a comunicação entre processos através da rede: os sockets. Em termos técnicos, um socket é uma abstração de software que representa uma extremidade de uma conexão de rede bidirecional. Por meio de sockets, dois processos conseguem trocar dados utilizando os protocolos da pilha TCP/IP.

A linguagem C oferece uma API de sockets por meio do cabeçalho sys/socket.h, juntamente com outros cabeçalhos complementares como netinet/in.h e arpa/inet.h. Essa API, conhecida como POSIX Sockets, é a base sobre a qual praticamente toda a comunicação de rede em sistemas Unix é construída. Ela é extremamente poderosa por ser de baixo nível, o que significa que o programador tem controle total sobre como a comunicação ocorre, mas também exige um entendimento sólido dos conceitos envolvidos.

O ciclo de vida de um socket servidor em C começa pela criação do socket com a função socket(), que recebe como parâmetros a família de endereços (como AF_INET para IPv4), o tipo do socket (como SOCK_STREAM para TCP) e o protocolo. Em seguida, o socket é associado a um endereço IP e porta com a função bind(). Após isso, o socket entra em modo de escuta com listen(), indicando que está pronto para aceitar conexões. Finalmente, a função accept() bloqueia o programa até que uma nova conexão chegue, retornando um novo descritor de arquivo que representa aquela conexão específica.

A arquitetura do load balancer

Antes de mergulhar nos detalhes do código, é importante entender a arquitetura geral do sistema. O balanceador de carga que construímos é composto por três partes principais: o socket de entrada, que recebe conexões dos clientes; a lógica de distribuição, que decide qual servidor de backend irá processar cada requisição; e o mecanismo de proxy, que encaminha a requisição ao servidor escolhido e retorna a resposta ao cliente.

Implementando o load balancer passo a passo

Agora que entendemos a teoria e o desenho da arquitetura, vamos colocar a mão na massa. Como estamos programando em C puro, não podemos simplesmente usar listas dinâmicas prontas. Precisamos construir a nossa própria estrutura de dados para armazenar os endereços dos servidores de backend e garantir que eles recebam as requisições de forma justa.

Estruturas de dados e o cérebro da operação

Para que o balanceador distribua a carga de forma igualitária, o algoritmo mais clássico e eficiente é o Round-Robin. A lógica é simples: a primeira requisição vai para o Servidor A, a segunda para o Servidor B, a terceira para o C, e depois o ciclo se repete.

Sabendo disso, será implementado um pool de threads em uma estrutura de dados chamada fila. Dessa forma, assim que uma requsição nova chegar ao load balancer a primeira thread do pool enviará para o servidor tendo em vista que essa estrutura segue o conceito FIFO. O fluxo se repete até chegar a última thread da fila.

O conceito de FIFO (First In, First Out – o primeiro a entrar é o primeiro a sair) casa perfeitamente com o nosso objetivo. Cada vez que uma requisição chega, nós retiramos a thread que está na frente da fila, enviamos o tráfego para o servidor por meio dela e, em seguida, o colocamos novamente no final da fila. Isso garante um loop infinito e perfeitamente balanceado.

Para definir uma fila iremos fazer o uso de uma TAD (Tipo abstrato de dados). Dessa forma em um arquivo chamado queue.c definimos a estrutura dos nós e da fila para armazenar os IPs e portas dos nossos backends:

C
#include<stdio.h>
#include<stdlib.h>
#include "queue.h"


struct no {
    int server_idx;
    int client_sock;
    struct no* prox;
};

struct queue{
    struct no* ini;
    struct no* fim;
};

queue_ptr queue_init(){
    queue_ptr q;
    q = (queue_ptr) malloc(sizeof(struct queue));
    if (q != NULL) {
        q->ini = NULL;
        q->fim = NULL;
    }
    return q;
}

int queue_is_empty(queue_ptr q){
    if (q->ini == NULL)
        return 1;
    return 0;
}

int queue_insert(queue_ptr q, int server_idx, int client_sock) {
    struct no *node;
    node = (struct no *) malloc(sizeof(struct no));
    if (node == NULL) return 0;

    node->server_idx = server_idx;
    node->client_sock = client_sock;
    node->prox = NULL;

    if (queue_is_empty(q) == 1)
        q->ini = node;
    else
        (q->fim)->prox = node;

    q->fim = node;
    return 1;
}

int queue_remove(queue_ptr q, int *server_idx, int *client_sock) {
    if (queue_is_empty(q) == 1)
        return 0;

    struct no *aux = q->ini;
    *server_idx = aux->server_idx;
    *client_sock = aux->client_sock;

    if (q->ini == q->fim)
        q->fim = NULL;

    q->ini = aux->prox;
    free(aux);
    return 1;
}

  

No código apresentado acima definidos a estrutura no que é definido pelo índice do servidor, o socket do cliente que está conectado e um ponteiro para o próximo servidor da lista. Já a segunda estrutura chamada de queue serve apenas para localizarmos o início e final da fila por meio de ponteiros. Além disso, esse código implementa as operações básicas de inserção, deleção, verficação se está vazia e a alocação de memória para iniciar a fila.

Como mencionei que estamos usando uma TAD, precisamos definir o arquivo .h com a assinatura de todas as funções que implementamos. Isso é feito criando um novo arquivo chamado queue.h e definindo a assinatura das funções implementadas da seguinte forma:

C
typedef struct queue* queue_ptr;
queue_ptr queue_init();
int queue_is_empty(queue_ptr q);
int queue_insert(queue_ptr q, int server_idx, int client_sock);
int queue_remove(queue_ptr q, int *server_idx, int *client_sock);

  

Repare que eu defini typedef struct queue* queue_ptr isso serve para facilitar na manipulação da fila. Com isso não há necessidade de declarar o ponteiro para fila para usa-lá, pois queue_ptr já é um ponteiro de ponteiro para a fila.

Definindo servidores e portas

Com a estrutura de dados pronta, temos condições de criar nosso load balancer, para isso, voltamos nosso foco para o arquivo onde está a função main. Criamos a função servers_entry() e por meio de comandos de entrada e saída, pedimos ao usuário que informe quantos servidores de backend estão disponíveis e quais são seus endereços e portas.

Essas informações são armazenadas em um array de structs chamado servers, que pode comportar até 10 entradas, conforme definido pela constante _MAX_SERVERS. Esse limite pode ser facilmente ampliado conforme a necessidade. Além disso, adicionamos também as bibliotecas que iremos utilizar, as macros e a variáveis globais. Veja como fica o trecho do código abaixo.

C
#include <unistd.h>
#include <stdio.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <string.h>
#include <pthread.h>
#include <time.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include "queue.h"

#define _MAX_SERVERS 10
#define _MAX_THREAD_NUM 4096
#define _PORT 8080
#define _BACKLOG 4096
#define _BUFFER_SIZE 4100
#define _THREAD_STACK_SIZE 65536

int _NUM_SERVERS;

struct server_info {
    char* address;
    int port;
} servers[_MAX_SERVERS];

pthread_t threads[_MAX_THREAD_NUM];

pthread_mutex_t queue_mutex;

void servers_entry()
{
    while(1)
    {
        printf("Enter the number of servers (1 <= number_of_servers <= %d): ", _MAX_SERVERS);
        scanf("%d", &_NUM_SERVERS);

        if (_NUM_SERVERS > 0 && _NUM_SERVERS <= _MAX_SERVERS)
        {
            break;
        }
        printf("Invalid input.\n");
    }

    int idx=0;
    while (idx != _NUM_SERVERS)
    {
        printf("Enter the address of the %d server: ", idx+1);
        servers[idx].address = (char*) malloc(20);
        scanf("%s", servers[idx].address);
        printf("Enter the port of the %d server: ", idx+1);
        scanf("%d", &servers[idx].port);
        idx++;
    }
}

int main(){
     //Chamaremos depois a função server_entry aqui
     return 0;
}

  

Configurando a escuta e o setup dos servidores

Com os endereços dos nossos servidores de backend mapeados e a nossa fila de conexões estruturada, precisamos dar vida ao balanceador. O próximo passo é criar o socket principal da nossa aplicação, que será o responsável por ficar "escutando" as requisições que chegam dos clientes.

Para isso, criamos a função create_balancer_socket(). Nela, configuramos a nossa estrutura sockaddr_in para utilizar o protocolo IPv4 (AF_INET), aceitar conexões de qualquer endereço IP da nossa rede (INADDR_ANY) e operar na porta definida na constante _PORT. Como lidamos com a ordem de bytes da rede, usamos a função htons() para converter o número da porta para o formato big endian, que é o padrão exigido pelos protocolos de rede.

Um detalhe crucial de engenharia de software nesta função é o uso do setsockopt com a flag SO_REUSEADDR. Quando fechamos um servidor abruptamente, o sistema operacional costuma segurar a porta de rede por alguns minutos no estado TIME_WAIT. O SO_REUSEADDR nos permite reiniciar o nosso Load Balancer imediatamente sem nos depararmos com o temido erro "Address already in use".

Após essas configurações, amarramos o socket à porta com o comando bind() e, finalmente, colocamos o servidor em estado de escuta passiva com o listen(), pronto para receber os clientes.

C
//...Funções já implementadas

int create_balancer_socket(struct sockaddr_in* address_balancer_sock)
{
    address_balancer_sock->sin_family = AF_INET;
    address_balancer_sock->sin_addr.s_addr = INADDR_ANY;
    address_balancer_sock->sin_port = htons( _PORT );

    int balancer_sock = socket(AF_INET, SOCK_STREAM, 0);
    if (balancer_sock == -1) {
        perror("Failed to initialize balancer socket");
        exit(EXIT_FAILURE);
    }
    
    int option_value = 1;
    if (setsockopt(balancer_sock, SOL_SOCKET, SO_REUSEADDR, &option_value, sizeof(option_value))) {
        perror("Failed to set socket options");
        exit(EXIT_FAILURE);
    }
       
    if (bind(balancer_sock, (struct sockaddr*) address_balancer_sock, sizeof(*address_balancer_sock)) < 0) {
        perror("Failed to bind balancer socket");
        exit(EXIT_FAILURE);
    }

    if (listen(balancer_sock, _BACKLOG) < 0) {
        perror("Failed to run listen command on balancer socket");
        exit(EXIT_FAILURE);
    }

    printf("Load balancer listening on port %d\n", _PORT);

    return balancer_sock;
}

int main(){
     return 0;
}

  

O Mecanismo de distribuição de carga: Round Robin

O algoritmo de distribuição de carga utilizado neste projeto é o Round Robin, que é um dos métodos mais simples e amplamente utilizados. O princípio é direto: cada nova requisição é encaminhada para o próximo servidor da fila, em rotação sequencial. Quando o último servidor da fila é atingido, o ciclo recomeça pelo primeiro.

Esse comportamento é implementado pela função get_next_server_id(), que simplesmente incrementa o índice do servidor atual e aplica o operador módulo pela quantidade total de servidores.

C
//...Funções já implementadas

void get_next_server_id(int* cur_id)
{
    *cur_id = (*cur_id + 1)%_NUM_SERVERS;
}


int main(){

     return 0;
}

  

O resultado disso é uma distribuição uniforme de requisições entre todos os servidores disponíveis. Se houver três servidores, a primeira requisição vai para o servidor 0, a segunda para o servidor 1, a terceira para o servidor 2, a quarta novamente para o servidor 0, e assim por diante. O Round Robin é ideal quando todos os servidores possuem capacidade computacional semelhante, pois ele não leva em conta a carga atual de cada servidor ao tomar sua decisão.

Existem variações mais sofisticadas desse algoritmo, como o Weighted Round Robin, que permite atribuir pesos diferentes a cada servidor conforme sua capacidade, e algoritmos como Least Connections, que direciona cada nova requisição ao servidor com o menor número de conexões ativas no momento. Para os objetivos deste artigo, o Round Robin simples é suficiente para demonstrar o funcionamento do balanceamento de carga.

Gestão de threads

Um dos desafios fundamentais no desenvolvimento de um load balancer é o tratamento concorrente das conexões. Se o programa processasse uma requisição de cada vez, esperando que ela fosse completamente resolvida antes de aceitar a próxima, o sistema seria praticamente inutilizável sob carga real. Para contornar isso, o balanceador utiliza threads para processar cada conexão de forma independente e simultânea.

Além disso, para evitar que o nosso Load Balancer trave por falta de memória ao receber milhares de acessos, criamos a função get_next_thread.

O código mantém um pool de threads de até 4096 entradas, definido pela constante _MAX_THREAD_NUM. A função get_next_thread() é responsável por selecionar qual thread será utilizada para a próxima conexão. Se a thread já tiver sido utilizada anteriormente, ela primeiro realiza um pthread_join() para aguardar a conclusão do trabalho anterior antes de reutilizá-la. Esse mecanismo garante que não haja vazamento de recursos ao longo do tempo de execução do programa.

C
//...Funções já implementadas

pthread_t* get_next_thread(int* iteration){
    int idx = *iteration % _MAX_THREAD_NUM;
    (*iteration) += 1; 

    if (*iteration > _MAX_THREAD_NUM * 5) {
        *iteration -= _MAX_THREAD_NUM;
    }

    if (*iteration < _MAX_THREAD_NUM){
        return &threads[idx];
    } else {
        if (pthread_join(threads[idx], NULL) != 0){
            perror("Failed to join the thread");
            exit(EXIT_FAILURE);
        }
        return &threads[idx];
    }
}


int main(){

     return 0;
}

  

A função proxy: A mágica do roteamento

O coração do balanceador é a função proxy_function(), que é executada em cada thread de trabalho. Ela realiza quatro passos essenciais: remover a conexão da fila, ler a requisição enviada pelo cliente, encaminhar essa requisição ao servidor de backend selecionado e retornar a resposta do servidor ao cliente.

C
//...Funções já implementadas

void* proxy_function(void* queue)
{
    int client_sock, server_idx;
    pthread_mutex_lock(&queue_mutex);
    queue_remove(queue, &server_idx, &client_sock);
    pthread_mutex_unlock(&queue_mutex);

    ssize_t read_bytes;
    char* buffer = calloc(_BUFFER_SIZE, sizeof(char));

    if ((read_bytes = read(client_sock, buffer, _BUFFER_SIZE - 10)) < 0) {
        perror("Read request content");
        exit(EXIT_FAILURE);
    }

    int final_server_sock = get_final_server_socket(server_idx);

    send(final_server_sock, buffer, strlen(buffer), 0);

    while ((read_bytes = read(final_server_sock, buffer, 4096)) > 0) {
        send(client_sock, buffer, read_bytes, 0);
    }
    if (read_bytes < 0) {
        perror("read server response content");
        exit(EXIT_FAILURE);
    }

    printf("### REQUEST FINISHED ###\n");
    shutdown(final_server_sock, SHUT_RDWR);
    close(final_server_sock);
    shutdown(client_sock, SHUT_RDWR);
    close(client_sock);
    int retval = EXIT_SUCCESS;
    pthread_exit(&retval);
}


int main(){

     return 0;
}

  

A função get_final_server_socket() é responsável por criar um novo socket TCP e estabelecer a conexão com o servidor de backend apropriado, utilizando as informações de endereço e porta armazenadas no array servers. Após o estabelecimento da conexão, a requisição original do cliente é enviada ao servidor, e a resposta é lida em chunks de até 4096 bytes e retransmitida imediatamente ao cliente. Ao final, ambos os sockets são encerrados com shutdown() e close(), liberando os recursos do sistema operacional.

Vale destacar o uso de shutdown() antes de close(). Enquanto close() apenas decrementa o contador de referências do descritor de arquivo, shutdown() com a flag SHUT_RDWR força o encerramento imediato de todas as operações de leitura e escrita pendentes, o que é importante para garantir que os dados em trânsito sejam devidamente finalizados antes do fechamento do socket.

A Orquestração Final: O loop infinito da função main

Chegamos ao ponto de integração de todo o nosso ecossistema. A função main() atua como um orquestrador do balanceador de carga.

Inicialização da fila

O primeiro passo da função main é inicializar a fila de conexões por meio da função queue_init(). Essa fila será o canal de comunicação entre a thread principal, que aceita as conexões, e as threads de trabalho, que efetivamente processam cada requisição. Caso a inicialização falhe e retorne NULL, o programa é encerrado imediatamente, pois sem a fila não há como o sistema funcionar corretamente.

Logo em seguida, a função servers_entry() é chamada para coletar do usuário as informações dos servidores de backend, como endereços IP e portas. É nesse momento que o balanceador aprende quais servidores estão disponíveis para receber tráfego.

C
//...Funções já implementadas

int main(int argc, char const *argv[])
{
    queue_ptr connections_queue = queue_init();
    if (connections_queue == NULL) 
    {
        printf("Failed to initialize queue\n");
        exit(EXIT_FAILURE);
    }
    servers_entry();
    //continua...


  

Criação do socket do load balancer

Com os servidores configurados, o próximo passo é criar o socket que ficará escutando as conexões dos clientes. A função create_balancer_socket() recebe um ponteiro para a struct sockaddr_in, que armazenará as informações de endereço do socket, e retorna o descritor do socket já configurado, vinculado à porta 8080 e pronto para aceitar conexões. A struct address_balancer_sock é mantida na main pois ela será reutilizada mais adiante dentro do loop principal.

C
//...Funções já implementadas

int main(int argc, char const *argv[])
{
    //...já implementado
    struct sockaddr_in address_balancer_sock;
    int balancer_sock = create_balancer_socket(&address_balancer_sock); 

    //continua...


  

Configuração das theads

Antes de iniciar o loop principal, é necessário preparar a infraestrutura de concorrência do programa. A struct pthread_attr_t armazena os atributos que serão aplicados a todas as threads criadas ao longo da execução. O atributo mais importante definido aqui é o tamanho da stack de cada thread, configurado para 65536 bytes por meio de pthread_attr_setstacksize(). Esse controle explícito é importante para evitar o consumo excessivo de memória quando centenas ou milhares de threads estiverem ativas simultaneamente.

Além dos atributos das threads, o mutex queue_mutex é inicializado neste bloco. Esse mutex será utilizado para proteger o acesso à fila de conexões, garantindo que múltiplas threads não tentem ler ou escrever na fila ao mesmo tempo, o que poderia causar corrupção de dados e comportamentos imprevisíveis.

C
//...Funções já implementadas

int main(int argc, char const *argv[])
{
    //...já implementado
    pthread_attr_t attrs;
    pthread_attr_init(&attrs);

    pthread_attr_setstacksize(&attrs, _THREAD_STACK_SIZE);

    if(pthread_mutex_init(&queue_mutex, NULL) < 0)
    {
        perror("Failed to init mutex");
        exit(EXIT_FAILURE);
    }

    //continua...


  

O loop de aceitação de conexões

Este é o coração da função main. O loop infinito while(1) mantém o balanceador sempre ativo e receptivo a novas conexões. Três variáveis de controle são declaradas antes do loop: client_sock, que armazenará o descritor de cada nova conexão aceita; iteration, que controla qual thread do pool será utilizada a seguir; e target_server, que rastreia qual servidor de backend receberá a próxima requisição.

Dentro do loop, a função accept() bloqueia a execução até que um novo cliente se conecte ao balanceador. Quando uma conexão chega, ela retorna um novo descritor de arquivo representando aquela conexão específica. É importante notar que uma cópia da struct de endereço é feita a cada iteração do loop, garantindo que as informações de cada conexão sejam independentes.

C
//...Funções já implementadas

int main(int argc, char const *argv[])
{
    //...já implementado
    int client_sock;
    int iteration = 0;
    int target_server = 0;
    while (1)
    {
        struct sockaddr_in address = address_balancer_sock;
        int addrlen = sizeof(address);

        if ((client_sock = accept(balancer_sock, (struct sockaddr*) &address, (socklen_t*) &addrlen)) < 0)
        {
            perror("Failed to accept connection");
            exit(EXIT_FAILURE);
        }

    //continua...


  

Inserção na fila e crição da thread

Uma vez que a conexão foi aceita, o par formado pelo índice do servidor de destino e pelo descritor do socket do cliente é inserido na fila de conexões. Essa operação é protegida pelo mutex para garantir que nenhuma outra thread esteja modificando a fila ao mesmo tempo.

Imediatamente após a inserção, a função get_next_thread() seleciona qual thread do pool será utilizada e pthread_create() a inicializa com a função proxy_function, passando a fila como argumento. A thread então assumirá o controle de toda a comunicação entre o cliente e o servidor de backend, enquanto a thread principal já estará livre para aceitar a próxima conexão, garantindo assim a natureza não bloqueante do sistema.

Por fim, get_next_server_id() avança o índice do servidor de destino para o próximo da lista, implementando o comportamento de Round Robin que distribui as requisições de forma equilibrada entre todos os backends disponíveis. Esse é o último passo de cada iteração do loop antes que o ciclo recomece e o balanceador aguarde a próxima conexão.

C
//...Funções já implementadas

int main(int argc, char const *argv[])
{
    //...já implementado
    pthread_mutex_lock(&queue_mutex);
        if (queue_insert(connections_queue, target_server, client_sock) == 0)
        {
            printf("Failed to insert to the queue\n");
            exit(EXIT_FAILURE);
        }
        pthread_mutex_unlock(&queue_mutex);
        pthread_t* thread = get_next_thread(&iteration);
        pthread_create(thread, &attrs, proxy_function, connections_queue);

        get_next_server_id(&target_server);
    }

    return EXIT_SUCCESS;
}

  

Quer o código completo ? Acesse o repositório desse projeto no meu Github.

Testando o Load Balancer na Prática

Para testar o load balancer é necessário ter alguns servidores, no caso, irei criar três servidores com o python usando o seguinte comando no terminal:

Terminal
    python3 -m http.server 8001
python3 -m http.server 8002
python3 -m http.server 8003 
    
  

É importante ressaltar que cada comando mostrado acima deve ser executado em uma instância diferente do terminal.

Agora que configuramos os servidores, podemos então executar o load balancer e informar quais são os servidores que receberão as requisições que o load balancer receber. Veja como é feito na imagem abaixo.

Pronto! O load balancer já está executando na porta 8080.

O próximo passo é enviar requisições para o load balancer e ver se ele está distruindo corretamente e se as requisiçõe são respondidas sem perdas. Para fazer isso , iremos usar o apache benchmark. Você pode baixa-ló usando o comando abaixo

Terminal
    sudo apt install apache2-utils
    
  

Quando a instalação terminar, execute

Terminal
    ab -n 1000 -c 100 http://localhost:8080/
    
  

Isso vai mandar 1000 requisições, sendo 100 conexões simultâneas ao mesmo tempo contra o load balancer.

Após executar esse comando podemos ver o relatório abaixo e, note que, não há nenhuma requisição com falha, ou seja o load balancer conseguiu distriuir todas as requisições e os servidores conseguiram responder com sucesso.

Deixe um comentário