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:
#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:
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.
#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.
//...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.
//...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.
//...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.
//...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.
//...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.
//...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.
//...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.
//...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.
//...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:
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
sudo apt install apache2-utils
Quando a instalação terminar, execute
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.


