Skip to main content


Concorrência aplicada à rede​


Programas que trabalham com redes frequentemente precisam executar várias operações durante o mesmo período. Uma ferramenta pode consultar diferentes servidores, verificar conexões TCP, receber dados de vários clientes ou aguardar respostas de serviços externos. Como essas operações dependem da comunicação com outros sistemas, parte do tempo de execução pode ser gasta aguardando respostas, sem que o processador esteja realizando algum trabalho diretamente.


Em Go, podemos organizar essas operações usando concorrência, que permite manter diferentes tarefas em andamento sem precisar esperar que uma termine para iniciar outra. A linguagem possui recursos próprios para esse tipo de programação, principalmente goroutines, channels e mecanismos de sincronização. Esses recursos fazem parte da linguagem e de sua biblioteca padrão, permitindo criar programas concorrentes sem depender de bibliotecas externas.


A concorrência também precisa ser controlada. Iniciar muitas operações ao mesmo tempo pode consumir memória, conexões e outros recursos do sistema, enquanto tarefas que não possuem limites de tempo podem permanecer bloqueadas indefinidamente. Por isso, além de executar operações simultâneas, precisamos entender como aguardar sua conclusão, compartilhar resultados, cancelar tarefas e limitar a quantidade de operações em andamento.



Introdução a goroutines​


Uma goroutine é uma unidade de execução gerenciada pelo runtime do Go, que permite executar uma função concorrentemente com outras partes do programa. O runtime é o conjunto de recursos responsáveis por executar e gerenciar determinados comportamentos da linguagem, incluindo o agendamento das goroutines. Quando uma função é iniciada como goroutine, sua execução pode continuar independentemente da função que a iniciou.


Em um programa tradicional que executa operações sequencialmente, cada tarefa precisa terminar antes que a próxima seja iniciada. Se uma conexão de rede demorar alguns segundos para responder, as operações seguintes permanecerão aguardando. Com goroutines, podemos iniciar outras tarefas enquanto uma conexão está esperando, permitindo aproveitar melhor o tempo gasto em operações de entrada e saída.


Goroutines são consideradas leves porque possuem um custo inicial relativamente pequeno quando comparadas às threads do sistema operacional. Uma thread é uma unidade de execução gerenciada pelo sistema operacional, enquanto as goroutines são gerenciadas pelo runtime do Go e distribuídas entre threads disponíveis. Isso permite manter muitas goroutines em execução sem precisar criar uma thread exclusiva para cada uma.



Concorrência e paralelismo​


Embora estejam relacionados, concorrência e paralelismo representam conceitos diferentes. Concorrência significa organizar um programa para que diferentes tarefas possam avançar durante o mesmo período, mesmo que suas instruções não sejam executadas exatamente no mesmo instante. Paralelismo acontece quando duas ou mais tarefas realmente executam instruções ao mesmo tempo, normalmente em diferentes núcleos do processador.


Considere um programa que precisa estabelecer três conexões TCP. Na execução sequencial, ele tenta estabelecer a primeira conexão, aguarda o resultado e somente depois inicia a segunda. Em uma implementação concorrente, podemos iniciar as três tentativas sem esperar a conclusão individual de cada uma, permitindo que avancem conforme o sistema operacional e a rede disponibilizem seus resultados.


Isso não significa que as três operações estarão necessariamente executando instruções ao mesmo tempo. Enquanto uma conexão aguarda uma resposta da rede, outra goroutine pode continuar seu processamento. O runtime do Go é responsável por organizar essa execução, permitindo que diferentes tarefas avancem sem que o programa precise administrar diretamente as threads do sistema operacional.



Criando uma goroutine​


Em Go, podemos iniciar uma goroutine usando a palavra-chave go antes da chamada de uma função. Essa instrução solicita que a função seja executada concorrentemente, permitindo que o código responsável pela chamada continue sua execução sem aguardar o retorno da função iniciada. Não precisamos declarar um tipo especial de função para trabalhar com goroutines.


Vamos criar um exemplo que executa uma função responsável por apresentar uma mensagem. A função será iniciada como goroutine, enquanto a função main continuará executando suas próprias instruções.

package main

import (
"fmt"
"time"
)

func tarefa() {
// Representa uma operação que demora para terminar.
time.Sleep(2 * time.Second)

fmt.Println("Tarefa concluída.")
}

func main() {
// Inicia a função em uma nova goroutine.
go tarefa()

fmt.Println("Programa principal.")

// Mantém o programa ativo apenas para demonstrar a execução.
time.Sleep(3 * time.Second)
}

Nesse exemplo, a instrução go tarefa() inicia a função tarefa em uma nova goroutine. Enquanto ela aguarda os dois segundos definidos por time.Sleep, a função main continua executando e apresenta sua mensagem. A função time.Sleep suspende a execução da goroutine que a chamou durante o intervalo informado, sem exigir que todas as outras goroutines também parem.


A saída normalmente será semelhante a esta:

Programa principal.
Tarefa concluída.

O programa apresenta primeiro a mensagem da função main, enquanto a outra goroutine continua aguardando o intervalo definido. Entretanto, usamos time.Sleep apenas para demonstrar o comportamento, pois ele não garante que uma tarefa tenha terminado. Se a operação demorar mais do que o intervalo escolhido, o programa poderá encerrar antes que a goroutine conclua seu trabalho.


Outro comportamento é que o encerramento da função main termina o processo, mesmo que existam outras goroutines em execução. Portanto, iniciar uma goroutine não mantém automaticamente o programa ativo até que ela termine. Para aguardar tarefas de maneira correta, precisamos de mecanismos de sincronização, como sync.WaitGroup ou channels.



Executando várias goroutines​


Podemos iniciar várias goroutines usando a palavra-chave go em chamadas diferentes ou dentro de um laço. Cada goroutine executará a função informada, permitindo que várias tarefas avancem durante o mesmo período. Esse comportamento é útil em ferramentas de rede porque diferentes conexões podem ser iniciadas sem que uma precise aguardar o resultado da outra.


Para demonstrar esse funcionamento, podemos criar uma função que recebe um identificador e representa uma tarefa independente. O programa iniciará três goroutines, cada uma responsável por apresentar uma mensagem depois de um pequeno intervalo.

package main

import (
"fmt"
"sync"
"time"
)

func tarefa(id int) {
// Simula o tempo gasto durante uma operação.
time.Sleep(1 * time.Second)

fmt.Printf("Tarefa %d concluída.\n", id)
}

func main() {
var wg sync.WaitGroup

for i := 1; i <= 3; i++ {
// Registra uma nova tarefa no grupo.
wg.Add(1)

go func(id int) {
// Informa a conclusão ao sair da função.
defer wg.Done()

tarefa(id)
}(i)
}

// Aguarda todas as goroutines terminarem.
wg.Wait()

fmt.Println("Todas as tarefas terminaram.")
}

O programa cria três goroutines, cada uma recebendo um identificador diferente. Como as tarefas são executadas concorrentemente, todas podem aguardar seu intervalo durante o mesmo período. Isso significa que o tempo total de execução pode ficar próximo de um segundo, em vez dos três segundos que seriam necessários se cada espera fosse executada sequencialmente.


A ordem das mensagens não é garantida. Uma goroutine pode concluir antes de outra mesmo tendo sido iniciada depois, porque o agendamento depende do runtime e das condições de execução. Por isso, não devemos escrever programas concorrentes supondo que as goroutines terminarão na mesma ordem em que foram criadas.



Aguardando goroutines com WaitGroup​


