Skip to main content


Programas que escutam portas​


Programas que trabalham com redes podem atuar como clientes ou servidores. Um cliente inicia uma comunicação com outro programa, enquanto um servidor disponibiliza um endereço e uma porta para receber conexões ou mensagens. Em Go, podemos criar servidores usando o pacote net, que fornece recursos para trabalhar com diferentes protocolos de rede, incluindo TCP e UDP.


Quando um programa começa a escutar uma porta, ele solicita ao sistema operacional que disponibilize um recurso de comunicação associado a determinado endereço e protocolo. Esse recurso é conhecido como socket e representa um ponto de comunicação entre processos. O programa usa esse socket para receber conexões TCP ou datagramas UDP, dependendo do protocolo escolhido.


Embora TCP e UDP trabalhem com endereços IP e portas, eles possuem comportamentos diferentes. TCP estabelece conexões e fornece um fluxo ordenado de bytes, enquanto UDP permite enviar e receber datagramas sem precisar estabelecer uma conexão antes da comunicação. Essas diferenças afetam a maneira como criamos servidores, recebemos informações, tratamos erros e encerramos as operações.



Criando um servidor TCP​


Um servidor TCP é um programa que disponibiliza uma porta para receber conexões de outros programas. TCP, que significa Transmission Control Protocol, é um protocolo orientado a conexão, responsável por fornecer um fluxo de bytes confiável e ordenado entre dois pontos de comunicação. Antes que os dados sejam transmitidos normalmente, o protocolo precisa estabelecer uma conexão entre o cliente e o servidor.


Para receber essas conexões, o servidor precisa criar um socket, associá-lo a um endereço local e colocá-lo em estado de escuta. Esse processo é conhecido como bind e listen, que representam a associação do socket a um endereço e a preparação para receber conexões. Em Go, podemos realizar essas operações por meio da função net.Listen, sem precisar trabalhar diretamente com as chamadas de baixo nível do sistema operacional.


Depois que o servidor começa a escutar, ele pode aceitar as conexões recebidas e trabalhar com cada cliente. Uma conexão TCP aceita representa um canal de comunicação entre o servidor e um cliente específico, permitindo que os dois lados enviem e recebam dados. O socket responsável pela escuta permanece separado dessas conexões e pode continuar recebendo novos clientes.



Criando o primeiro servidor TCP​


O pacote net fornece a função net.Listen, responsável por criar um listener para determinado protocolo e endereço local. Um listener é um objeto que representa um recurso preparado para receber conexões. A função recebe o tipo de rede e o endereço em que o programa deverá escutar, retornando um valor que implementa net.Listener ou um erro.


Vamos criar um servidor TCP que escuta a porta 9000 no endereço 127.0.0.1. Esse endereço representa a interface de loopback, usada para comunicação com a própria máquina. Isso permite testar o servidor localmente sem disponibilizar sua porta diretamente para outras máquinas da rede.

package main

import (
"fmt"
"net"
"os"
)

func main() {
// Cria um servidor TCP na interface local.
listener, err := net.Listen(
"tcp4",
"127.0.0.1:9000",
)

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao iniciar servidor:",
err,
)

return
}

// Fecha o listener quando a função terminar.
defer listener.Close()

fmt.Println("Servidor escutando em 127.0.0.1:9000")

// Aguarda a chegada de uma conexão TCP.
conn, err := listener.Accept()

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao aceitar conexão:",
err,
)

return
}

// Fecha a conexão quando não for mais necessária.
defer conn.Close()

fmt.Println(
"Cliente conectado:",
conn.RemoteAddr(),
)
}

A função net.Listen recebe "tcp4" como primeiro argumento, indicando que o servidor deverá trabalhar com TCP sobre IPv4. O segundo argumento representa o endereço local e a porta em que o programa deverá escutar. Quando a operação é concluída, recebemos um listener que pode ser usado para aceitar conexões, enquanto uma falha na criação é informada pelo valor err.


O método listener.Accept() aguarda até que uma conexão seja recebida ou aconteça algum erro. Enquanto não existir uma conexão disponível, essa chamada normalmente permanece bloqueada, sem precisar executar um laço que consulte continuamente o estado da porta. Quando um cliente estabelece uma conexão, Accept retorna um valor que implementa net.Conn, representando a comunicação com aquele cliente.


O método conn.RemoteAddr() retorna o endereço remoto associado à conexão, permitindo identificar o IP e a porta usados pelo cliente. Já defer conn.Close() registra o fechamento da conexão para quando a função terminar. Nesse exemplo, o servidor aceita apenas um cliente e encerra sua execução logo depois, porque ainda não implementamos um laço para receber novas conexões.



Entendendo o listener e a conexão​


O listener e a conexão aceita representam recursos diferentes. O listener fica associado ao endereço em que o servidor escuta e é responsável por receber novas conexões, enquanto o objeto retornado por Accept representa a comunicação com um cliente específico. Essa separação permite que o servidor continue aceitando novos clientes mesmo quando já existem conexões estabelecidas.


O tipo net.Listener é uma interface, que define um conjunto de métodos disponíveis para os objetos que a implementam. Nesse caso, os principais métodos são Accept, Close e Addr. O método Accept recebe uma conexão, Close encerra o listener e Addr retorna o endereço local em que ele está escutando.


Já net.Conn representa uma conexão de rede orientada a fluxo e disponibiliza métodos como Read, Write, Close, LocalAddr e RemoteAddr. Os métodos Read e Write permitem receber e enviar dados, enquanto Close encerra a conexão. Também existem métodos para definir limites de tempo durante operações de leitura e escrita, que serão apresentados posteriormente.


Quando fechamos uma conexão aceita, apenas aquela comunicação é encerrada. O listener pode continuar aberto e receber novos clientes. Por outro lado, fechar o listener impede que novas conexões sejam aceitas, mas não encerra automaticamente as conexões que já foram estabelecidas. Esse comportamento precisa ser considerado quando implementamos servidores que atendem vários clientes.



Escolhendo o endereço e a porta​


O endereço informado em net.Listen determina onde o servidor ficará disponível. Quando usamos 127.0.0.1, o programa escuta somente na interface de loopback IPv4, permitindo conexões locais. Se quisermos receber conexões de outras máquinas, precisamos associar o listener a um endereço de rede apropriado e garantir que as regras de firewall permitam essa comunicação.


Também podemos informar somente a porta, deixando a parte do endereço vazia. Nesse caso, o sistema pode criar um listener associado aos endereços locais disponíveis, de acordo com o protocolo solicitado. Para trabalhar especificamente com IPv4, podemos usar "tcp4" e informar o endereço 0.0.0.0, que representa todas as interfaces IPv4 locais.

// Escuta a porta 9000 em todas as interfaces IPv4 locais.
listener, err := net.Listen(
"tcp4",
"0.0.0.0:9000",
)

Esse trecho pressupõe que os pacotes net e os foram importados e que o erro retornado será tratado antes de usar o listener. Associar o servidor a 0.0.0.0 não significa que ele ficará acessível automaticamente pela internet, pois isso depende do roteamento, das regras de firewall e das interfaces disponíveis. Entretanto, essa configuração permite receber conexões destinadas aos endereços IPv4 locais, aumentando a exposição do serviço.


Também podemos permitir que o sistema operacional escolha uma porta disponível, informando o valor 0 como porta de escuta. Essa abordagem é comum durante testes automatizados, porque evita depender de uma porta fixa que pode estar ocupada por outro processo. Depois da criação, podemos consultar o endereço real por meio de listener.Addr().

// Solicita uma porta disponível ao sistema operacional.
listener, err := net.Listen(
"tcp4",
"127.0.0.1:0",
)

if err != nil {
return
}

defer listener.Close()

// Exibe o endereço e a porta realmente escolhidos.
fmt.Println(listener.Addr())

Esse trecho representa parte de uma função e pressupõe que os pacotes net e fmt foram importados. Como o sistema escolhe a porta, não podemos determinar antecipadamente qual número será retornado. O método Addr permite descobrir a porta escolhida, evitando precisar consultar manualmente os sockets disponíveis no sistema operacional.


A escolha da porta também precisa considerar permissões e conflitos com outros processos. Em sistemas Unix, portas abaixo de 1024 normalmente exigem permissões especiais ou capacidades específicas, dependendo da configuração do sistema. Além disso, se outro processo já estiver escutando no mesmo endereço e porta com uma configuração incompatível, a criação do listener poderá falhar.



Executando e testando o servidor​


Podemos iniciar o servidor usando go run, que compila e executa o programa. Depois de iniciado, ele permanecerá aguardando uma conexão na porta configurada, porque a chamada de Accept bloqueia até que exista um cliente disponível. Para testar o comportamento, podemos manter o servidor executando em um terminal e abrir outro terminal para estabelecer uma conexão.

go run main.go

A saída inicial será:

Servidor escutando em 127.0.0.1:9000

Em outro terminal, podemos usar uma ferramenta como nc, também conhecida como netcat, caso esteja instalada no sistema. Ela permite estabelecer conexões TCP diretamente pelo terminal, sendo conveniente para verificar o funcionamento de servidores simples.

nc 127.0.0.1 9000

Quando a conexão for estabelecida, o servidor apresentará uma mensagem semelhante a esta:

