Comunicação em Rede com Sockets

[439] Comunicação em Rede com Sockets

Depois de conectado, um socket se lê e se escreve como arquivo — e é essa continuidade que faz a rede caber no que você já sabe de descritores. Em volta dela vêm o par de IP e porta, a sequência socket, bind, listen e accept do servidor, o connect do cliente, e o htons que a ordem de bytes exige.
Linguagem C

13 min de leitura

Até agora, nossos programas viveram dentro de uma única máquina. Hoje eles ganham voz para conversar com o mundo. Os sockets são o mecanismo que permite a programas se comunicarem através de uma rede — seja com outro programa na mesma máquina, seja com um servidor do outro lado do planeta. Toda a internet que você usa — sites, mensageiros, e-mail, jogos online — funciona sobre sockets. E, mais uma vez, o C está na base disso: a API de sockets nasceu no Unix, em C, e permanece o alicerce sobre o qual quase toda comunicação em rede é construída. Vamos escrever nosso primeiro par de programas que conversam pela rede.

A metáfora do socket: um ponto de conexão

A palavra socket significa "tomada" ou "encaixe", e a metáfora é útil: um socket é um ponto de conexão através do qual os dados fluem entre dois programas. Assim como você conecta um aparelho a uma tomada, dois programas se conectam através de sockets para trocar informação. Do ponto de vista do seu código, um socket, depois de conectado, comporta-se de forma parecida com um arquivo — você escreve dados nele (para enviar) e dados dele (para receber). Essa semelhança com arquivos não é coincidência: no Unix, sockets são um tipo de descritor, manipulados por funções primas das que usamos com arquivos.

Endereços: IP e porta

Para dois programas se conectarem pela rede, é preciso saber onde encontrar o outro. Dois conceitos identificam um ponto de comunicação. O endereço IP identifica a máquina na rede (como 127.0.0.1, que é sempre a própria máquina local, o "localhost"). A porta é um número que identifica um programa específico dentro daquela máquina — como um número de apartamento dentro de um prédio. Assim, "conectar ao IP 127.0.0.1 na porta 8080" significa "falar com o programa que está escutando na porta 8080 desta máquina". Portas abaixo de 1024 são reservadas para serviços do sistema; para nossos testes, usaremos números mais altos, como 8080.

Os dois papéis: servidor e cliente

A comunicação por sockets tem dois papéis distintos. O servidor é o programa que espera por conexões — ele abre um socket, associa-o a uma porta, e fica escutando quem quer se conectar. O cliente é o programa que inicia a conexão — ele procura o servidor num endereço e porta específicos e se conecta. Pense num restaurante: o servidor (garçom) espera no local, pronto para atender; o cliente chega e faz o pedido. Cada papel usa uma sequência ligeiramente diferente de chamadas, mas ambos começam criando um socket.

Construindo um servidor

O servidor segue uma sequência de passos bem definida. Vamos construí-lo por partes. Primeiro, cria-se o socket com socket, depois associa-se a uma porta com bind, coloca-se em modo de escuta com listen, e aceita-se conexões com accept:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h> // funções e structs de rede

int main(void) {
    // 1. CRIAR o socket (IPv4, TCP)
    int servidor_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (servidor_fd < 0) {
        perror("socket");
        return 1;
    }

    // 2. configurar o endereço (IP e porta) que o servidor usará
    struct sockaddr_in endereco;
    endereco.sin_family = AF_INET;
    endereco.sin_addr.s_addr = INADDR_ANY; // aceita conexões de qualquer IP
    endereco.sin_port = htons(8080);        // porta 8080 (htons ajusta a ordem de bytes)

    // 3. BIND: associar o socket à porta
    if (bind(servidor_fd, (struct sockaddr *)&endereco, sizeof(endereco)) < 0) {
        perror("bind");
        return 1;
    }

    // 4. LISTEN: colocar o socket em modo de escuta
    listen(servidor_fd, 5); // 5 = tamanho da fila de conexões pendentes
    printf("Servidor escutando na porta 8080...\n");

    // 5. ACCEPT: esperar e aceitar uma conexão de um cliente
    int cliente_fd = accept(servidor_fd, NULL, NULL); // bloqueia até um cliente conectar
    if (cliente_fd < 0) {
        perror("accept");
        return 1;
    }
    printf("Cliente conectado!\n");

    // 6. comunicar: enviar uma mensagem ao cliente
    const char *msg = "Ola do servidor em C!\n";
    write(cliente_fd, msg, strlen(msg)); // escreve no socket como se fosse um arquivo

    // 7. FECHAR os sockets
    close(cliente_fd);
    close(servidor_fd);
    return 0;
}