O pacote sync fornece o tipo WaitGroup, que permite aguardar a conclusão de um grupo de tarefas. Ele funciona mantendo um contador interno que representa a quantidade de operações ainda não concluídas. Podemos aumentar esse contador antes de iniciar uma tarefa, diminuí-lo quando ela terminar e bloquear a execução até que todas tenham sido finalizadas.


No exemplo anterior, usamos três métodos para controlar esse comportamento. O método Add acrescenta tarefas ao contador, Done informa que uma tarefa terminou e Wait aguarda até que o contador chegue a zero. Essa combinação permite que a função main espere pelas goroutines sem depender de intervalos arbitrários definidos com time.Sleep.

var wg sync.WaitGroup

// Registra uma tarefa que será executada.
wg.Add(1)

go func() {
// Reduz o contador quando a goroutine terminar.
defer wg.Done()

fmt.Println("Executando tarefa.")
}()

// Bloqueia até que todas as tarefas registradas terminem.
wg.Wait()

Esse trecho pressupõe que os pacotes sync e fmt foram importados e que o código está dentro de uma função. O método wg.Add(1) deve ser chamado antes de iniciar a goroutine, garantindo que a tarefa seja registrada antes que wg.Wait() verifique o contador. O uso de defer wg.Done() permite informar a conclusão quando a função retornar, inclusive quando existem diferentes caminhos de retorno.


Precisamos manter o contador correto durante toda a execução. Se chamarmos Add para registrar uma tarefa, mas não executarmos o Done correspondente, Wait poderá permanecer bloqueado indefinidamente. Também não devemos chamar Done mais vezes do que a quantidade registrada, porque um contador negativo provoca um panic.


Nas versões atuais do Go, também existe o método WaitGroup.Go, introduzido no Go 1.25, que inicia uma função em uma goroutine e gerencia sua participação no grupo. Os exemplos deste capítulo continuam usando Add, Done e Wait porque deixam explícito como o contador funciona e são compatíveis com versões anteriores da linguagem. Ao usar WaitGroup.Go, a função recebida não deve provocar panic.



Cuidados com dados compartilhados​


Goroutines executadas dentro do mesmo processo podem acessar as mesmas variáveis, estruturas e objetos. Entretanto, quando duas goroutines acessam uma mesma região de memória concorrentemente e pelo menos uma modifica seu conteúdo, pode ocorrer uma condição chamada data race. Esse problema acontece quando os acessos não possuem a sincronização necessária para garantir um comportamento correto.


Considere duas goroutines que incrementam uma variável compartilhada. Embora a expressão contador++ pareça representar uma única operação, seu processamento envolve acessar o valor atual, calcular o incremento e armazenar o resultado. Se duas goroutines executarem essas etapas concorrentemente, uma atualização poderá sobrescrever a outra, fazendo com que o resultado final seja diferente do esperado.


O WaitGroup permite aguardar a conclusão das tarefas, mas não protege automaticamente as variáveis compartilhadas. Para controlar o acesso aos dados, podemos usar mecanismos como sync.Mutex, que permite restringir o acesso simultâneo a determinada região do código, ou channels, que permitem transferir informações entre goroutines. A escolha depende de como os dados precisam ser compartilhados.


Go também possui um detector de data races, que pode ser ativado durante a execução ou os testes. Esse recurso ajuda a identificar acessos concorrentes inadequados que realmente ocorrerem durante a execução analisada.

go run -race main.go

O argumento -race ativa o detector de data races no programa compilado pelo comando go run. Também podemos usar go test -race para executar testes com essa verificação habilitada. O detector não garante encontrar todos os problemas possíveis, porque consegue observar apenas os caminhos de execução que realmente aconteceram durante a análise.



Usando channels de forma simples​


Channels são estruturas da linguagem Go usadas para enviar e receber valores entre goroutines. Eles permitem organizar a comunicação entre tarefas concorrentes sem exigir que todas compartilhem diretamente as mesmas variáveis. Um channel pode transportar valores de determinado tipo, permitindo que uma goroutine produza uma informação e outra receba o resultado quando estiver disponível.


Esse mecanismo é especialmente útil em programas de rede, nos quais diferentes goroutines podem executar conexões e produzir resultados independentes. Em vez de modificar uma estrutura compartilhada diretamente, cada goroutine pode enviar o resultado por um channel, enquanto outra parte do programa fica responsável por receber e processar essas informações.


Além de transportar dados, channels também podem funcionar como mecanismos de sincronização. Dependendo de como são criados, uma operação de envio pode precisar aguardar um receptor, enquanto uma operação de recebimento pode permanecer bloqueada até que exista um valor disponível. Esse comportamento permite coordenar tarefas usando operações da própria linguagem.



Criando e usando um channel​


Podemos criar um channel usando a função make e informando o tipo de dado que será transportado. Um channel declarado como chan string, por exemplo, permite enviar e receber strings, enquanto chan int trabalha com números inteiros. O tipo do channel define quais valores podem ser transmitidos, impedindo o envio de dados incompatíveis durante a compilação.


Para enviar um valor, usamos o operador <- depois do channel. Para receber, usamos o mesmo operador antes do channel. Podemos demonstrar esse comportamento criando uma goroutine que envia uma mensagem para a função main.

package main

import "fmt"

func main() {
// Cria um channel que transporta strings.
mensagens := make(chan string)

go func() {
// Envia uma mensagem para o channel.
mensagens <- "Conexão concluída."
}()

// Aguarda e recebe a mensagem enviada.
mensagem := <-mensagens

fmt.Println(mensagem)
}

A função make(chan string) cria um channel não armazenado em buffer, também chamado de channel sem buffer. Quando a goroutine executa mensagens <- "Conexão concluída.", o envio precisa encontrar uma operação de recebimento correspondente para ser concluído. Ao mesmo tempo, a função main permanece aguardando em <-mensagens até que o valor seja recebido.


Quando a transferência acontece, a variável mensagem recebe a string enviada e o programa continua sua execução. Nesse exemplo, não precisamos usar WaitGroup, porque a própria comunicação pelo channel garante que a função main aguarde o recebimento da informação antes de continuar. Entretanto, essa sincronização cobre apenas a transferência do valor, não necessariamente outras operações que a goroutine possa executar depois do envio.



Channels com buffer​


Por padrão, um channel criado sem capacidade adicional exige que exista uma operação de envio e uma operação de recebimento correspondentes para que a comunicação aconteça. Entretanto, também podemos criar channels com buffer, que possuem capacidade para armazenar determinada quantidade de valores enquanto aguardam seu processamento.


Para criar um channel com buffer, informamos sua capacidade como segundo argumento da função make. Um channel criado com make(chan string, 2) pode armazenar até duas strings sem precisar que exista um receptor executando naquele momento. Quando o buffer fica cheio, novas operações de envio permanecem bloqueadas até que algum valor seja recebido.

package main

import "fmt"

func main() {
// Cria um channel com capacidade para dois valores.
mensagens := make(chan string, 2)

// Armazena duas mensagens no buffer.
mensagens <- "Servidor A"
mensagens <- "Servidor B"

// Recebe as mensagens armazenadas.
fmt.Println(<-mensagens)
fmt.Println(<-mensagens)
}

Nesse exemplo, as duas operações de envio são concluídas porque o buffer possui capacidade suficiente para armazenar os valores. Depois, as chamadas de recebimento retiram as mensagens na ordem em que foram enviadas. Se tentássemos enviar uma terceira mensagem antes de receber alguma das anteriores, a operação ficaria bloqueada porque o buffer já estaria cheio.