Cliente conectado: 127.0.0.1:54321

O endereço apresentado identifica o cliente conectado. A porta 54321 representa apenas um exemplo, porque o sistema operacional normalmente escolhe uma porta local disponível para a conexão iniciada pelo cliente. Como o servidor ainda não possui operações de leitura e escrita, ele apenas aceita a conexão, apresenta o endereço remoto e encerra sua execução.


A ferramenta nc não faz parte do Go e pode não estar instalada em todos os sistemas. Sua sintaxe e algumas opções também variam entre implementações, especialmente no Windows. Podemos usar outras ferramentas de conexão TCP para realizar o mesmo teste, desde que sejam capazes de se conectar ao endereço e à porta em que o servidor está escutando.



Aceitando múltiplas conexões TCP​


O servidor anterior aceita somente uma conexão porque executa Accept uma única vez. Para continuar recebendo clientes, precisamos colocar essa operação dentro de um laço. Dessa maneira, depois que uma conexão for aceita, o programa poderá voltar a aguardar a próxima sem precisar reiniciar o servidor.


Entretanto, precisamos considerar como cada cliente será atendido. Se o servidor executar uma operação demorada diretamente dentro do laço, ele não voltará a chamar Accept enquanto essa operação não terminar. Isso pode atrasar o atendimento de outros clientes, mesmo que o sistema operacional já tenha recebido novas solicitações de conexão.


Uma maneira de organizar esse comportamento é usar goroutines. Cada conexão aceita pode ser encaminhada para uma função executada concorrentemente, permitindo que o laço principal continue recebendo novos clientes. Como as goroutines são gerenciadas pelo runtime do Go, não precisamos criar e administrar manualmente uma thread do sistema operacional para cada conexão.



Criando um laço para receber conexões​


Podemos usar um laço for para executar listener.Accept() continuamente. Quando uma conexão é recebida, o programa cria uma goroutine responsável por atendê-la e retorna imediatamente ao início do laço. Isso permite manter várias conexões em andamento durante o mesmo período, sem exigir que uma termine antes de aceitar a próxima.


Vamos criar uma função chamada atenderCliente, que será responsável por receber uma conexão e executar suas operações. Nesse primeiro exemplo, o servidor enviará uma mensagem de boas-vindas e aguardará o cliente encerrar a comunicação. O objetivo é demonstrar a separação entre a aceitação de conexões e o atendimento de cada cliente.

package main

import (
"fmt"
"io"
"net"
"os"
)

func atenderCliente(conn net.Conn) {
// Fecha a conexão quando o atendimento terminar.
defer conn.Close()

fmt.Println("Cliente conectado:", conn.RemoteAddr())

// Envia uma mensagem inicial ao cliente.
_, err := io.WriteString(
conn,
"Conexão estabelecida.\n",
)

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao enviar mensagem:",
err,
)

return
}

// Aguarda o cliente encerrar o envio de dados.
_, err = io.Copy(io.Discard, conn)

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro durante a leitura:",
err,
)
}

fmt.Println("Cliente desconectado:", conn.RemoteAddr())
}

func main() {
listener, err := net.Listen(
"tcp4",
"127.0.0.1:9000",
)

if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}

defer listener.Close()

fmt.Println("Servidor escutando em", listener.Addr())

for {
// Aguarda uma nova conexão TCP.
conn, err := listener.Accept()

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao aceitar conexão:",
err,
)

return
}

// Atende o cliente em uma goroutine independente.
go atenderCliente(conn)
}
}

A função main cria o listener e entra em um laço que permanece aguardando novas conexões. Sempre que Accept retorna uma conexão válida, a instrução go atenderCliente(conn) inicia uma goroutine responsável por aquele cliente. Como o atendimento acontece separadamente, o laço principal pode voltar a chamar Accept enquanto a conexão anterior continua aberta.


A função atenderCliente recebe um valor do tipo net.Conn, que representa a conexão estabelecida. Depois de apresentar o endereço remoto, ela envia uma mensagem usando io.WriteString, que escreve uma string em um destino compatível com operações de escrita. Como net.Conn implementa essa capacidade, podemos enviar texto diretamente pela conexão sem precisar converter manualmente a string em bytes.


A chamada io.Copy(io.Discard, conn) lê os dados recebidos e os descarta até que a leitura termine. O valor io.Discard representa um destino que aceita dados sem armazená-los, sendo usado aqui apenas para manter a conexão em leitura enquanto o cliente continua enviando informações. Quando o cliente encerra o fluxo de envio e não existem mais dados pendentes, a leitura normalmente termina e a função pode finalizar o atendimento.


Esse exemplo ainda não possui timeout para clientes que permanecem conectados sem enviar ou encerrar dados. Uma conexão pode ficar aberta por tempo indeterminado, mantendo sua goroutine ativa. Também não existe um limite para a quantidade de clientes atendidos simultaneamente, por isso o código deve ser considerado um exemplo inicial, e não uma implementação completa para ambientes que recebem muitas conexões.



Entendendo a execução concorrente​


Quando diferentes clientes se conectam, cada um recebe uma conexão própria e uma goroutine responsável pelo atendimento. Isso permite que um cliente permaneça aguardando uma operação de leitura enquanto outro recebe uma resposta do servidor. As conexões não precisam compartilhar diretamente seus buffers de leitura ou escrita, porque cada goroutine recebe o objeto correspondente ao cliente que está atendendo.


A ordem de execução das goroutines não é garantida. Um cliente que se conecta primeiro pode terminar depois de outro, dependendo da quantidade de dados transmitidos, do comportamento da rede e das operações realizadas pelo programa. Por isso, não devemos depender da ordem de criação das goroutines para determinar a ordem de conclusão das conexões.


Também precisamos lembrar que iniciar uma goroutine não significa que todos os recursos associados a ela serão liberados automaticamente. Se uma operação permanecer bloqueada durante muito tempo, a goroutine continuará existindo enquanto aguarda seu resultado. Em servidores maiores, precisamos combinar o atendimento concorrente com timeouts, limites de conexões e mecanismos de encerramento.



Identificando as conexões dos clientes​


Cada conexão TCP possui um endereço local e um endereço remoto. O endereço local representa o ponto da comunicação pertencente ao servidor, enquanto o endereço remoto identifica o cliente conectado. Podemos consultar essas informações usando os métodos LocalAddr e RemoteAddr, disponíveis na interface net.Conn.


Esses métodos retornam valores que implementam net.Addr, permitindo consultar informações associadas ao endereço de rede. Podemos converter esses valores para strings quando precisamos apresentá-los em mensagens de diagnóstico ou registros de conexão.

func atenderCliente(conn net.Conn) {
// Fecha a conexão ao terminar o atendimento.
defer conn.Close()

// Identifica os dois pontos da comunicação.
local := conn.LocalAddr()
remoto := conn.RemoteAddr()

fmt.Println("Endereço local:", local)
fmt.Println("Endereço remoto:", remoto)
}

Esse trecho pressupõe que os pacotes net e fmt foram importados. Para uma conexão TCP, os endereços normalmente incluem o IP e a porta associados a cada lado da comunicação. Entretanto, esses valores identificam os pontos da conexão de rede e não devem ser tratados como uma identificação confiável de quem está usando o serviço.


O endereço remoto pode ser usado para registrar conexões e auxiliar em diagnósticos, mas não substitui mecanismos de autenticação ou autorização. Em ambientes que possuem proxies, NAT ou outros intermediários, o endereço observado pelo servidor também pode não representar diretamente a máquina ou o usuário responsável pela operação original.



Lendo e respondendo dados recebidos​


Depois que uma conexão TCP é estabelecida, o servidor pode receber e enviar dados usando os métodos Read e Write. Essas operações fazem parte da interface net.Conn e permitem trabalhar com os bytes transmitidos entre os dois lados da comunicação. Como TCP fornece um fluxo de bytes, precisamos definir como o programa interpretará as informações recebidas.


Uma característica do TCP é que ele não preserva automaticamente os limites das mensagens escritas pelo programa. Se um cliente executar duas operações de escrita, o servidor poderá receber esses dados em uma ou várias operações de leitura. Isso acontece porque o protocolo organiza a comunicação como um fluxo contínuo, enquanto a divisão das mensagens precisa ser definida pela aplicação.


Podemos criar protocolos simples estabelecendo regras para identificar onde uma mensagem começa e termina. Uma possibilidade é usar um caractere de quebra de linha para separar mensagens de texto. Outra é informar o tamanho de cada mensagem antes de enviar seu conteúdo, permitindo que o servidor saiba quantos bytes precisa receber. Antes disso, vamos entender como funciona uma leitura direta usando Read.



Lendo dados com Read​


O método Read recebe um slice de bytes que será usado como buffer para armazenar os dados recebidos. Um buffer é uma região de memória reservada temporariamente para operações de entrada e saída. Quando a leitura acontece, o método retorna a quantidade de bytes copiados para o buffer e um possível erro.


Podemos criar uma função que recebe dados de uma conexão TCP e apresenta o conteúdo recebido. O programa fará uma leitura e encerrará o atendimento depois de processar o resultado. Esse exemplo demonstra apenas uma operação de leitura, por isso não deve ser interpretado como uma implementação capaz de receber mensagens de tamanho arbitrário.