Acompanhe os passos, pois cada um tem um papel. O socket cria o ponto de comunicação (aqui, AF_INET para IPv4 e SOCK_STREAM para TCP, o protocolo confiável). O bind associa esse socket à porta 8080. O listen o coloca em modo de escuta. E o accept bloqueia o programa até que um cliente se conecte — quando isso acontece, retorna um novo socket (cliente_fd) para conversar com aquele cliente específico. A partir daí, write envia dados pelo socket, exatamente como escreveríamos num arquivo. Note o htons na porta: ele ajusta a ordem de bytes para o padrão da rede (o endianness que discutimos na aula de arquivos binários reaparece aqui — redes têm sua própria convenção de ordem de bytes).

Construindo um cliente

O cliente é mais simples: cria um socket e se conecta ao endereço do servidor com connect, depois lê a mensagem:

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main(void) {
    // 1. CRIAR o socket
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0) {
        perror("socket");
        return 1;
    }

    // 2. configurar o endereço do SERVIDOR a que vamos nos conectar
    struct sockaddr_in servidor;
    servidor.sin_family = AF_INET;
    servidor.sin_port = htons(8080);
    inet_pton(AF_INET, "127.0.0.1", &servidor.sin_addr); // IP do servidor (localhost)

    // 3. CONNECT: conectar-se ao servidor
    if (connect(sock, (struct sockaddr *)&servidor, sizeof(servidor)) < 0) {
        perror("connect");
        return 1;
    }

    // 4. RECEBER a mensagem do servidor
    char buffer[256] = {0};
    int lidos = read(sock, buffer, sizeof(buffer) - 1); // lê do socket como de um arquivo
    if (lidos > 0) {
        printf("Servidor disse: %s", buffer);
    }

    // 5. FECHAR o socket
    close(sock);
    return 0;
}

O cliente cria seu socket, configura o endereço do servidor (IP 127.0.0.1 e porta 8080, os mesmos do servidor), e chama connect para estabelecer a ligação. Uma vez conectado, ele do socket com read — de novo, exatamente como leria de um arquivo — e recebe a mensagem que o servidor enviou. Para testar, você compila os dois programas, roda o servidor num terminal (ele fica esperando) e o cliente em outro (ele conecta, recebe a mensagem e sai). Seu primeiro diálogo entre dois programas pela rede.

O modelo cliente-servidor no mundo real

O que acabamos de construir é um exemplo mínimo, mas o padrão é exatamente o de toda a web. Um servidor web (como os que hospedam os sites que você visita) faz, no essencial, o que nosso servidor faz: cria um socket, faz bind numa porta (a 80 para HTTP, a 443 para HTTPS), escuta, e aceita conexões de clientes. O seu navegador é o cliente: ele faz connect ao servidor, envia uma requisição e lê a resposta. A diferença entre nosso exemplo e um servidor real está na escala e na sofisticação — servidores reais atendem muitos clientes simultaneamente (usando threads da aula Threads com pthreads ou técnicas assíncronas), falam protocolos complexos (HTTP), e lidam com segurança e erros robustamente. Mas o esqueleto — socket, bind, listen, accept no servidor; socket, connect no cliente — é o mesmo. Você acabou de tocar a fundação sobre a qual a internet inteira é construída.

A honestidade sobre a complexidade