A capacidade de um channel pode ser consultada usando cap, enquanto len informa quantos valores estão armazenados no buffer naquele momento. Essas funções não devem ser usadas como mecanismo principal de sincronização entre goroutines, porque a quantidade de elementos pode mudar imediatamente depois da consulta. Para coordenar operações concorrentes, normalmente trabalhamos diretamente com os envios, recebimentos e sinais de cancelamento.



Recebendo resultados de várias goroutines​


Uma aplicação de rede pode executar diferentes operações concorrentemente e precisar reunir os resultados em um único local. Podemos criar um channel compartilhado para receber essas informações, permitindo que cada goroutine envie seu resultado assim que terminar. Dessa maneira, a função principal não precisa acessar diretamente as variáveis internas de cada tarefa.


Vamos criar um exemplo com três goroutines que representam operações independentes. Cada tarefa recebe um identificador, aguarda um pequeno intervalo e envia uma mensagem para o mesmo channel. A função main receberá uma mensagem para cada tarefa iniciada.

package main

import (
"fmt"
"time"
)

func main() {
// Cria um channel para receber os resultados.
resultados := make(chan string)

for i := 1; i <= 3; i++ {
go func(id int) {
// Simula uma operação que demora para responder.
time.Sleep(time.Duration(id) * time.Second)

// Envia o resultado quando terminar.
resultados <- fmt.Sprintf(
"Tarefa %d concluída.",
id,
)
}(i)
}

// Recebe um resultado para cada tarefa iniciada.
for i := 0; i < 3; i++ {
fmt.Println(<-resultados)
}
}

O programa inicia três goroutines, cada uma responsável por enviar uma mensagem ao channel resultados. Como cada tarefa possui um intervalo diferente, os valores normalmente serão recebidos conforme as operações terminarem. A função main executa três recebimentos porque conhece a quantidade de tarefas iniciadas, garantindo que todas as mensagens sejam processadas antes do encerramento.


Esse modelo funciona bem quando sabemos exatamente quantos resultados serão produzidos. Entretanto, em programas maiores, a quantidade de tarefas pode variar e nem sempre queremos controlar manualmente o número de recebimentos. Nesses casos, podemos fechar o channel depois que todos os produtores terminarem e permitir que a função responsável pelo processamento continue recebendo até que não existam mais valores.



Fechando channels​


Um channel pode ser fechado usando a função close, que indica que nenhum outro valor será enviado por ele. O fechamento não remove os valores que já estão armazenados no buffer, permitindo que sejam recebidos normalmente antes que o channel seja considerado completamente esgotado. Depois que os valores pendentes forem consumidos, novas operações de recebimento retornam imediatamente o valor zero do tipo armazenado.


Também podemos usar range para receber valores continuamente até que o channel seja fechado. Essa abordagem é conveniente quando a quantidade de resultados não precisa ser conhecida antecipadamente, desde que exista uma goroutine responsável por encerrar o envio e fechar o channel quando todas as tarefas terminarem.

package main

import (
"fmt"
"sync"
)

func main() {
resultados := make(chan int)

var wg sync.WaitGroup

for i := 1; i <= 3; i++ {
wg.Add(1)

go func(numero int) {
defer wg.Done()

// Envia o resultado da tarefa.
resultados <- numero * 2
}(i)
}

go func() {
// Aguarda todos os produtores terminarem.
wg.Wait()

// Fecha o channel após o último envio.
close(resultados)
}()

// Recebe os valores até o channel ser fechado.
for resultado := range resultados {
fmt.Println(resultado)
}
}

O programa inicia três goroutines que enviam valores para o channel resultados. Depois, cria uma goroutine responsável por aguardar o WaitGroup e fechar o channel quando todas as tarefas forem concluídas. Enquanto isso, a função main continua recebendo os valores usando range, que termina automaticamente quando o channel é fechado e todos os valores pendentes já foram recebidos.


Normalmente, o fechamento deve ser feito por quem controla os envios, depois que não existe mais nenhuma goroutine tentando enviar informações. Tentar enviar um valor para um channel fechado provoca um panic, assim como tentar fechar um channel que já está fechado. Também não precisamos fechar todos os channels obrigatoriamente, pois o fechamento é principalmente uma forma de informar que não existirão novos valores.



Usando select com channels​


A instrução select permite aguardar diferentes operações de comunicação com channels. Ela possui uma estrutura semelhante ao switch, mas suas alternativas são operações de envio ou recebimento. Quando uma dessas operações pode prosseguir, o select executa o bloco correspondente, permitindo reagir a diferentes eventos sem precisar bloquear o programa em uma única operação.


Esse recurso é especialmente útil quando precisamos receber uma resposta, mas também queremos estabelecer um tempo máximo de espera. Podemos combinar um channel de resultados com time.After, que retorna um channel usado para sinalizar que determinado intervalo terminou.

package main

import (
"fmt"
"time"
)

func main() {
resultado := make(chan string, 1)

go func() {
// Simula uma operação demorada.
time.Sleep(2 * time.Second)

resultado <- "Operação concluída."
}()

select {
case mensagem := <-resultado:
fmt.Println(mensagem)

case <-time.After(1 * time.Second):
fmt.Println("Tempo de espera excedido.")
}
}

O select aguarda até que uma das operações possa prosseguir. Se a mensagem chegar primeiro, o programa executará o bloco responsável pelo recebimento. Se o intervalo de um segundo terminar antes da resposta, o bloco associado a time.After será executado. Quando mais de uma alternativa está pronta ao mesmo tempo, o Go escolhe uma delas de maneira pseudoaleatória.


Nesse exemplo, o timeout controla apenas quanto tempo a função main aguarda pelo resultado. Ele não cancela automaticamente a goroutine que está executando a tarefa, que continuará trabalhando até terminar sua operação. O channel possui buffer para permitir que o envio seja concluído mesmo quando a função principal já deixou de receber, mas isso não substitui um mecanismo de cancelamento para operações maiores.



Timeouts e cancelamento com context​


Operações de rede podem demorar mais do que o esperado ou permanecer aguardando respostas que nunca chegam. Uma conexão TCP pode não ser estabelecida dentro do intervalo desejado, um servidor pode parar de responder ou uma tarefa pode deixar de ser necessária depois que outra operação falhou. Para controlar essas situações, precisamos estabelecer limites de tempo e permitir que operações em andamento sejam canceladas.


Go possui o pacote context, que permite transportar sinais de cancelamento, limites de tempo e informações associadas a uma operação. Um contexto pode ser compartilhado por diferentes funções e goroutines, permitindo que elas identifiquem quando precisam interromper seu trabalho. Esse mecanismo é bastante usado em operações de rede, servidores HTTP, consultas a bancos de dados e outras tarefas que podem permanecer bloqueadas.


O contexto não interrompe automaticamente qualquer função ou goroutine. Para que o cancelamento funcione, a operação precisa consultar o contexto ou chamar uma função que ofereça suporte a ele. Quando o contexto é cancelado, o código responsável pela tarefa precisa reconhecer esse sinal e encerrar sua execução de maneira adequada.



Criando um context​


O pacote context possui a função context.Background, que retorna um contexto vazio, sem cancelamento automático e sem prazo definido. Ele normalmente é usado como contexto inicial de uma aplicação, a partir do qual podemos criar outros contextos com regras específicas. Esse contexto inicial não possui uma operação própria para cancelamento, mas pode servir como origem para contextos derivados.


Podemos criar um contexto básico e passá-lo para funções que recebem context.Context como argumento. A convenção adotada em Go é colocar esse parâmetro como o primeiro argumento da função, normalmente com o nome ctx.