func atenderCliente(conn net.Conn) {
// Fecha a conexão quando a função terminar.
defer conn.Close()

// Reserva espaço para receber até 1024 bytes.
buffer := make([]byte, 1024)

// Aguarda dados enviados pelo cliente.
n, err := conn.Read(buffer)

if n > 0 {
// Processa somente os bytes realmente recebidos.
fmt.Printf(
"Recebido de %s: %q\n",
conn.RemoteAddr(),
buffer[:n],
)
}

if err != nil {
fmt.Println("Erro de leitura:", err)
}
}

Esse trecho representa uma função de atendimento e pressupõe que os pacotes net e fmt foram importados. O buffer possui capacidade para armazenar até 1024 bytes, mas isso não significa que uma leitura preencherá todo esse espaço. O método Read pode retornar uma quantidade menor de dados, mesmo quando a conexão continua aberta e novos bytes ainda serão enviados posteriormente.


Por isso, usamos buffer[:n] para acessar somente os bytes realmente recebidos. Se Read retornar n = 5, apenas os cinco primeiros bytes do buffer pertencem ao resultado daquela leitura. Também precisamos processar os dados quando n > 0, mesmo que o método retorne um erro, porque a interface permite que dados válidos e uma condição de erro sejam informados na mesma operação.


O formato %q apresenta os bytes de maneira que caracteres especiais possam ser identificados, sendo conveniente para demonstrações e diagnósticos. Em aplicações reais, precisamos considerar se o conteúdo recebido representa texto, dados binários ou informações sensíveis. Também não devemos assumir que os bytes recebidos formam uma string válida em UTF-8 ou que representam uma mensagem completa.



Lendo continuamente e respondendo ao cliente​


Quando queremos manter a comunicação ativa, precisamos executar várias operações de leitura. Podemos criar um laço que recebe os dados enviados pelo cliente e devolve o conteúdo pela mesma conexão. Esse comportamento é conhecido como echo, porque o servidor responde com os mesmos bytes que recebeu.


O exemplo seguinte usa Read para receber blocos de dados e Write para devolvê-los. Como TCP trabalha com um fluxo, o servidor não tenta identificar mensagens individuais, apenas responde a cada bloco recebido. Essa abordagem é útil para demonstrar a comunicação bidirecional, mas não representa um protocolo de mensagens estruturadas.

package main

import (
"errors"
"fmt"
"io"
"net"
"os"
)

func atenderCliente(conn net.Conn) {
defer conn.Close()

// Reserva um buffer para receber os dados.
buffer := make([]byte, 1024)

for {
// Lê o próximo bloco disponível na conexão.
n, err := conn.Read(buffer)

if n > 0 {
// Devolve os bytes recebidos ao cliente.
_, writeErr := conn.Write(buffer[:n])

if writeErr != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao responder:",
writeErr,
)

return
}
}

if err != nil {
// EOF indica que o fluxo de entrada terminou.
if !errors.Is(err, io.EOF) {
fmt.Fprintln(
os.Stderr,
"Erro ao receber dados:",
err,
)
}

return
}
}
}

func main() {
listener, err := net.Listen(
"tcp4",
"127.0.0.1:9000",
)

if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}

defer listener.Close()

fmt.Println("Servidor TCP:", listener.Addr())

for {
conn, err := listener.Accept()

if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}

// Atende cada conexão separadamente.
go atenderCliente(conn)
}
}

A função atenderCliente cria um buffer de 1024 bytes e executa um laço de leitura. Sempre que recebe dados, utiliza conn.Write(buffer[:n]) para devolver somente os bytes disponíveis naquele bloco. Como a mesma conexão permite operações de leitura e escrita, o servidor consegue receber informações e responder ao cliente sem precisar criar outra conexão.


Quando Read retorna io.EOF, significa que o fluxo de entrada chegou ao fim de maneira normal. Isso pode acontecer quando o cliente encerra o envio de dados. O programa diferencia essa situação de outros erros porque o encerramento normal de uma conexão não precisa ser apresentado como falha, enquanto problemas de rede podem exigir uma mensagem de diagnóstico.


O método Write retorna a quantidade de bytes escritos e um possível erro. No exemplo, verificamos o erro, mas não usamos a quantidade retornada porque seguimos o contrato de escrita da interface io.Writer, no qual uma escrita curta deve ser acompanhada de erro. Se a operação falhar depois de escrever parte dos dados, a resposta poderá estar incompleta e precisamos tratar essa condição conforme as regras do protocolo.


Esse servidor devolve os bytes conforme as operações de leitura acontecem, sem garantir que cada resposta corresponda a uma escrita individual do cliente. Uma mensagem pode ser dividida em vários blocos ou diferentes envios podem ser recebidos juntos. Para trabalhar com mensagens de texto completas, precisamos estabelecer uma regra que indique onde cada mensagem termina.



Separando mensagens por quebra de linha​


Uma maneira simples de definir os limites de mensagens TCP é estabelecer que cada mensagem termina com uma quebra de linha. Nesse modelo, o cliente envia uma sequência de caracteres seguida por \n, enquanto o servidor continua lendo até encontrar esse delimitador. Esse formato é conveniente para protocolos textuais simples e ferramentas de teste executadas pelo terminal.


O pacote bufio possui o tipo Scanner, que facilita a leitura de textos divididos em linhas. Quando criamos um scanner com bufio.NewScanner, sua configuração padrão permite percorrer linhas de texto, removendo o delimitador de quebra de linha. Isso evita precisar procurar manualmente pelo caractere \n em cada bloco recebido.


Vamos criar um servidor que recebe uma linha de texto e responde com a mesma mensagem, acrescentando o prefixo Eco. O atendimento continuará enquanto o cliente enviar novas linhas ou até que a conexão seja encerrada.

package main

import (
"bufio"
"fmt"
"io"
"net"
"os"
)

func atenderCliente(conn net.Conn) {
defer conn.Close()

// Cria um leitor que interpreta mensagens por linha.
scanner := bufio.NewScanner(conn)

for scanner.Scan() {
// Obtém a linha recebida sem o delimitador.
mensagem := scanner.Text()

fmt.Printf(
"%s enviou: %s\n",
conn.RemoteAddr(),
mensagem,
)

// Responde usando a mesma conexão TCP.
_, err := io.WriteString(
conn,
"Eco: "+mensagem+"\n",
)

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao responder:",
err,
)

return
}
}

// Verifica se a leitura terminou por algum erro.
if err := scanner.Err(); err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao ler mensagem:",
err,
)
}
}

func main() {
listener, err := net.Listen(
"tcp4",
"127.0.0.1:9000",
)

if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}

defer listener.Close()

fmt.Println("Servidor TCP:", listener.Addr())

for {
conn, err := listener.Accept()

if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}

go atenderCliente(conn)
}
}

A função bufio.NewScanner recebe a conexão e cria um scanner responsável por interpretar os dados recebidos. O método Scan tenta obter a próxima linha, retornando true quando existe uma mensagem disponível. Depois disso, Text retorna o conteúdo da linha como string, permitindo que o programa trabalhe diretamente com a mensagem sem precisar processar manualmente os bytes do buffer.


O servidor responde usando io.WriteString, acrescentando \n ao final da mensagem. Essa quebra de linha permite que clientes que também trabalham com leitura por linhas identifiquem onde a resposta termina. Sem esse delimitador, um cliente que aguarda uma linha completa poderia permanecer bloqueado, mesmo que parte dos dados já tivesse sido recebida.


Quando Scan retorna false, significa que a leitura terminou ou ocorreu algum erro. O método scanner.Err() permite diferenciar essas situações, retornando nil quando a leitura terminou normalmente. Isso é necessário porque o encerramento do laço não indica sozinho se o cliente fechou a conexão ou se aconteceu uma falha durante o recebimento.


O Scanner possui um limite padrão para o tamanho dos tokens, associado a um buffer máximo de 64 KiB. Uma linha muito grande pode ultrapassar esse limite e provocar um erro de leitura. Podemos alterar essa configuração usando o método Buffer, mas também precisamos definir um tamanho máximo adequado para a aplicação, evitando aceitar mensagens excessivamente grandes sem necessidade.



Testando o servidor de mensagens​


Depois de iniciar o servidor, podemos abrir outro terminal e estabelecer uma conexão usando netcat. Como o servidor interpreta mensagens separadas por quebra de linha, basta digitar uma mensagem e pressionar Enter para que o terminal envie o conteúdo completo. O servidor receberá a linha, acrescentará o prefixo Eco e devolverá a resposta pela mesma conexão.

nc 127.0.0.1 9000

Se digitarmos Olá servidor e pressionarmos Enter, teremos uma interação semelhante a esta:

Olá servidor
Eco: Olá servidor

A primeira linha representa a mensagem digitada no cliente, enquanto a segunda representa a resposta enviada pelo servidor. O comportamento de exibição pode variar conforme a implementação do netcat e as configurações do terminal, mas a resposta enviada pelo servidor seguirá o formato definido no código. Podemos continuar enviando mensagens enquanto a conexão permanecer aberta.