Preciso ser transparente: programação de redes é um campo vasto, e esta aula é apenas a porta de entrada. Há muito mais — tratar múltiplos clientes ao mesmo tempo, lidar com conexões que caem, garantir que todos os bytes foram enviados (um write pode enviar menos do que você pediu), protocolos de aplicação, o UDP (uma alternativa ao TCP para casos que priorizam velocidade sobre confiabilidade), IPv6, segurança com TLS. Cada um desses tópicos renderia aulas próprias. O objetivo de hoje foi explicar o conceito fundamental — que a comunicação em rede é, na sua essência, escrever e ler bytes através de sockets, usando as mesmas ideias de descritores e I/O que já dominamos — e mostrar o esqueleto cliente-servidor funcionando. Com essa base, você tem o mapa para explorar o resto quando precisar.

O que vem a seguir

Hoje abrimos nossos programas para o mundo com sockets: o modelo cliente-servidor, os endereços de IP e porta, a sequência do servidor (socket, bind, listen, accept) e a do cliente (socket, connect), e a percepção de que, uma vez conectado, um socket se lê e se escreve como um arquivo. Tocamos a fundação da internet. Na próxima aula, a última antes do projeto final, aprenderemos sobre os sinais — mensagens assíncronas que o sistema operacional envia aos programas para notificá-los de eventos (como o Ctrl+C que você aperta para interromper um programa). Com os sinais, completaremos nosso repertório de programação de sistemas e estaremos prontos para o capstone que integra toda a jornada.

Fontes e leituras recomendadas

  • The Linux Programming Interface, Michael Kerrisk — capítulos sobre sockets
  • UNIX Network Programming, W. Richard Stevens — a bíblia da programação de redes em C
  • Beej's Guide to Network Programming — o guia introdutório mais amado sobre sockets — https://beej.us/guide/bgnet/
  • man pages — man 2 socket, man 2 bind, man 2 connect, man 2 accept
  • Computer Networking: A Top-Down Approach, Kurose & Ross — fundamentos de redes

Exercícios

Exercício 1

Compile e rode o par servidor/cliente do artigo (em dois terminais, servidor primeiro). Confirme que o cliente recebe e imprime a mensagem do servidor. Descreva a ordem em que você precisou iniciá-los e por quê.

Ver resposta

✓ Resposta: Compilando ambos (gcc servidor.c -o servidor e gcc cliente.c -o cliente), você precisa iniciar o servidor primeiro, num terminal — ele imprime "Servidor escutando na porta 8080..." e fica bloqueado no accept, esperando. Depois, num segundo terminal, roda o cliente, que se conecta, recebe a mensagem "Ola do servidor em C!", imprime-a e termina; o servidor então imprime "Cliente conectado!" e também termina. A ordem importa porque o cliente precisa ter a quem se conectar: se você rodasse o cliente primeiro, o connect falharia (não há ninguém escutando na porta 8080 ainda), retornando erro ("Connection refused"). O servidor precisa estar escutando antes de o cliente tentar conectar.

Exercício 2

Modifique o servidor para, além de enviar uma mensagem, receber uma resposta do cliente (use read no servidor e write no cliente). Faça o cliente enviar seu nome e o servidor imprimi-lo.

Ver resposta

✓ Resposta:

// no SERVIDOR, após o accept e antes de fechar:
char buffer[256] = {0};
read(cliente_fd, buffer, sizeof(buffer) - 1); // recebe do cliente
printf("Cliente disse: %s\n", buffer);
const char *resp = "Ola, recebi seu nome!\n";
write(cliente_fd, resp, strlen(resp));

// no CLIENTE, após o connect:
const char *nome = "Marcelo\n";
write(sock, nome, strlen(nome)); // envia o nome
char buffer[256] = {0};
read(sock, buffer, sizeof(buffer) - 1); // recebe a resposta
printf("Servidor respondeu: %s", buffer);

O servidor lê o nome enviado pelo cliente (com read) e o imprime; o cliente escreve seu nome (com write) e lê a resposta. A comunicação agora é bidirecional — ambos os lados escrevem e leem do socket. (Numa aplicação real, é preciso cuidado com a ordem e o sincronismo das leituras/escritas para não haver impasses, mas o exemplo ilustra o princípio.)

Exercício 3

Explique a diferença entre um endereço IP e uma porta na comunicação por sockets. Use a analogia de um prédio com apartamentos (ou outra à sua escolha).

Ver resposta