package main

import (
"context"
"fmt"
)

func executar(ctx context.Context) {
// Recebe o contexto associado à operação.
fmt.Println("Operação iniciada.")

_ = ctx
}

func main() {
// Cria o contexto inicial do programa.
ctx := context.Background()

executar(ctx)
}

Nesse exemplo, a função executar recebe um contexto, mas ainda não realiza operações que dependam dele. A instrução _ = ctx apenas evita que o parâmetro fique sem uso no exemplo. Em aplicações reais, esse contexto pode ser encaminhado para outras funções ou usado para verificar cancelamentos e limites de tempo.


O mesmo contexto pode ser passado para diferentes goroutines, porque seus métodos foram projetados para permitir acesso concorrente. Entretanto, ele não deve ser usado como substituto para uma estrutura comum de configuração. Valores como endereço, porta ou tamanho de buffer normalmente devem ser recebidos por parâmetros próprios, enquanto o contexto fica responsável por informações associadas à duração e ao controle da operação.



Criando um timeout com context.WithTimeout​


A função context.WithTimeout permite criar um contexto derivado que será cancelado quando determinado intervalo de tempo terminar. Ela recebe um contexto de origem e uma duração, retornando um novo contexto e uma função de cancelamento. Podemos usar esse mecanismo para limitar quanto tempo uma tarefa deve permanecer em andamento antes de abandonar seu trabalho.


O contexto disponibiliza o método Done, que retorna um channel fechado quando acontece o cancelamento. Podemos aguardar esse sinal usando select, permitindo que uma goroutine identifique quando o prazo definido terminou. Também podemos consultar Err para descobrir por que o contexto foi encerrado.

package main

import (
"context"
"fmt"
"time"
)

func main() {
// Cria um contexto com limite de dois segundos.
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)

// Libera os recursos associados ao contexto.
defer cancel()

select {
case <-time.After(5 * time.Second):
fmt.Println("Operação concluída.")

case <-ctx.Done():
fmt.Println("Operação interrompida:", ctx.Err())
}
}

Nesse exemplo, o contexto possui um limite de dois segundos, enquanto a operação simulada demoraria cinco segundos para terminar. Como o prazo do contexto termina primeiro, o channel retornado por ctx.Done() é fechado e o select executa o bloco responsável pelo cancelamento. O método ctx.Err() retorna context.DeadlineExceeded, indicando que o limite de tempo foi ultrapassado.


A chamada defer cancel() libera os recursos associados ao contexto quando a função terminar. Mesmo quando o timeout é alcançado automaticamente, devemos chamar a função de cancelamento porque ela também permite liberar referências e temporizadores associados ao contexto derivado. Se a operação terminar antes do prazo, essa chamada evita manter recursos desnecessários até que o timeout expire.


Precisamos diferenciar o prazo associado ao contexto do tempo gasto em uma operação específica. Um contexto com limite de cinco segundos pode ser compartilhado por várias funções, e todas elas estarão sujeitas ao mesmo prazo final. Isso é diferente de criar um novo contexto de cinco segundos para cada função, porque nesse segundo caso cada operação recebe seu próprio intervalo de tempo.



Cancelando operações manualmente​


Nem sempre queremos esperar que um prazo termine para interromper uma tarefa. Em algumas situações, uma operação deixa de ser necessária porque o usuário cancelou um comando, outra tarefa encontrou um erro ou o programa está encerrando. Para esses casos, podemos usar context.WithCancel, que cria um contexto associado a uma função de cancelamento manual.


Quando essa função é chamada, o channel retornado por ctx.Done() é fechado, permitindo que as funções que recebem o contexto identifiquem o cancelamento. O contexto também informa o motivo por meio de ctx.Err(), que normalmente retorna context.Canceled quando o encerramento foi provocado por um cancelamento explícito.

package main

import (
"context"
"fmt"
)

func main() {
// Cria um contexto que pode ser cancelado manualmente.
ctx, cancel := context.WithCancel(
context.Background(),
)

fim := make(chan struct{})

go func() {
// Sinaliza quando a goroutine terminar.
defer close(fim)

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

fmt.Println("Tarefa encerrada:", ctx.Err())
}()

// Solicita o cancelamento da tarefa.
cancel()

// Aguarda a goroutine reconhecer o cancelamento.
<-fim
}

A função context.WithCancel retorna um contexto e uma função responsável por cancelá-lo. Depois de iniciar a goroutine, a função main chama cancel(), fazendo com que ctx.Done() seja fechado. A goroutine recebe esse sinal, apresenta a mensagem correspondente e retorna, encerrando sua execução.


O channel fim é usado para informar quando a goroutine realmente terminou. Isso acontece porque chamar cancel() apenas solicita o cancelamento, sem aguardar a conclusão das tarefas que receberam o contexto. Quando precisamos garantir que os recursos foram liberados antes de continuar, também devemos aguardar a finalização das goroutines, usando channels ou outros mecanismos de sincronização.


O tipo chan struct{} é comum quando precisamos transmitir apenas um sinal, sem transportar informações adicionais. A estrutura vazia struct{} não possui campos, permitindo representar eventos como conclusão ou cancelamento sem precisar enviar um valor significativo. O fechamento do channel pode ser observado por diferentes goroutines, permitindo sinalizar que determinado evento aconteceu.



Tratando os motivos do cancelamento​


Quando um contexto é cancelado, podemos consultar ctx.Err() para identificar a condição responsável pelo encerramento. Os dois resultados mais comuns são context.Canceled, que representa um cancelamento solicitado, e context.DeadlineExceeded, que indica que o prazo do contexto terminou. Antes do cancelamento, ctx.Err() retorna nil.


Como essas condições são representadas por valores do tipo error, podemos usar errors.Is para compará-las. Essa função permite verificar se determinado erro corresponde a um valor esperado, inclusive quando ele foi encapsulado por outras funções que preservam sua identidade usando mecanismos como %w.

// Verifica o motivo do encerramento do contexto.
switch {
case errors.Is(ctx.Err(), context.Canceled):
fmt.Println("Operação cancelada.")

case errors.Is(ctx.Err(), context.DeadlineExceeded):
fmt.Println("Tempo limite excedido.")

default:
fmt.Println("Contexto ainda ativo.")
}

Esse trecho pressupõe que ctx representa um contexto válido e que os pacotes context, errors e fmt foram importados. A verificação permite diferenciar um cancelamento intencional de uma operação que ultrapassou seu limite de tempo. Essa distinção pode ajudar a definir mensagens de erro, códigos de saída e estratégias de tratamento dentro de ferramentas de linha de comando.



Aplicando context em conexões TCP​


O pacote net fornece o tipo Dialer, responsável por configurar e estabelecer conexões de rede. Seu método DialContext permite iniciar uma conexão usando um contexto, fazendo com que a tentativa possa ser interrompida quando o prazo terminar ou quando um cancelamento for solicitado. Isso permite controlar a duração da tentativa de conexão sem depender exclusivamente dos limites definidos pelo sistema operacional.


O método recebe um contexto, o tipo de rede e o endereço de destino. Para uma conexão TCP, usamos "tcp" como tipo de rede e informamos o destino no formato de endereço e porta. Podemos combinar esse método com context.WithTimeout para estabelecer um limite de tempo para a tentativa de conexão.

package main

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

func main() {
// Cria um contexto com limite de três segundos.
ctx, cancel := context.WithTimeout(
context.Background(),
3*time.Second,
)

defer cancel()

// Cria um cliente responsável pela tentativa de conexão.
dialer := net.Dialer{}

// Tenta estabelecer uma conexão TCP.
conn, err := dialer.DialContext(
ctx,
"tcp",
"127.0.0.1:8080",
)

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

os.Exit(1)
}

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