Esse modelo de comunicação é suficiente para protocolos textuais simples, mas não deve ser aplicado automaticamente a qualquer tipo de dado. Protocolos binários podem transportar bytes que não representam texto ou conter quebras de linha dentro de uma mensagem. Nesses casos, precisamos usar outro mecanismo de separação, como um campo que informa previamente o tamanho da mensagem.



Trabalhando com mensagens de tamanho definido​


Outra maneira de identificar os limites de mensagens TCP é enviar primeiro uma informação que representa o tamanho do conteúdo. O servidor lê esse tamanho e depois recebe exatamente a quantidade de bytes indicada. Esse modelo permite transportar mensagens que não dependem de caracteres especiais, incluindo dados binários que podem conter qualquer valor de byte.


O pacote io fornece a função io.ReadFull, que tenta preencher completamente um buffer com os dados recebidos. Diferentemente de uma chamada simples a Read, essa função continua realizando leituras enquanto ainda faltarem bytes, até completar o buffer ou encontrar um erro. Isso é útil quando o protocolo já informa quantos bytes devem ser recebidos.


O trecho seguinte demonstra a leitura de uma mensagem que possui exatamente cinco bytes. Ele pressupõe que conn representa uma conexão TCP válida e que os pacotes io e fmt foram importados.

// Reserva espaço para receber exatamente cinco bytes.
buffer := make([]byte, 5)

// Continua lendo até preencher o buffer ou encontrar um erro.
_, err := io.ReadFull(conn, buffer)

if err != nil {
return fmt.Errorf(
"receber mensagem: %w",
err,
)
}

// Processa os cinco bytes recebidos.
fmt.Printf("Mensagem: %q\n", buffer)

Esse trecho deve ser colocado dentro de uma função que retorna error. A função io.ReadFull recebe o leitor e o buffer que deverá ser preenchido, retornando a quantidade de bytes copiados e um possível erro. Se a conexão terminar antes que todos os bytes sejam recebidos, a operação retornará io.EOF ou io.ErrUnexpectedEOF, dependendo de quantos bytes já tinham sido lidos.


Em um protocolo real baseado em tamanho, precisamos definir como esse valor será transmitido. Podemos reservar uma quantidade fixa de bytes para representar o comprimento da mensagem e depois interpretar esse valor antes de chamar io.ReadFull. Também precisamos validar o tamanho recebido, evitando que um cliente solicite a alocação de buffers excessivamente grandes ou envie informações incompatíveis com as regras do protocolo.



Criando um servidor UDP​


Um servidor UDP também disponibiliza um endereço e uma porta para receber informações de outros programas, mas seu funcionamento é diferente de um servidor TCP. UDP, que significa User Datagram Protocol, é um protocolo orientado a datagramas e não exige o estabelecimento de uma conexão antes do envio de dados. Cada datagrama representa uma unidade de informação transmitida de forma independente.


Como não existe uma conexão TCP para cada cliente, o servidor UDP normalmente recebe diretamente os datagramas enviados para sua porta. Cada operação de recebimento também informa o endereço de origem, permitindo identificar para onde uma resposta deverá ser enviada. Isso significa que um único socket UDP pode receber mensagens de diferentes clientes sem precisar executar uma operação equivalente a Accept.


UDP não garante que um datagrama chegará ao destino, nem que os datagramas serão recebidos na mesma ordem em que foram enviados. Também não fornece automaticamente retransmissão ou controle de fluxo equivalente ao TCP. Quando uma aplicação precisa dessas características sobre UDP, elas devem ser implementadas por mecanismos próprios ou por protocolos construídos sobre ele.



Criando um socket UDP​


O pacote net fornece a função net.ListenPacket, que permite criar um socket preparado para receber datagramas. Ela recebe o tipo de rede e o endereço local, retornando um valor que implementa net.PacketConn ou um erro. Essa interface possui métodos para receber e enviar datagramas, além de consultar o endereço local, configurar deadlines e encerrar o socket.


Vamos criar um servidor UDP que escuta a porta 9001 no endereço 127.0.0.1. O programa permanecerá aguardando datagramas e apresentará as informações recebidas, incluindo o endereço de origem. Nesse primeiro exemplo, o servidor apenas recebe os dados, sem enviar uma resposta.

package main

import (
"fmt"
"net"
"os"
)

func main() {
// Cria um socket UDP na interface local.
conn, err := net.ListenPacket(
"udp4",
"127.0.0.1:9001",
)

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao iniciar servidor UDP:",
err,
)

return
}

// Fecha o socket quando a função terminar.
defer conn.Close()

fmt.Println("Servidor UDP:", conn.LocalAddr())

// Reserva espaço para receber datagramas.
buffer := make([]byte, 2048)

for {
// Aguarda um datagrama e identifica sua origem.
n, endereco, err := conn.ReadFrom(buffer)

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao receber datagrama:",
err,
)

return
}

// Processa somente os bytes recebidos.
fmt.Printf(
"Recebido de %s: %q\n",
endereco,
buffer[:n],
)
}
}

A função net.ListenPacket recebe "udp4" para indicar que o socket deverá trabalhar com UDP sobre IPv4. O endereço 127.0.0.1:9001 define a interface e a porta em que os datagramas serão recebidos. Quando a operação é concluída, o método LocalAddr permite consultar o endereço associado ao socket criado.


O método ReadFrom aguarda a chegada de um datagrama e retorna três valores. O primeiro informa quantos bytes foram copiados para o buffer, o segundo identifica o endereço de origem e o terceiro representa um possível erro. Diferentemente de Read em uma conexão TCP, essa operação trabalha com unidades de dados preservadas pelo protocolo UDP.


O programa usa buffer[:n] para acessar somente a quantidade de bytes recebidos. Isso evita processar regiões do buffer que não pertencem ao datagrama atual, incluindo dados que possam ter permanecido na memória depois de leituras anteriores. Como o mesmo buffer é reutilizado em todas as iterações, precisamos terminar o processamento dos dados antes que uma nova leitura sobrescreva seu conteúdo.


O servidor permanece executando o laço enquanto consegue receber datagramas. Como UDP não exige uma conexão estabelecida, não existe uma operação de Accept nem um objeto de conexão separado para cada remetente. O mesmo socket recebe mensagens de diferentes origens, permitindo que a aplicação identifique cada remetente pelo endereço retornado em ReadFrom.



Enviando respostas UDP​


Depois de receber um datagrama, podemos responder ao endereço informado pelo método ReadFrom. A interface net.PacketConn fornece o método WriteTo, que recebe os bytes que serão enviados e o endereço de destino. Isso permite utilizar o mesmo socket do servidor para responder diretamente ao remetente, sem precisar estabelecer uma conexão antes da transmissão.


Vamos modificar o servidor para criar uma resposta contendo o prefixo Eco. Depois que um datagrama for recebido, o programa converterá seu conteúdo para uma string, montará uma mensagem de resposta e enviará os bytes resultantes para o endereço de origem.

package main

import (
"fmt"
"net"
"os"
)

func main() {
// Cria o socket UDP do servidor.
conn, err := net.ListenPacket(
"udp4",
"127.0.0.1:9001",
)

if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}

defer conn.Close()

fmt.Println("Servidor UDP:", conn.LocalAddr())

buffer := make([]byte, 2048)

for {
// Recebe um datagrama e seu endereço de origem.
n, endereco, err := conn.ReadFrom(buffer)

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao receber:",
err,
)

return
}

// Obtém somente os bytes do datagrama recebido.
mensagem := string(buffer[:n])

fmt.Printf(
"%s enviou: %q\n",
endereco,
mensagem,
)

// Monta uma resposta para o remetente.
resposta := []byte("Eco: " + mensagem)

// Envia o datagrama de resposta.
_, err = conn.WriteTo(resposta, endereco)

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao responder:",
err,
)
}
}
}

O servidor recebe os dados usando ReadFrom e armazena o endereço de origem na variável endereco. Depois, monta a resposta e chama WriteTo, passando os bytes que serão enviados e o endereço para onde o datagrama deverá ser transmitido. Como o endereço foi obtido diretamente da operação de recebimento, o servidor consegue responder ao mesmo ponto que enviou a mensagem original.


A conversão string(buffer[:n]) é adequada para esse exemplo porque estamos tratando as mensagens como texto. Entretanto, UDP também pode transportar dados binários, e nem todo conteúdo recebido representa uma string válida em UTF-8. Quando trabalhamos com protocolos binários, normalmente mantemos os dados como []byte e interpretamos seus campos de acordo com a estrutura definida pelo protocolo.


O método WriteTo retorna a quantidade de bytes escritos e um possível erro. Mesmo quando essa chamada termina sem erro, isso não garante que o datagrama tenha chegado ao destinatário ou que tenha sido processado pela aplicação remota. O retorno indica o resultado da operação local de envio, enquanto a confirmação de recebimento depende de mecanismos adicionais definidos pela aplicação ou pelo protocolo usado.



Testando o servidor UDP​


Depois de iniciar o servidor, podemos enviar um datagrama usando uma ferramenta que ofereça suporte a UDP. Algumas implementações do netcat possuem a opção -u, responsável por selecionar esse protocolo. Também podemos informar um tempo de espera para evitar que o comando permaneça aguardando indefinidamente depois do envio.


Em sistemas com uma implementação compatível do netcat, podemos executar um comando semelhante ao seguinte:

printf 'ola' | nc -u -w 2 127.0.0.1 9001

Esse comando envia a string ola para a porta UDP 9001 da máquina local. Se a implementação do netcat permanecer aguardando respostas, poderemos receber o conteúdo devolvido pelo servidor. O comportamento exato da opção -w e o momento em que o programa encerra dependem da versão do netcat, por isso essa sintaxe pode precisar de ajustes em alguns sistemas.


No terminal em que o servidor está executando, teremos uma mensagem semelhante a esta:

127.0.0.1:54321 enviou: "ola"

A porta apresentada representa a porta de origem escolhida para o socket do remetente e poderá mudar entre execuções. Como UDP não estabelece uma conexão antes do envio, o recebimento desse datagrama não exige que o servidor aceite um cliente. Ele apenas recebe a mensagem, identifica sua origem e envia uma resposta para o endereço informado.


Também precisamos considerar que a ausência de uma resposta não significa necessariamente que o servidor não está escutando. O datagrama pode ter sido perdido, bloqueado por um firewall ou recebido por uma aplicação que não envia respostas. Essa diferença é especialmente relevante quando usamos UDP em ferramentas de diagnóstico ou tentamos determinar se determinada porta está disponível.



Entendendo o tamanho dos datagramas​


Diferentemente do TCP, UDP preserva os limites dos datagramas enviados pela aplicação. Quando um programa envia um datagrama, o receptor realiza uma operação de leitura para receber aquela unidade de dados. Entretanto, o buffer fornecido pelo programa precisa possuir espaço suficiente para armazenar o conteúdo que será recebido.


Se o datagrama for maior do que o buffer disponível, a leitura poderá receber apenas parte do conteúdo, enquanto o restante será descartado pelo sistema operacional. Isso significa que não podemos simplesmente executar outra leitura esperando receber a continuação do mesmo datagrama, como seria possível fazer com um fluxo TCP. A operação seguinte deverá receber outro datagrama.


No exemplo anterior, usamos um buffer de 2048 bytes. Essa escolha é suficiente para demonstrações com mensagens pequenas, mas não representa um tamanho adequado para todos os protocolos UDP. O tamanho necessário depende do formato das mensagens, dos limites definidos pela aplicação e das condições da rede, incluindo aspectos relacionados à fragmentação de pacotes IP.


Um datagrama UDP sobre IPv4 possui um limite teórico de carga útil de 65507 bytes quando não existem opções adicionais no cabeçalho IPv4. Entretanto, enviar datagramas próximos desse tamanho pode exigir fragmentação IP, aumentando a possibilidade de perda e reduzindo a compatibilidade com determinados caminhos de rede. Aplicações reais normalmente trabalham com tamanhos menores ou definem mecanismos próprios para dividir informações maiores.


Também precisamos considerar que uma leitura que preenche completamente o buffer não permite concluir, por si só, que o datagrama foi recebido integralmente. Quando precisamos identificar truncamentos com maior precisão, podemos recorrer a operações específicas de net.UDPConn, como ReadMsgUDP, que disponibilizam informações adicionais sobre o recebimento. O tratamento dessas informações pode depender do sistema operacional e das opções disponíveis no socket.



Recebendo datagramas de diferentes clientes​


Um único socket UDP pode receber datagramas enviados por vários clientes. Como cada operação de recebimento informa o endereço de origem, o servidor consegue identificar para onde deve encaminhar uma resposta sem manter obrigatoriamente uma conexão individual por remetente. Isso simplifica determinadas aplicações, principalmente quando cada mensagem pode ser processada independentemente das demais.


Entretanto, isso não significa que o servidor UDP não possa trabalhar concorrentemente. Podemos receber um datagrama e encaminhar seu processamento para uma goroutine, permitindo que a operação principal volte a aguardar novas mensagens. Nesse caso, precisamos tomar cuidado com os buffers compartilhados, porque a próxima leitura poderá sobrescrever os bytes que uma goroutine ainda está processando.


Se uma goroutine precisar processar os dados depois que o laço executar outra leitura, normalmente devemos criar uma cópia dos bytes recebidos. Podemos usar bytes.Clone, disponível na biblioteca padrão desde o Go 1.20, ou criar um novo slice e copiar os dados manualmente. Isso garante que cada tarefa trabalhe com seus próprios bytes, evitando condições de corrida relacionadas à reutilização do buffer.

// Cria uma cópia independente dos bytes recebidos.
dados := make([]byte, n)

copy(dados, buffer[:n])

// Processa a cópia em uma goroutine.
go func(mensagem []byte, origem net.Addr) {
fmt.Printf(
"Datagrama de %s: %q\n",
origem,
mensagem,
)
}(dados, endereco)

Esse trecho pressupõe que n, buffer e endereco foram obtidos de uma chamada anterior a ReadFrom e que os pacotes fmt e net foram importados. A função copy transfere os bytes para um novo slice, permitindo que o buffer original seja reutilizado pelo laço principal. Sem essa cópia, uma goroutine poderia acessar dados que já foram modificados pela próxima operação de recebimento.


Também precisamos limitar a quantidade de goroutines quando o servidor recebe muitos datagramas. UDP não estabelece uma conexão para cada cliente, mas o processamento das mensagens ainda consome memória e tempo de CPU. Iniciar uma goroutine para cada datagrama sem nenhum controle pode permitir que uma grande quantidade de mensagens provoque consumo excessivo de recursos.



Diferenças práticas entre TCP e UDP​


TCP e UDP são protocolos de transporte que permitem a comunicação entre aplicações usando endereços IP e portas. Entretanto, eles oferecem propriedades diferentes, o que afeta tanto a implementação dos programas quanto o comportamento da comunicação. A escolha do protocolo depende das necessidades da aplicação, principalmente em relação à confiabilidade, à organização dos dados e ao controle da transmissão.


TCP oferece um fluxo confiável e ordenado de bytes, realizando mecanismos de confirmação, retransmissão e controle de fluxo. UDP trabalha com datagramas independentes e não oferece automaticamente essas garantias, permitindo que a aplicação controle como deseja tratar perdas, ordem e confirmação das mensagens. Nenhum dos dois protocolos é melhor para todas as situações, porque cada um resolve problemas diferentes.

CaracterísticaTCPUDP
ComunicaçãoOrientada a conexãoOrientada a datagramas
EstabelecimentoExige uma conexão antes da transmissão normalNão exige estabelecimento de conexão
EntregaPossui mecanismos de retransmissãoNão garante entrega
OrdemEntrega os bytes em ordemDatagramas podem chegar fora de ordem
Limites de mensagensNão preserva limites das escritasPreserva limites dos datagramas
Controle de fluxoImplementado pelo protocoloNão oferece controle equivalente
Leitura no servidorRead em uma conexãoReadFrom em um socket
Envio no servidorWrite em uma conexãoWriteTo com endereço de destino

A principal diferença para quem está desenvolvendo um servidor é a maneira como os dados são recebidos. Em TCP, cada conexão possui um fluxo de bytes que precisa ser interpretado pela aplicação. Em UDP, cada leitura recebe um datagrama enviado por determinado remetente, preservando sua separação em relação aos demais datagramas.


Essa diferença também modifica a organização dos recursos. Em servidores TCP, normalmente mantemos um listener para receber novas conexões e um objeto net.Conn para cada cliente aceito. Em servidores UDP, podemos usar um único socket associado à porta local e receber datagramas de diferentes origens, identificando cada remetente pelo endereço retornado durante a leitura.



Confiabilidade e ordem dos dados​


TCP foi desenvolvido para fornecer um fluxo de dados confiável entre os dois lados da comunicação. Quando segmentos são perdidos durante a transmissão, o protocolo possui mecanismos para identificar essa situação e realizar retransmissões. Também reorganiza os dados recebidos para que a aplicação observe os bytes na ordem correta, respeitando as condições em que a conexão continua funcionando.


Essas garantias não significam que uma operação TCP sempre terminará com sucesso. A conexão pode falhar, ser encerrada inesperadamente ou ultrapassar limites de tempo definidos pela aplicação. O protocolo também não garante que uma mensagem foi interpretada ou processada corretamente pelo programa remoto, porque isso depende das regras do protocolo de aplicação.


UDP não possui mecanismos automáticos equivalentes para garantir que todos os datagramas sejam entregues ou recebidos na ordem original. Um datagrama pode ser perdido sem que o remetente receba uma confirmação diretamente do protocolo. Se a aplicação precisar identificar perdas, solicitar retransmissões ou reorganizar mensagens, será necessário implementar esses comportamentos ou trabalhar com protocolos que já ofereçam essas características.



Fluxo de bytes e datagramas​


TCP trabalha com um fluxo contínuo de bytes, por isso as operações de escrita do cliente não correspondem obrigatoriamente às operações de leitura do servidor. Se o cliente enviar duas mensagens separadas, o servidor poderá receber os bytes juntos ou divididos em diferentes leituras. Para identificar mensagens completas, precisamos estabelecer um formato que defina seus limites.


UDP preserva os limites das mensagens enviadas como datagramas. Se um cliente enviar dois datagramas, o servidor os receberá em operações de leitura distintas, desde que ambos cheguem. Entretanto, se um datagrama ultrapassar o tamanho do buffer de recebimento, parte de seu conteúdo poderá ser perdida, e uma segunda leitura não recuperará os bytes descartados.