✓ Resposta: Na comunicação por sockets, o endereço IP identifica a máquina na rede — qual computador, entre todos os conectados, você quer alcançar. A porta identifica um programa específico dentro daquela máquina — qual dos vários programas rodando naquele computador deve receber a conexão. Analogia do prédio: o endereço IP é o endereço do prédio (a rua e o número que levam ao edifício correto na cidade); a porta é o número do apartamento (qual unidade dentro daquele prédio). Assim como você precisa tanto do endereço do prédio quanto do número do apartamento para entregar uma carta a uma pessoa específica, você precisa tanto do IP quanto da porta para entregar dados a um programa específico. "Conectar ao IP 127.0.0.1 na porta 8080" é como dizer "vá ao prédio nesta rua (esta máquina) e bata no apartamento 8080 (este programa)". Um único prédio (máquina) pode ter muitos apartamentos (portas) ocupados por programas diferentes, cada um escutando na sua.

Exercício 4

No servidor, explique o papel de cada uma das quatro chamadas principais: socket, bind, listen e accept. Qual delas bloqueia o programa esperando por um cliente?

Ver resposta

✓ Resposta: No servidor, as quatro chamadas principais têm papéis sequenciais: socket cria o ponto de comunicação — reserva um descritor de socket, especificando o tipo de rede (IPv4) e protocolo (TCP); é o primeiro passo, mas o socket ainda não está associado a nada. bind associa o socket a um endereço e porta específicos (por exemplo, a porta 8080) — é o que diz "este socket vai receber conexões nesta porta"; sem bind, o sistema não saberia onde o servidor espera conexões. listen coloca o socket em modo de escuta — marca-o como um socket passivo, disposto a aceitar conexões de entrada, e define o tamanho da fila de conexões pendentes. accept aceita uma conexão de um cliente — retira um cliente da fila de espera e retorna um novo socket, dedicado à comunicação com aquele cliente específico (o socket original continua escutando por mais conexões). A chamada que bloqueia o programa esperando por um cliente é o accept: se nenhum cliente ainda se conectou, accept pausa a execução do servidor até que um chegue — é onde o servidor "espera" no exemplo. As outras três (socket, bind, listen) são passos de configuração que executam rapidamente e não esperam.

Exercício 5

Explique por que, uma vez estabelecida a conexão, dizemos que um socket "se comporta como um arquivo". Que funções, já conhecidas de aulas anteriores, são usadas para enviar e receber dados através dele?

Ver resposta

✓ Resposta: Uma vez estabelecida a conexão, dizemos que um socket "se comporta como um arquivo" porque, no Unix, um socket é representado por um descritor — o mesmo tipo de identificador numérico que representa arquivos abertos — e pode ser lido e escrito com as mesmas funções primitivas usadas para arquivos. Você envia dados pela conexão escrevendo no socket, e recebe dados lendo dele, exatamente como faria com um arquivo no disco: os bytes que você escreve num lado saem no outro, e os bytes que chegam você lê. Essa uniformidade é uma decisão elegante do design do Unix — a ideia de que "tudo é um arquivo" (ou um descritor), permitindo tratar arquivos, sockets, pipes e outros de forma consistente. As funções usadas para enviar e receber dados através de um socket são justamente write (para enviar) e read (para receber) — as mesmas funções de baixo nível de I/O que operam sobre descritores. (Existem também funções específicas de socket, send e recv, que oferecem opções extras, mas write e read funcionam perfeitamente para o caso básico.) Assim, todo o conhecimento de I/O que construímos ao longo do curso se transfere diretamente para a comunicação em rede: uma vez conectado, um socket é apenas mais um lugar de onde se lê e para onde se escrevem bytes.

Comentários

Mais em Linguagem C

O Pré-processador: #define, Macros e Inclusões
O Pré-processador: #define, Macros e Inclusões

O quadrado de 2 + 3 dá 11 quando a macro esquece os parênteses — o…

Tabelas Hash: Busca em Tempo Constante
Tabelas Hash: Busca em Tempo Constante

Em vez de procurar onde o dado está, calcula-se onde ele deveria estar — e é…

Arquivos Binários e Posicionamento no Disco
Arquivos Binários e Posicionamento no Disco

O inteiro 12345 ocupa cinco bytes gravado como texto e quatro gravado como…