fmt.Println("Conexão TCP estabelecida.")
}

Nesse exemplo, context.WithTimeout cria um contexto com limite de três segundos, enquanto net.Dialer é responsável por tentar estabelecer a conexão TCP. O método DialContext utiliza esse contexto durante a tentativa, permitindo que ela termine com erro se o prazo for ultrapassado antes que a conexão seja concluída. Se a conexão for estabelecida, recebemos um valor que implementa net.Conn.


O endereço 127.0.0.1 representa a própria máquina, enquanto 8080 identifica a porta de destino. Para que a conexão seja estabelecida, precisamos ter um serviço TCP escutando nessa porta. Caso contrário, o programa poderá receber um erro de conexão recusada, que é diferente de um timeout e não significa necessariamente que o prazo configurado foi alcançado.


O contexto passado para DialContext controla a tentativa de estabelecer a conexão, mas não continua controlando automaticamente a conexão depois que ela foi estabelecida. Se o contexto for cancelado após o sucesso, isso não provoca o fechamento automático de conn. Para limitar operações posteriores de leitura e escrita, precisamos aplicar mecanismos específicos, como os deadlines disponíveis na interface net.Conn.


Também precisamos considerar que os.Exit encerra o processo imediatamente, sem executar chamadas pendentes de defer. Nesse exemplo, o encerramento acontece antes de uma conexão ser obtida, enquanto a conexão estabelecida é fechada normalmente quando a função retorna. Em programas maiores, geralmente é melhor retornar o erro para uma função responsável por controlar o encerramento, permitindo que os recursos já abertos sejam liberados corretamente.



Definindo timeout para leitura e escrita​


Depois que uma conexão TCP é estabelecida, podemos executar operações de leitura e escrita usando os métodos Read e Write. Entretanto, essas operações também podem permanecer bloqueadas aguardando dados ou espaço disponível para transmissão. Como o contexto usado na conexão não limita automaticamente essas operações, podemos trabalhar com deadlines definidos diretamente na conexão.


A interface net.Conn fornece SetDeadline, SetReadDeadline e SetWriteDeadline, que permitem estabelecer limites de tempo para operações de entrada e saída. Esses métodos recebem um valor time.Time, que representa um instante específico. Diferentemente de uma duração relativa, o deadline indica o horário máximo até o qual determinada operação poderá continuar aguardando.


Podemos configurar um prazo para leitura e escrita depois que a conexão for estabelecida. O exemplo seguinte pressupõe que a variável conn contém uma conexão válida que implementa net.Conn e que o pacote time foi importado.

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

if err != nil {
// Trata a falha ao configurar o deadline.
return err
}

A função time.Now() obtém o instante atual, enquanto Add acrescenta cinco segundos a esse valor. O método SetDeadline aplica esse instante como prazo para as operações de leitura e escrita da conexão. Quando o deadline é ultrapassado, operações bloqueadas podem retornar um erro indicando timeout, permitindo que o programa interrompa a espera.


Também podemos configurar deadlines separados. O método SetReadDeadline afeta operações de leitura, enquanto SetWriteDeadline controla operações de escrita. Esses limites são associados a instantes específicos e continuam valendo até que sejam alterados, por isso uma aplicação que mantém conexões abertas por muito tempo pode precisar atualizar os deadlines conforme novas operações forem executadas.


O uso de um deadline também não significa que uma operação necessariamente demorará até o limite definido. Ela pode terminar antes por sucesso, erro de rede ou encerramento da conexão. Quando precisamos implementar um timeout por período de inatividade, podemos atualizar o deadline depois de cada operação concluída, mantendo um novo prazo relativo ao último momento de atividade.



Executando várias conexões ao mesmo tempo​


Depois de entender goroutines, channels e contextos, podemos combinar esses recursos para executar várias tentativas de conexão concorrentemente. Em uma ferramenta de diagnóstico, por exemplo, podemos precisar verificar se diferentes serviços TCP estão aceitando conexões. Se essas verificações forem feitas sequencialmente, cada tentativa precisará terminar antes que a próxima seja iniciada.


Com goroutines, podemos iniciar diferentes tentativas e permitir que cada uma aguarde seu resultado independentemente das outras. Isso pode reduzir o tempo total de execução, principalmente quando algumas conexões demoram para responder. Entretanto, precisamos controlar o tempo máximo de espera, armazenar os resultados e garantir que todas as tarefas sejam concluídas antes de encerrar o programa.


Nos exemplos seguintes, vamos trabalhar com endereços locais, usando 127.0.0.1. Isso permite demonstrar o funcionamento das conexões sem depender de servidores externos. Os resultados dependerão dos serviços que estiverem escutando nas portas informadas, por isso uma conexão recusada também será um resultado esperado quando não existir um servidor naquela porta.



Executando conexões sequencialmente​


Antes de executar várias conexões concorrentemente, podemos observar como seria uma implementação sequencial. Nesse modelo, o programa percorre uma lista de destinos e tenta estabelecer uma conexão com cada um deles. A próxima tentativa somente começa depois que a anterior termina, independentemente de ter sido concluída com sucesso ou erro.


Vamos usar net.DialTimeout, que permite definir um limite de tempo para a tentativa de conexão. Essa função recebe o tipo de rede, o endereço de destino e uma duração máxima, retornando uma conexão ou um erro.

package main

import (
"fmt"
"net"
"time"
)

func main() {
destinos := []string{
"127.0.0.1:8080",
"127.0.0.1:8081",
"127.0.0.1:8082",
}

for _, destino := range destinos {
// Tenta estabelecer a conexão com o destino atual.
conn, err := net.DialTimeout(
"tcp",
destino,
2*time.Second,
)

if err != nil {
fmt.Printf("%s: %v\n", destino, err)
continue
}

// Fecha a conexão após confirmar seu estabelecimento.
conn.Close()

fmt.Printf("%s: conexão estabelecida\n", destino)
}
}

O programa percorre os endereços definidos em destinos e executa uma tentativa de conexão TCP para cada elemento. A função net.DialTimeout recebe um limite de dois segundos, que controla quanto tempo a tentativa pode permanecer em andamento. Quando a conexão é estabelecida, o programa fecha o recurso imediatamente, porque o objetivo desse exemplo é apenas verificar se foi possível conectar.


Se uma tentativa consumir todo o intervalo de dois segundos, a próxima conexão somente será iniciada depois desse período. Como temos três destinos, o tempo total pode se aproximar de seis segundos quando todas as tentativas aguardam seus respectivos limites. Esse tempo não é garantido, porque erros como conexão recusada normalmente podem ser identificados mais rapidamente.


A execução sequencial é simples de implementar e pode ser suficiente quando a quantidade de destinos é pequena. Entretanto, conforme aumentamos o número de operações que dependem de respostas externas, a espera acumulada pode se tornar um problema. Nesse caso, podemos iniciar as tentativas em goroutines para permitir que avancem durante o mesmo período.



Executando conexões concorrentes​


Para executar várias conexões concorrentemente, podemos iniciar uma goroutine para cada destino. Cada goroutine será responsável por estabelecer sua conexão, verificar o resultado e informar quando terminar. O programa principal precisará aguardar todas essas tarefas, evitando encerrar a execução antes que os resultados sejam apresentados.