Essa diferença influencia diretamente a criação dos protocolos de aplicação. Em TCP, podemos usar delimitadores, campos de tamanho ou outras estruturas para identificar mensagens. Em UDP, os limites já existem no transporte, mas ainda precisamos definir como interpretar o conteúdo de cada datagrama e o que fazer quando uma mensagem não chega ou contém informações inválidas.



Desempenho e casos de uso​


UDP possui um cabeçalho de transporte menor e não exige o estabelecimento de uma conexão antes do envio de datagramas. Isso pode reduzir determinadas etapas da comunicação, mas não significa que aplicações UDP serão sempre mais rápidas do que aplicações TCP. O desempenho depende do comportamento da rede, do tamanho das mensagens, das características do protocolo de aplicação e dos mecanismos adicionais necessários.


TCP costuma ser adequado quando precisamos transferir informações mantendo sua ordem e integridade durante a comunicação, como acontece em muitos protocolos de aplicação e transferências de dados. UDP costuma aparecer em aplicações que trabalham com mensagens independentes, consultas curtas ou mecanismos próprios de controle, como algumas operações DNS e protocolos de comunicação em tempo real.


Também existem protocolos mais completos construídos sobre UDP, como QUIC, que implementa mecanismos próprios de confiabilidade, controle de fluxo e segurança. Isso mostra que usar UDP não significa necessariamente abrir mão dessas características, mas sim que elas não são fornecidas diretamente pelo transporte UDP. Da mesma maneira, TCP não oferece criptografia automaticamente, exigindo mecanismos adicionais como TLS quando a comunicação precisa ser protegida.



Tratando timeout, erro e encerramento da conexão​


Servidores de rede precisam lidar com situações em que clientes não enviam dados, conexões são interrompidas ou operações demoram mais do que o esperado. Sem limites de tempo e tratamento adequado, uma goroutine pode permanecer bloqueada aguardando informações que nunca chegarão. Além disso, conexões e sockets precisam ser encerrados quando deixam de ser necessários, evitando manter recursos do sistema operacional ocupados.


Em Go, o pacote net fornece métodos para configurar deadlines, que representam instantes a partir dos quais determinadas operações não devem continuar aguardando. Também podemos usar os erros retornados pelas funções para identificar condições como encerramento normal, timeout e uso de conexões fechadas. Esses mecanismos permitem implementar servidores que lidam de maneira mais previsível com falhas durante a comunicação.


O tratamento depende do tipo de operação. Em uma conexão TCP, podemos definir prazos individuais para leitura e escrita, enquanto um listener possui seus próprios mecanismos de encerramento e, em alguns casos, deadlines de aceitação. Em UDP, podemos limitar a espera por datagramas e fechar o socket para interromper operações bloqueadas quando o servidor precisar encerrar sua execução.



Configurando timeout de leitura TCP​


O método SetReadDeadline, disponível em net.Conn, permite definir um instante máximo para operações de leitura. Quando esse prazo é ultrapassado, uma leitura bloqueada pode retornar um erro indicando timeout. Isso evita que uma conexão permaneça aguardando indefinidamente quando o cliente para de enviar dados ou não conclui uma mensagem.


O deadline é representado por um valor time.Time, e não apenas por uma duração. Por isso, normalmente usamos time.Now().Add, combinando o instante atual com o intervalo que desejamos permitir. Podemos atualizar esse prazo antes de cada operação para implementar um timeout de inatividade, garantindo que o cliente tenha um novo período de espera depois de uma leitura concluída.


O exemplo seguinte apresenta uma função de atendimento que recebe mensagens por linha e permite até dez segundos para concluir cada leitura. Se o cliente não enviar uma linha completa durante esse intervalo, a operação poderá terminar com timeout e a conexão será encerrada.

package main

import (
"bufio"
"errors"
"fmt"
"io"
"net"
"os"
"time"
)

func atenderCliente(conn net.Conn) {
defer conn.Close()

scanner := bufio.NewScanner(conn)

for {
// Atualiza o prazo antes de receber a próxima linha.
err := conn.SetReadDeadline(
time.Now().Add(10 * time.Second),
)

if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}

// Aguarda a próxima mensagem.
if !scanner.Scan() {
break
}

mensagem := scanner.Text()

// Responde à mensagem recebida.
_, err = io.WriteString(
conn,
"Eco: "+mensagem+"\n",
)

if err != nil {
fmt.Fprintln(
os.Stderr,
"Erro ao responder:",
err,
)

return
}
}

// Verifica o motivo do encerramento da leitura.
if err := scanner.Err(); err != nil {
if errors.Is(err, os.ErrDeadlineExceeded) {
fmt.Println("Cliente excedeu o tempo de leitura.")
} else {
fmt.Fprintln(
os.Stderr,
"Erro de leitura:",
err,
)
}
}
}

A função SetReadDeadline estabelece um prazo de dez segundos antes de cada chamada que tenta receber uma nova linha. Como Scanner utiliza a conexão internamente, o limite também afeta as operações de leitura realizadas por ele. Se o cliente enviar uma linha completa dentro do intervalo, o servidor processará a mensagem e definirá um novo prazo antes de aguardar a próxima.


Quando uma leitura ultrapassa o deadline, o erro pode ser identificado usando errors.Is(err, os.ErrDeadlineExceeded). Essa comparação permite reconhecer a condição de prazo excedido mesmo quando o erro foi encapsulado por outras operações. Também existem outros erros de rede que podem representar timeout, por isso aplicações que precisam tratar todas essas condições podem examinar a interface net.Error.


O prazo definido nesse exemplo limita o tempo disponível para concluir a leitura de uma linha. Ele não é exatamente um timeout de inatividade por byte, porque o deadline é atualizado antes de cada chamada de Scanner.Scan, e não depois de cada byte recebido. Para protocolos que exigem controle mais preciso da inatividade, podemos realizar leituras diretamente com Read e atualizar o prazo conforme novos dados são recebidos.


Esse trecho representa apenas a função responsável pelo atendimento de uma conexão. Para executá-lo como servidor completo, precisamos associá-lo a um listener e chamar atenderCliente para cada conexão aceita. Também precisamos considerar que o timeout de leitura não limita automaticamente as operações de escrita, que possuem um mecanismo próprio.



Configurando timeout de escrita TCP​


O método SetWriteDeadline permite definir um prazo para operações de escrita na conexão. Esse recurso é útil quando o cliente deixa de receber dados ou quando o envio permanece bloqueado por condições relacionadas aos buffers e ao controle de fluxo. Sem um limite adequado, uma goroutine pode permanecer aguardando espaço para transmitir informações durante um período maior do que o esperado.


Podemos definir um prazo antes de enviar uma resposta, garantindo que a operação de escrita tenha um limite de tempo. O trecho seguinte pressupõe que conn representa uma conexão TCP válida e que os pacotes time e io foram importados.

// Define um prazo de cinco segundos para a escrita.
err := conn.SetWriteDeadline(
time.Now().Add(5 * time.Second),
)

if err != nil {
return err
}

// Envia uma resposta ao cliente.
_, err = io.WriteString(
conn,
"Mensagem recebida.\n",
)

if err != nil {
return err
}

Esse trecho deve ser colocado dentro de uma função que retorna error. O prazo é aplicado à conexão e afeta as operações de escrita que estiverem pendentes ou forem executadas posteriormente. Se o deadline for ultrapassado, a operação poderá retornar um erro, mas parte dos dados pode ter sido escrita antes da falha, exigindo cuidado quando o protocolo depende da transmissão completa de uma mensagem.


Também podemos usar SetDeadline para definir o mesmo prazo para leitura e escrita. Entretanto, os deadlines continuam associados à conexão até que sejam alterados ou removidos. Isso significa que um prazo definido anteriormente pode afetar operações futuras, mesmo que a aplicação já tenha concluído a etapa para a qual ele foi configurado.


Para remover um deadline, podemos informar o valor zero de time.Time. Essa operação faz com que o método correspondente deixe de impor aquele limite temporal, permitindo que novas operações aguardem conforme o comportamento normal da conexão. A remoção deve ser feita conscientemente, porque operações sem prazo podem permanecer bloqueadas durante períodos prolongados.

// Remove o prazo anteriormente definido para escrita.
err := conn.SetWriteDeadline(time.Time{})

if err != nil {
return err
}

Esse trecho também pressupõe uma função que retorna error. Em servidores que processam mensagens continuamente, normalmente é melhor definir prazos adequados para cada etapa da comunicação do que remover todos os limites indiscriminadamente. A escolha depende de quanto tempo cada operação pode aguardar e de como a aplicação deve reagir quando o cliente deixa de responder.



Tratando erros de conexão​


Operações de rede podem retornar erros por diferentes motivos, incluindo encerramento remoto, timeout, conexão recusada, uso de socket fechado ou falhas do sistema operacional. Como essas condições possuem significados diferentes, não devemos tratar todos os erros como se representassem a mesma situação. O tratamento adequado depende da operação realizada e do comportamento esperado pelo servidor.