Podemos usar sync.WaitGroup para controlar essa sincronização. Como as operações são independentes, cada goroutine pode executar sua tentativa de conexão sem precisar aguardar as demais. Vamos manter um timeout individual de dois segundos para evitar que alguma tarefa permaneça aguardando indefinidamente.

package main

import (
"fmt"
"net"
"sync"
"time"
)

func main() {
destinos := []string{
"127.0.0.1:8080",
"127.0.0.1:8081",
"127.0.0.1:8082",
}

var wg sync.WaitGroup

for _, destino := range destinos {
// Registra uma nova tentativa de conexão.
wg.Add(1)

go func(endereco string) {
defer wg.Done()

// Tenta estabelecer uma conexão TCP.
conn, err := net.DialTimeout(
"tcp",
endereco,
2*time.Second,
)

if err != nil {
fmt.Printf("%s: %v\n", endereco, err)
return
}

// Libera a conexão após o teste.
conn.Close()

fmt.Printf(
"%s: conexão estabelecida\n",
endereco,
)
}(destino)
}

// Aguarda todas as tentativas terminarem.
wg.Wait()
}

Nesse exemplo, o laço percorre a lista de destinos e cria uma goroutine para cada endereço. O método wg.Add(1) registra a tarefa antes de iniciá-la, enquanto defer wg.Done() informa sua conclusão. Como cada goroutine executa sua tentativa independentemente, uma conexão que demora para responder não impede que as outras também sejam iniciadas.


A função main chama wg.Wait() depois de iniciar todas as tarefas, aguardando até que o contador chegue a zero. O tempo total de execução poderá ficar próximo ao maior tempo gasto por uma tentativa individual, em vez da soma de todos os tempos. Entretanto, isso depende do comportamento das conexões e dos recursos disponíveis no sistema.


A ordem das mensagens também poderá mudar entre execuções. Como cada goroutine apresenta seu resultado quando termina, a primeira mensagem pode pertencer a qualquer destino. Esse comportamento é esperado em programas concorrentes e não representa necessariamente um erro. Quando precisamos preservar uma ordem específica, podemos armazenar os resultados e organizá-los depois que todas as tarefas terminarem.



Organizando os resultados com channels​


Embora possamos imprimir os resultados diretamente de cada goroutine, essa abordagem mistura a execução das conexões com a apresentação das informações. Em ferramentas maiores, pode ser mais conveniente separar essas responsabilidades. Podemos fazer com que cada goroutine produza um resultado e o envie por um channel, enquanto a função principal recebe e apresenta os dados.


Para manter as informações organizadas, vamos criar uma estrutura chamada Resultado, que representa o destino consultado, o tempo gasto e um possível erro. Também criaremos uma função responsável por executar a conexão, permitindo que o mesmo comportamento seja chamado por diferentes goroutines sem repetir o código responsável pela tentativa.

package main

import (
"context"
"fmt"
"net"
"sync"
"time"
)

type Resultado struct {
Destino string
Duracao time.Duration
Erro error
}

func testarConexao(
ctx context.Context,
destino string,
) Resultado {
// Registra o início da tentativa.
inicio := time.Now()

dialer := net.Dialer{}

// Tenta estabelecer a conexão usando o contexto.
conn, err := dialer.DialContext(
ctx,
"tcp",
destino,
)

if err == nil {
// Fecha a conexão quando ela é estabelecida.
conn.Close()
}

return Resultado{
Destino: destino,
Duracao: time.Since(inicio),
Erro: err,
}
}

func main() {
destinos := []string{
"127.0.0.1:8080",
"127.0.0.1:8081",
"127.0.0.1:8082",
}

// Define um prazo compartilhado para as tentativas.
ctx, cancel := context.WithTimeout(
context.Background(),
5*time.Second,
)

defer cancel()

resultados := make(chan Resultado)

var wg sync.WaitGroup

for _, destino := range destinos {
wg.Add(1)

go func(endereco string) {
defer wg.Done()

// Executa a conexão e envia seu resultado.
resultados <- testarConexao(ctx, endereco)
}(destino)
}

go func() {
// Fecha o channel quando todos os envios terminarem.
wg.Wait()
close(resultados)
}()

// Processa os resultados conforme forem recebidos.
for resultado := range resultados {
if resultado.Erro != nil {
fmt.Printf(
"%s: erro após %s: %v\n",
resultado.Destino,
resultado.Duracao,
resultado.Erro,
)

continue
}

fmt.Printf(
"%s: conexão em %s\n",
resultado.Destino,
resultado.Duracao,
)
}
}

A estrutura Resultado reúne as informações produzidas por cada tentativa de conexão. O campo Destino identifica o endereço consultado, Duracao registra quanto tempo a operação demorou e Erro armazena uma possível falha. Essa organização permite transportar diferentes informações dentro de um único valor, facilitando o envio pelo channel e o processamento posterior.


A função testarConexao registra o instante inicial usando time.Now() e executa a tentativa com DialContext. Quando a conexão é estabelecida, ela é fechada imediatamente, porque o exemplo não precisa transmitir dados. Depois, time.Since calcula o tempo decorrido desde o início da tentativa, permitindo observar quanto tempo foi gasto até o sucesso ou erro.


Na função main, criamos um contexto compartilhado com limite de cinco segundos e iniciamos uma goroutine para cada destino. Todas recebem o mesmo contexto, portanto compartilham o prazo final definido para o conjunto de conexões. Se precisássemos de um limite independente para cada tentativa, poderíamos criar um contexto derivado dentro de cada goroutine.


O channel resultados recebe os valores produzidos pelas tarefas. Depois que todas as goroutines terminam, uma goroutine auxiliar fecha esse channel, permitindo que o laço range da função principal seja encerrado. Essa organização evita precisar conhecer a quantidade exata de resultados durante o recebimento e mantém a apresentação das informações separada das operações de rede.


Um cuidado é que o channel não possui buffer. Isso significa que uma goroutine que terminou sua conexão poderá ficar aguardando até que a função principal receba seu resultado. Nesse exemplo, isso não causa um bloqueio permanente porque a função principal permanece consumindo os valores enquanto as tarefas são executadas. Se o processamento dos resultados fosse interrompido antecipadamente, precisaríamos de mecanismos adicionais para evitar goroutines bloqueadas no envio.


Também precisamos lembrar que uma conexão TCP estabelecida indica apenas que foi possível completar essa etapa da comunicação. Isso não garante que o serviço esteja funcionando corretamente no nível da aplicação, nem que ele responderá a mensagens específicas. Para validar um protocolo como HTTP, precisaríamos enviar uma requisição e interpretar sua resposta, o que envolve operações adicionais de leitura e escrita.



Cuidados com muitas conexões simultâneas​


Criar uma goroutine para cada conexão é uma abordagem simples, mas pode se tornar inadequada quando a quantidade de destinos é muito grande. Embora goroutines sejam leves, cada tarefa consome recursos, como memória para sua execução e estruturas internas mantidas pelo runtime. Quando também existem conexões de rede, o programa passa a consumir recursos do sistema operacional associados aos sockets.


Sockets são recursos usados pelo sistema operacional para representar pontos de comunicação de rede. Cada conexão TCP estabelecida envolve recursos locais e remotos, incluindo estruturas de controle mantidas pelo sistema. Se tentarmos iniciar milhares de conexões ao mesmo tempo, podemos atingir limites de descritores, consumir memória excessivamente ou aumentar a carga sobre os serviços consultados.


Também não devemos assumir que mais concorrência sempre produzirá melhores resultados. Em determinados cenários, aumentar a quantidade de operações simultâneas pode causar congestionamento, aumentar o tempo de resposta e provocar mais falhas. Para controlar esse comportamento, precisamos limitar quantas conexões podem permanecer em andamento ao mesmo tempo.



Limitando quantidade de conexões simultâneas​


Limitar a concorrência significa definir quantas operações podem estar em andamento ao mesmo tempo. Em uma ferramenta que precisa consultar muitos destinos, podemos permitir que apenas determinada quantidade de conexões seja executada simultaneamente, mantendo as demais aguardando sua vez. Isso ajuda a controlar o consumo de recursos e evita iniciar mais operações do que o programa ou o ambiente consegue administrar adequadamente.


Em Go, podemos implementar esse controle usando channels com buffer ou criando um conjunto fixo de goroutines responsáveis por processar tarefas. A primeira abordagem pode funcionar como um semáforo, enquanto a segunda é conhecida como worker pool. Ambas permitem limitar a concorrência, mas organizam a execução de maneiras diferentes.


Um semáforo controla quantas tarefas podem entrar em determinada região do programa. Já um worker pool mantém uma quantidade definida de trabalhadores, chamados de workers, que recebem tarefas de uma fila e as executam conforme ficam disponíveis. Para começar, vamos usar um channel com buffer, porque ele permite compreender o controle de concorrência usando um recurso que já foi apresentado.



Criando um semáforo com channel​


Podemos usar um channel com buffer para representar a quantidade máxima de operações permitidas. Cada tarefa precisa ocupar uma posição desse channel antes de começar e liberar essa posição quando terminar. Quando todas as posições estão ocupadas, novas tarefas precisam aguardar, impedindo que a quantidade de operações simultâneas ultrapasse o limite definido.


Um channel do tipo chan struct{} é adequado para esse controle porque não precisamos transportar dados específicos. O objetivo é apenas representar permissões de execução, permitindo que cada envio ocupe uma posição e cada recebimento libere uma posição. Como o valor da estrutura vazia não contém informações, ele funciona como um marcador de ocupação.


Podemos demonstrar esse comportamento limitando a execução de cinco tarefas a duas operações simultâneas. Nesse primeiro exemplo, as tarefas apenas aguardam um segundo, permitindo observar como o semáforo organiza a execução antes de aplicarmos o mesmo mecanismo às conexões de rede.

package main

import (
"fmt"
"sync"
"time"
)

func main() {
const limite = 2

// Cria um semáforo com duas permissões.
semaforo := make(chan struct{}, limite)

var wg sync.WaitGroup

for i := 1; i <= 5; i++ {
// Aguarda até existir uma permissão disponível.
semaforo <- struct{}{}

wg.Add(1)

go func(id int) {
defer wg.Done()

// Libera a permissão quando a tarefa terminar.
defer func() {
<-semaforo
}()

fmt.Printf("Tarefa %d iniciada.\n", id)

// Simula o processamento da tarefa.
time.Sleep(1 * time.Second)

fmt.Printf("Tarefa %d concluída.\n", id)
}(i)
}

// Aguarda todas as tarefas terminarem.
wg.Wait()
}

O channel semaforo possui capacidade para dois valores, permitindo que até duas tarefas ocupem suas posições ao mesmo tempo. Antes de iniciar cada goroutine, o laço executa semaforo <- struct{}{}, ocupando uma permissão. Quando o buffer fica cheio, o envio seguinte permanece bloqueado até que alguma goroutine termine e execute o recebimento responsável por liberar sua posição.


O uso de defer garante que a permissão seja liberada quando a função da goroutine retornar. Dessa maneira, outras tarefas poderão começar conforme as anteriores forem terminando, mantendo o limite de duas operações simultâneas. O WaitGroup continua sendo necessário para garantir que o programa aguarde a conclusão de todas as tarefas antes de encerrar.


A quantidade de goroutines que executam a região controlada pelo semáforo não ultrapassa o limite estabelecido. Entretanto, a ordem em que as tarefas começam e terminam pode variar, porque depende do agendamento realizado pelo runtime. O semáforo controla a quantidade de operações permitidas, mas não garante uma ordem específica de execução.



Limitando conexões TCP​


Podemos aplicar o mesmo mecanismo às conexões TCP, substituindo a tarefa simulada por uma tentativa real de conexão. Cada goroutine ocupará uma permissão antes de iniciar sua operação e a liberará quando terminar. Isso permite trabalhar com uma lista maior de destinos sem precisar estabelecer todas as conexões durante o mesmo período.


Vamos criar uma ferramenta que recebe uma lista de endereços locais e permite executar no máximo duas tentativas simultâneas. Cada conexão terá um timeout próprio de dois segundos, evitando que uma tentativa permaneça aguardando indefinidamente e impeça a liberação de sua permissão.

package main

import (
"context"
"fmt"
"net"
"sync"
"time"
)

type Resultado struct {
Destino string
Duracao time.Duration
Erro error
}

func testarConexao(destino string) Resultado {
// Cria um prazo individual para esta tentativa.
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)

defer cancel()

inicio := time.Now()

dialer := net.Dialer{}

// Tenta estabelecer a conexão TCP.
conn, err := dialer.DialContext(
ctx,
"tcp",
destino,
)

if err == nil {
// Fecha a conexão após confirmar seu estabelecimento.
conn.Close()
}

return Resultado{
Destino: destino,
Duracao: time.Since(inicio),
Erro: err,
}
}

func main() {
destinos := []string{
"127.0.0.1:8080",
"127.0.0.1:8081",
"127.0.0.1:8082",
"127.0.0.1:8083",
"127.0.0.1:8084",
}

const limite = 2

// Controla quantas conexões podem executar ao mesmo tempo.
semaforo := make(chan struct{}, limite)

// Armazena um resultado para cada destino.
resultados := make(chan Resultado, len(destinos))

var wg sync.WaitGroup

for _, destino := range destinos {
// Aguarda uma permissão para iniciar a conexão.
semaforo <- struct{}{}

wg.Add(1)

go func(endereco string) {
defer wg.Done()

// Libera a permissão ao terminar.
defer func() {
<-semaforo
}()

// Executa a conexão e envia seu resultado.
resultados <- testarConexao(endereco)
}(destino)
}

// Aguarda todas as tentativas terminarem.
wg.Wait()

// Nenhuma goroutine enviará novos resultados.
close(resultados)

// Apresenta os resultados recebidos.
for resultado := range resultados {
if resultado.Erro != nil {
fmt.Printf(
"%s: erro após %s: %v\n",
resultado.Destino,
resultado.Duracao,
resultado.Erro,
)

continue
}

fmt.Printf(
"%s: conexão em %s\n",
resultado.Destino,
resultado.Duracao,
)
}
}

O programa define uma lista de cinco destinos e estabelece o limite de duas conexões simultâneas. O channel semaforo controla a quantidade de operações que podem começar, enquanto cada goroutine executa sua conexão usando testarConexao. Quando uma tentativa termina, sua permissão é liberada e a próxima operação pode ser iniciada.


Cada conexão possui um contexto próprio com limite de dois segundos. Isso significa que o timeout começa quando a função testarConexao é executada, e não quando o destino é encontrado pela primeira vez no laço. Como cada tarefa é iniciada somente depois de obter uma permissão, o prazo individual não é consumido enquanto o destino ainda aguarda sua vez.


O channel resultados possui capacidade igual à quantidade de destinos. Isso permite que cada goroutine envie seu resultado sem depender de um recebimento imediato pela função principal. Essa decisão é necessária para a organização escolhida, porque a função main primeiro inicia todas as tarefas e aguarda sua conclusão, deixando o processamento dos resultados para depois.