Quando um cliente encerra normalmente o fluxo de entrada de uma conexão TCP, uma leitura pode retornar io.EOF. Essa condição indica que não existem mais dados disponíveis naquele fluxo e não representa necessariamente uma falha. Já net.ErrClosed pode ser retornado quando tentamos executar operações em uma conexão ou listener que foi fechado, permitindo identificar situações em que o encerramento foi iniciado localmente.


Podemos usar errors.Is para verificar essas condições sem depender da comparação de textos presentes nas mensagens de erro. Isso é mais confiável porque os erros podem ser encapsulados por diferentes camadas do pacote net, mantendo suas identificações originais.

func tratarErro(err error) {
if err == nil {
return
}

switch {
case errors.Is(err, io.EOF):
fmt.Println("Fluxo de entrada encerrado.")

case errors.Is(err, net.ErrClosed):
fmt.Println("Conexão ou socket fechado.")

case errors.Is(err, os.ErrDeadlineExceeded):
fmt.Println("Tempo limite excedido.")

default:
fmt.Println("Erro de rede:", err)
}
}

Esse trecho pressupõe que os pacotes errors, fmt, io, net e os foram importados. A função recebe um erro e compara sua identidade com algumas condições conhecidas, permitindo apresentar mensagens diferentes conforme o problema identificado. Quando o erro não corresponde a nenhuma condição tratada, sua mensagem original é apresentada para auxiliar no diagnóstico.


Também podemos usar errors.As para verificar se um erro implementa a interface net.Error, que disponibiliza o método Timeout. Isso permite identificar erros relacionados a timeout que não correspondem necessariamente a os.ErrDeadlineExceeded. Entretanto, devemos considerar que essa interface não informa sozinha qual etapa da operação falhou, por isso normalmente precisamos manter o contexto da leitura, escrita ou conexão que produziu o erro.



Configurando timeout em servidores UDP​


Sockets UDP também permitem configurar limites de tempo para operações de leitura e escrita. A interface net.PacketConn fornece os métodos SetReadDeadline, SetWriteDeadline e SetDeadline, permitindo controlar quanto tempo determinada operação poderá permanecer bloqueada. Esses métodos seguem o mesmo conceito de deadline usado nas conexões TCP, mas são aplicados às operações de envio e recebimento de datagramas.


Podemos criar um servidor UDP que aguarda datagramas por até cinco segundos. Se nenhuma mensagem for recebida dentro do intervalo, ReadFrom poderá retornar um erro de timeout. O programa pode tratar essa condição e continuar aguardando, desde que atualize o deadline antes da próxima operação.

package main

import (
"errors"
"fmt"
"net"
"os"
"time"
)

func main() {
conn, err := net.ListenPacket(
"udp4",
"127.0.0.1:9001",
)

if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}

defer conn.Close()

buffer := make([]byte, 2048)

fmt.Println("Servidor UDP:", conn.LocalAddr())

for {
// Define o prazo para receber o próximo datagrama.
err := conn.SetReadDeadline(
time.Now().Add(5 * time.Second),
)

if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}

// Aguarda um datagrama até o prazo definido.
n, endereco, err := conn.ReadFrom(buffer)

if err != nil {
if errors.Is(err, os.ErrDeadlineExceeded) {
fmt.Println("Nenhum datagrama recebido.")
continue
}

fmt.Fprintln(
os.Stderr,
"Erro ao receber:",
err,
)

return
}

fmt.Printf(
"%s enviou: %q\n",
endereco,
buffer[:n],
)
}
}

Nesse exemplo, SetReadDeadline define um novo prazo antes de cada operação de recebimento. Se o prazo terminar sem que um datagrama seja recebido, o programa identifica a condição usando errors.Is e continua o laço. Na próxima iteração, um novo deadline é configurado, permitindo que o socket continue aguardando mensagens sem ficar permanentemente preso ao prazo anterior.


Esse comportamento pode ser conveniente quando o servidor precisa realizar verificações periódicas mesmo sem receber mensagens. Entretanto, o exemplo apenas apresenta uma mensagem de timeout e continua aguardando, sem executar outras tarefas durante os intervalos. Em aplicações reais, podemos combinar esses limites com mecanismos de encerramento ou outras operações que precisem acontecer periodicamente.


O timeout de leitura não informa que determinado cliente deixou de responder, porque UDP não estabelece uma conexão individual para cada remetente. Ele apenas indica que a operação de recebimento não foi concluída dentro do prazo definido. Para detectar a ausência de resposta de um cliente ou serviço específico, precisamos implementar regras de comunicação que associem solicitações, respostas e limites de tempo.



Encerrando conexões corretamente​


Quando uma conexão TCP deixa de ser necessária, devemos chamar seu método Close para liberar os recursos associados. Normalmente usamos defer conn.Close() logo depois que a conexão é obtida com sucesso, garantindo que o fechamento aconteça quando a função responsável pelo atendimento terminar. Isso reduz a possibilidade de esquecer o encerramento em algum caminho de retorno.


Fechar uma conexão também interrompe operações de leitura e escrita que estejam bloqueadas, fazendo com que retornem erros. Esse comportamento é útil durante o encerramento de servidores, porque permite interromper goroutines que estão aguardando dados. Entretanto, o fechamento não garante que todas as informações pendentes tenham sido recebidas ou processadas pelo outro lado.


TCP também permite encerrar separadamente determinadas direções da comunicação usando métodos específicos de net.TCPConn. O método CloseWrite, por exemplo, encerra o lado de escrita da conexão, permitindo informar ao peer que não serão enviados novos dados enquanto a aplicação ainda pode receber informações. Esse comportamento é conhecido como encerramento parcial, ou half-close, e pode ser necessário em protocolos que dependem do fim do fluxo de envio.


O trecho seguinte pressupõe que conn representa uma conexão TCP válida. Como a interface net.Conn não possui diretamente o método CloseWrite, precisamos verificar se o valor recebido corresponde ao tipo *net.TCPConn.

// Verifica se a conexão é um TCPConn.
tcpConn, ok := conn.(*net.TCPConn)

if !ok {
return fmt.Errorf("conexão não é TCP")
}

// Encerra somente o lado de escrita.
if err := tcpConn.CloseWrite(); err != nil {
return fmt.Errorf(
"encerrar escrita: %w",
err,
)
}

Esse trecho deve ser colocado dentro de uma função que retorna error e pressupõe que os pacotes net e fmt foram importados. A expressão conn.(*net.TCPConn) representa uma afirmação de tipo, que permite verificar se a interface contém um valor concreto do tipo esperado. Quando essa verificação é concluída, podemos chamar CloseWrite para encerrar somente o envio de dados.


O encerramento parcial não substitui o fechamento completo da conexão. Depois que todas as operações necessárias forem concluídas, ainda precisamos chamar Close para liberar o recurso. Também não devemos assumir que o peer continuará conectado indefinidamente depois de receber o encerramento do fluxo de escrita, porque o comportamento posterior depende das regras do protocolo de aplicação e das decisões do programa remoto.



Encerrando o listener TCP​


O fechamento de um listener TCP impede que novas conexões sejam aceitas. Quando chamamos listener.Close(), uma operação de Accept que esteja bloqueada é interrompida e retorna um erro. Isso permite encerrar o laço responsável por receber clientes sem precisar aguardar a chegada de uma nova conexão.


Entretanto, o fechamento do listener não encerra automaticamente as conexões que já foram aceitas. Se o servidor possui goroutines atendendo diferentes clientes, essas tarefas podem continuar executando depois que o listener foi fechado. Para realizar um encerramento controlado, precisamos decidir como interromper novas conexões e como finalizar ou cancelar os atendimentos que ainda estão em andamento.


Uma maneira de identificar o encerramento do listener é verificar se Accept retornou net.ErrClosed. Essa condição permite diferenciar o fechamento esperado de outros problemas que possam acontecer durante a aceitação de conexões. Quando o fechamento faz parte do processo normal de encerramento, não precisamos apresentar o erro como uma falha inesperada.



Encerrando o servidor com Ctrl+C​


Quando executamos um servidor pelo terminal, normalmente podemos solicitar seu encerramento usando Ctrl+C. Em muitos ambientes, essa combinação envia um sinal de interrupção ao processo, permitindo que a aplicação identifique a solicitação e inicie seu procedimento de encerramento. Em Go, podemos trabalhar com esses sinais usando o pacote os/signal.


A função signal.NotifyContext permite criar um contexto que será cancelado quando determinado sinal for recebido. Podemos usar esse contexto para fechar o listener, interromper as conexões ativas e aguardar a conclusão das goroutines. Isso permite organizar o encerramento sem depender de chamadas imediatas a os.Exit, que impediriam a execução de funções registradas com defer.


Vamos criar um servidor TCP que recebe várias conexões e mantém um conjunto dos clientes ativos. Quando o programa receber uma solicitação de interrupção, ele fechará o listener e as conexões que ainda estiverem abertas, permitindo que as goroutines terminem suas operações antes do encerramento do processo.

package main

import (
"context"
"fmt"
"io"
"net"
"os"
"os/signal"
"sync"
)

func main() {
// Cria um contexto cancelado pelo sinal de interrupção.
ctx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt,
)

defer stop()

// Cria o listener TCP do servidor.
listener, err := net.Listen(
"tcp4",
"127.0.0.1:9000",
)