Se usássemos um channel sem buffer nesse exemplo, as goroutines poderiam ficar bloqueadas tentando enviar seus resultados, enquanto a função principal permaneceria aguardando a conclusão das tarefas. Como o recebimento só começaria depois de wg.Wait(), isso poderia provocar um deadlock. Um deadlock acontece quando as operações ficam permanentemente bloqueadas aguardando eventos que não podem acontecer enquanto o bloqueio continuar.


Depois que todas as goroutines terminam, o programa fecha o channel e percorre os resultados usando range. Essa organização permite controlar o número máximo de conexões e garantir que os resultados sejam processados antes do encerramento. Entretanto, o armazenamento de um resultado para cada destino também consome memória proporcional à quantidade de tarefas, o que pode exigir outra estratégia quando a lista for muito grande.



Trabalhando com worker pools​


Um worker pool é uma organização em que criamos uma quantidade fixa de goroutines responsáveis por executar tarefas recebidas de uma fila. Em vez de iniciar uma nova goroutine para cada destino, mantemos um conjunto de trabalhadores que permanecem recebendo operações enquanto existem tarefas disponíveis. Isso permite controlar a concorrência pelo número de workers, sem depender da criação de uma goroutine para cada elemento da lista.


Podemos implementar essa estrutura usando um channel de tarefas e outro de resultados. A função principal envia os destinos para o channel de entrada, enquanto os workers recebem essas informações e executam as conexões. Quando uma tarefa termina, o worker envia seu resultado e volta a aguardar outro destino, repetindo o processo até que o channel de tarefas seja fechado e esgotado.


Vamos criar um exemplo com dois workers, permitindo que apenas duas tentativas de conexão sejam executadas durante o mesmo período. Para manter a responsabilidade de cada parte organizada, usaremos uma função que executa a conexão e outra que representa o comportamento de cada trabalhador.

package main

import (
"context"
"fmt"
"net"
"sync"
"time"
)

type Resultado struct {
Destino string
Erro error
}

func conectar(destino string) Resultado {
// Define um timeout individual para a conexão.
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)

defer cancel()

dialer := net.Dialer{}

// Tenta estabelecer a conexão TCP.
conn, err := dialer.DialContext(
ctx,
"tcp",
destino,
)

if err == nil {
conn.Close()
}

return Resultado{
Destino: destino,
Erro: err,
}
}

func worker(
tarefas <-chan string,
resultados chan<- Resultado,
) {
// Recebe tarefas até o channel ser fechado.
for destino := range tarefas {
// Processa o destino recebido.
resultado := conectar(destino)

// Envia o resultado para processamento.
resultados <- resultado
}
}

func main() {
destinos := []string{
"127.0.0.1:8080",
"127.0.0.1:8081",
"127.0.0.1:8082",
"127.0.0.1:8083",
"127.0.0.1:8084",
}

const quantidadeWorkers = 2

tarefas := make(chan string)

resultados := make(chan Resultado)

var wg sync.WaitGroup

// Inicia uma quantidade fixa de workers.
for i := 0; i < quantidadeWorkers; i++ {
wg.Add(1)

go func() {
defer wg.Done()

worker(tarefas, resultados)
}()
}

go func() {
// Envia os destinos para os workers.
for _, destino := range destinos {
tarefas <- destino
}

// Informa que não existem novas tarefas.
close(tarefas)
}()

go func() {
// Aguarda os workers terminarem.
wg.Wait()

// Fecha os resultados após o último envio.
close(resultados)
}()

// Processa os resultados conforme forem chegando.
for resultado := range resultados {
if resultado.Erro != nil {
fmt.Printf(
"%s: %v\n",
resultado.Destino,
resultado.Erro,
)

continue
}

fmt.Printf(
"%s: conexão estabelecida\n",
resultado.Destino,
)
}
}

O programa cria dois workers, cada um executando a função worker em uma goroutine. Essa função recebe destinos pelo channel tarefas e executa uma conexão para cada valor recebido. Quando a operação termina, o resultado é enviado pelo channel resultados, permitindo que a função principal processe as informações enquanto os workers continuam trabalhando.


O channel de tarefas é alimentado por uma goroutine separada, que percorre a lista de destinos e envia cada endereço para processamento. Depois que todos os destinos são enviados, essa goroutine fecha tarefas, permitindo que os workers identifiquem quando não existem novas operações. O laço range de cada worker termina somente depois que o channel é fechado e todas as tarefas recebidas são consumidas.


O WaitGroup acompanha a execução dos workers, enquanto uma goroutine auxiliar aguarda sua conclusão e fecha o channel de resultados. Essa organização garante que resultados somente seja fechado quando nenhum worker estiver tentando enviar novos valores. Enquanto isso, a função main recebe os resultados continuamente, evitando que os workers fiquem bloqueados por falta de um receptor.


Como existem apenas dois workers, no máximo duas conexões serão executadas ao mesmo tempo. Quando um worker termina sua conexão, ele volta a receber outra tarefa, permitindo manter a concorrência dentro do limite estabelecido. Essa abordagem é conveniente quando precisamos processar muitas operações semelhantes, porque a quantidade de goroutines responsáveis pelas conexões permanece fixa.


Também precisamos considerar que o encerramento dos workers depende do fechamento do channel de tarefas. Se esse channel nunca for fechado e não existirem novos valores, os workers poderão permanecer bloqueados aguardando operações. Em aplicações que precisam interromper o processamento antecipadamente, normalmente combinamos o worker pool com um contexto de cancelamento, permitindo que os trabalhadores abandonem a espera quando a operação geral deixar de ser necessária.



Escolhendo entre semáforos e worker pools​


Semáforos e worker pools permitem limitar a concorrência, mas possuem diferenças na maneira como organizam as tarefas. Com o semáforo, podemos manter uma estrutura próxima da criação direta de goroutines, controlando quantas operações podem começar durante o mesmo período. Com o worker pool, criamos uma quantidade fixa de goroutines que recebem tarefas continuamente, separando a produção das operações de seu processamento.


Para uma lista pequena de conexões, o semáforo costuma ser simples de implementar e suficiente para controlar o consumo de recursos. Quando precisamos processar muitas tarefas semelhantes, um worker pool pode facilitar a organização e evitar a criação repetida de goroutines. Entretanto, nenhuma dessas abordagens elimina a necessidade de definir timeouts, tratar erros e garantir o encerramento correto das operações.


A quantidade de conexões simultâneas também deve considerar o comportamento dos serviços consultados e os recursos disponíveis no sistema. Um limite adequado para uma ferramenta local pode não funcionar da mesma maneira em outra máquina ou ambiente de rede. Por isso, ferramentas que executam muitas conexões normalmente permitem configurar esse valor por meio de uma flag ou arquivo de configuração, mantendo um padrão conservador quando nenhuma opção é informada.


Outro cuidado é que limitar conexões simultâneas não significa limitar a quantidade de conexões iniciadas por segundo. Se as operações terminarem rapidamente, um programa com poucos workers ainda poderá iniciar muitas conexões durante um intervalo curto. Quando precisamos controlar a frequência das tentativas, além da quantidade em andamento, será necessário implementar um mecanismo específico de limitação de taxa.


Em programas concorrentes de rede, também precisamos garantir que cada conexão seja fechada quando deixar de ser necessária, que os channels sejam encerrados somente depois que seus produtores terminarem e que todas as tarefas possam reconhecer cancelamentos. Esses cuidados evitam conexões abandonadas, goroutines bloqueadas e crescimento desnecessário do consumo de recursos durante execuções prolongadas.