if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}

defer listener.Close()

// Controla o acesso às conexões ativas.
var mu sync.Mutex

// Aguarda a conclusão das goroutines de atendimento.
var wg sync.WaitGroup

// Armazena as conexões que ainda estão abertas.
conexoes := make(map[net.Conn]struct{})

go func() {
// Aguarda uma solicitação de encerramento.
<-ctx.Done()

// Impede que novas conexões sejam aceitas.
listener.Close()

// Fecha as conexões que ainda estão ativas.
mu.Lock()

for conn := range conexoes {
conn.Close()
}

mu.Unlock()
}()

fmt.Println("Servidor TCP:", listener.Addr())

for {
// Aguarda uma nova conexão.
conn, err := listener.Accept()

if err != nil {
if ctx.Err() == nil {
fmt.Fprintln(
os.Stderr,
"Erro ao aceitar conexão:",
err,
)

// Solicita o encerramento após erro inesperado.
stop()
}

break
}

mu.Lock()

// Não aceita novos atendimentos após cancelamento.
if ctx.Err() != nil {
mu.Unlock()
conn.Close()
break
}

// Registra a conexão e seu atendimento.
conexoes[conn] = struct{}{}
wg.Add(1)

mu.Unlock()

go func(c net.Conn) {
defer wg.Done()

defer func() {
// Remove a conexão do conjunto de clientes ativos.
mu.Lock()
delete(conexoes, c)
mu.Unlock()

c.Close()
}()

// Devolve os bytes recebidos ao cliente.
_, err := io.Copy(c, c)

if err != nil && ctx.Err() == nil {
fmt.Fprintln(
os.Stderr,
"Erro no atendimento:",
err,
)
}
}(conn)
}

// Aguarda todos os atendimentos terminarem.
wg.Wait()

fmt.Println("Servidor encerrado.")
}

A função signal.NotifyContext cria um contexto que será cancelado quando o processo receber os.Interrupt. Esse sinal normalmente é enviado quando pressionamos Ctrl+C em um terminal, embora seu comportamento dependa do sistema operacional e do ambiente de execução. A função também retorna stop, que deve ser chamada quando não precisamos mais receber notificações associadas ao contexto.


O servidor mantém um mapa chamado conexoes, usado para armazenar os objetos net.Conn que ainda estão ativos. Como diferentes goroutines podem adicionar ou remover elementos desse mapa, usamos sync.Mutex para proteger os acessos. Sem essa sincronização, operações concorrentes de leitura e escrita no mapa poderiam provocar data races ou outros comportamentos incorretos.


Cada conexão aceita é registrada no mapa e no WaitGroup antes de sua goroutine ser iniciada. Quando o atendimento termina, a função registrada com defer remove a conexão do mapa e fecha o recurso associado. O método wg.Done informa que aquela goroutine terminou, permitindo que a função principal aguarde até que todos os atendimentos tenham sido finalizados.


A goroutine responsável pelo encerramento permanece aguardando ctx.Done(). Quando o contexto é cancelado, ela fecha o listener e percorre as conexões ativas, chamando Close em cada uma. Isso interrompe operações de leitura e escrita que estejam bloqueadas, permitindo que as goroutines responsáveis pelos clientes retornem e liberem seus recursos.


No laço principal, verificamos ctx.Err() para identificar se um erro de Accept aconteceu durante o encerramento solicitado. Quando o contexto já foi cancelado, o erro não precisa ser tratado como falha inesperada. Se acontecer outro erro durante a aceitação, chamamos stop() para iniciar o mesmo procedimento de encerramento, evitando deixar atendimentos ativos sem controle.


O uso de io.Copy(c, c) implementa um comportamento echo, copiando os bytes recebidos pela conexão de volta para a própria conexão. Essa função continua realizando operações de leitura e escrita até que o fluxo termine ou aconteça um erro. Quando a conexão é fechada durante o encerramento, essas operações são interrompidas e a goroutine pode finalizar normalmente.


Esse exemplo realiza um encerramento que interrompe as conexões ativas. Em aplicações que precisam permitir a conclusão de operações pendentes antes de fechar os clientes, podemos implementar um período de encerramento controlado, aguardando determinado intervalo antes de forçar o fechamento. Isso exige mecanismos adicionais para acompanhar tarefas em andamento, definir um prazo máximo e garantir que o processo não permaneça bloqueado indefinidamente.


Também precisamos considerar que o exemplo aceita conexões sem um limite explícito de clientes simultâneos. Em um servidor de produção, normalmente combinamos o atendimento concorrente com limites de recursos, timeouts de leitura e escrita, validação das mensagens recebidas e controles adequados para o ambiente em que o serviço será disponibilizado.



Encerrando um servidor UDP​


O encerramento de um servidor UDP segue uma ideia semelhante, mas não precisamos controlar conexões individuais quando estamos trabalhando com um único socket que recebe datagramas. Podemos fechar o objeto retornado por net.ListenPacket, fazendo com que operações de ReadFrom bloqueadas sejam interrompidas. Depois disso, o laço de recebimento pode identificar o encerramento e terminar sua execução.


Podemos combinar signal.NotifyContext com conn.Close para implementar esse comportamento. O exemplo seguinte cria um servidor UDP que recebe datagramas até que o processo receba uma solicitação de interrupção, fechando o socket e encerrando o laço de maneira controlada.

package main

import (
"context"
"errors"
"fmt"
"net"
"os"
"os/signal"
)

func main() {
// Cria um contexto associado ao sinal de interrupção.
ctx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt,
)

defer stop()

// Cria o socket UDP.
conn, err := net.ListenPacket(
"udp4",
"127.0.0.1:9001",
)

if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}

defer conn.Close()

go func() {
// Aguarda o cancelamento do contexto.
<-ctx.Done()

// Interrompe operações bloqueadas no socket.
conn.Close()
}()

fmt.Println("Servidor UDP:", conn.LocalAddr())

buffer := make([]byte, 2048)

for {
// Aguarda o próximo datagrama.
n, endereco, err := conn.ReadFrom(buffer)

if err != nil {
// Identifica o fechamento solicitado.
if ctx.Err() != nil &&
errors.Is(err, net.ErrClosed) {
break
}

fmt.Fprintln(
os.Stderr,
"Erro ao receber datagrama:",
err,
)

break
}

fmt.Printf(
"%s enviou: %q\n",
endereco,
buffer[:n],
)
}

fmt.Println("Servidor UDP encerrado.")
}

O programa cria um contexto associado ao sinal de interrupção e inicia uma goroutine que aguarda seu cancelamento. Quando Ctrl+C provoca o cancelamento do contexto, essa goroutine chama conn.Close(), encerrando o socket UDP. Como ReadFrom estava aguardando novos datagramas, a operação é interrompida e retorna um erro associado ao fechamento do recurso.


O laço verifica se o contexto foi cancelado e se o erro corresponde a net.ErrClosed. Quando essas condições são atendidas, o programa entende que o socket foi fechado durante o procedimento normal de encerramento e termina o laço sem apresentar uma falha inesperada. Se ocorrer outro erro, ele será apresentado na saída de erros antes do encerramento.


Esse exemplo não cria goroutines separadas para processar cada datagrama, por isso o fechamento do socket é suficiente para interromper a operação principal de recebimento. Se o servidor encaminhasse os datagramas para tarefas concorrentes, também precisaríamos acompanhar essas goroutines e definir como elas devem reagir ao cancelamento. O fechamento do socket não interrompe automaticamente funções que já receberam os dados e continuam processando informações na memória.



Cuidados ao disponibilizar servidores na rede​


Um servidor que escuta somente em 127.0.0.1 normalmente fica acessível apenas pela própria máquina, enquanto um servidor associado a 0.0.0.0 pode receber conexões destinadas às interfaces IPv4 locais. Essa diferença precisa ser considerada porque disponibilizar uma porta para outras máquinas aumenta a quantidade de clientes capazes de interagir com o programa, incluindo clientes que enviam dados inesperados ou tentam manter conexões abertas por longos períodos.


Também não devemos confiar automaticamente nas informações recebidas pela rede. Um cliente pode enviar mensagens maiores do que o esperado, dados inválidos ou sequências de operações incompatíveis com o protocolo. Por isso, precisamos validar os conteúdos recebidos, limitar o tamanho das mensagens e controlar o consumo de recursos, principalmente em servidores que aceitam conexões ou datagramas de origens externas.


Servidores TCP e UDP também não oferecem criptografia automaticamente apenas por usarem o pacote net. Os dados transmitidos por conexões TCP comuns ou datagramas UDP comuns não recebem proteção criptográfica do próprio transporte. Quando a comunicação exige confidencialidade, autenticação ou proteção contra alterações, precisamos trabalhar com mecanismos apropriados, como TLS para conexões TCP ou protocolos seguros desenvolvidos sobre UDP.


Em ferramentas de desenvolvimento e diagnóstico, normalmente é suficiente começar escutando na interface local e usar portas que não dependam de permissões especiais. Quando o serviço precisar ser disponibilizado em outras interfaces, devemos considerar as regras de firewall, o controle de acesso e as permissões do processo. Essas decisões fazem parte da configuração do servidor e precisam acompanhar as necessidades reais da aplicação.