A palavra-chave chan declara um tipo de channel. Um channel é um canal tipado que as goroutines usam para passar valores umas às outras, e cada envio e cada receção também sincronizam as duas goroutines envolvidas. chan Job transporta valores Job nos dois sentidos, chan<- Job só pode enviar e <-chan Job só pode receber. Um channel tem de ser criado com make antes de ser usado, porque o valor zero de um tipo de channel é nil (especificação do Go).
Resumo rápido
make(chan Job)cria um channel sem buffer. Um envio bloqueia até outra goroutine receber, por isso cada envio é uma entrega em mão.make(chan Job, 100)cria um channel com buffer. Os envios só bloqueiam quando o buffer está cheio, e as receções só bloqueiam quando está vazio.chan<- Jobe<-chan Jobna assinatura de uma função limitam-na a enviar ou a receber. O compilador rejeita tudo o resto.- Quem fecha o channel é quem envia, nunca quem recebe.
for job := range jobstermina depois do fecho, ev, ok := <-chdevolveok == falseassim que o channel está fechado e vazio. - Enviar para um channel fechado provoca um panic, e fechar um channel duas vezes também. Todas as operações sobre um channel
nil, excetoclose, bloqueiam para sempre. fatal error: all goroutines are asleep - deadlock!significa que todas as goroutines estão bloqueadas. Uma fuga é a versão silenciosa, em que só algumas goroutines ficam presas. O Go 1.27 encontra-as com o perfilgoroutineleak.- O
selectespera por várias operações em channels ao mesmo tempo. Dá-te timeouts, cancelamento e envios não bloqueantes.
Como se declara e cria um channel em Go?
Escreve chan seguido do tipo dos elementos. Uma variável declarada assim é nil até lhe atribuíres um channel criado com make:
example.gogopackage main import "fmt" type Job struct { ID int URL string } func main() { var queue chan Job fmt.Println(queue == nil, len(queue), cap(queue)) queue = make(chan Job, 100) queue <- Job{ID: 1, URL: "https://api.example.com/webhooks/stripe"} queue <- Job{ID: 2, URL: "https://api.example.com/webhooks/github"} fmt.Println(len(queue), cap(queue)) job := <-queue fmt.Println(job.ID, job.URL) }
example.texttexttrue 0 0 2 100 1 https://api.example.com/webhooks/stripe
ch <- v envia e <-ch recebe. A seta aponta sempre no sentido em que os dados se movem. O cap devolve o tamanho de buffer que passaste ao make, e o len devolve quantos valores estão no buffer nesse momento. Os valores saem pela ordem em que entraram.
Um channel é uma referência para uma estrutura do runtime, tal como um map. Passá-lo a uma função ou guardá-lo numa struct copia a referência, por isso todas as cópias apontam para o mesmo channel. A palavra-chave var em Go e var vs make tratam os channels, os maps e os slices como os três tipos que normalmente se criam com make.
Qual é a diferença entre channels com buffer e sem buffer?
Um channel sem buffer não tem armazenamento. Um envio espera até um recetor ficar com o valor, e uma receção espera até um emissor oferecer um. Os dois lados encontram-se no mesmo instante. A especificação do Go diz que «communication succeeds only when both a sender and receiver are ready», ou seja, a comunicação só acontece quando o emissor e o recetor estão ambos prontos.
Aqui, um handler de uploads passa o nome de um ficheiro a um antivírus que demora 100ms a arrancar:
example.gogofunc main() { uploads := make(chan string) go func() { time.Sleep(100 * time.Millisecond) fmt.Println("scanner: ready") for name := range uploads { _ = name // virus-scan the file } }() start := time.Now() uploads <- "invoice-1001.pdf" fmt.Println("handler: send returned after", time.Since(start).Round(10*time.Millisecond)) }
example.texttextscanner: ready handler: send returned after 100ms
O envio não podia terminar antes de o antivírus estar pronto para receber. Quando o envio retorna, o handler sabe que o antivírus tem o valor. Essa garantia é útil por si só, e o modelo de memória do Go formaliza-a: uma receção num channel sem buffer está sincronizada antes de o envio correspondente terminar.
Um channel com buffer guarda até cap valores. Com make(chan string, 10), o envio do handler teria retornado de imediato, e o antivírus teria ido buscar o ficheiro 100ms depois. O emissor só bloqueia quando as 10 posições estão ocupadas.
Que tamanho de buffer deve ter um channel em Go?
Começa com zero ou um, e escolhe um número maior só quando consegues dizer para que serve. Um buffer não torna mais rápido um consumidor lento. Se os produtores forem sistematicamente mais rápidos do que os consumidores, um buffer de 1.000 enche e passa a comportar-se como um channel sem buffer, com 1.000 valores de latência extra à frente.
Os buffers ajudam em três situações:
- Uma goroutine que produz um único resultado.
make(chan Result, 1)deixa-a enviar e terminar mesmo que ninguém receba, e é assim que se corrige a fuga mostrada mais abaixo. - Picos. Um exportador de métricas que recebe 50 amostras de uma vez e as envia em lotes uma vez por segundo pode usar um buffer do tamanho de um pico normal.
- Limitar a concorrência. Um channel com buffer de capacidade N funciona como um semáforo que deixa N goroutines correr ao mesmo tempo. A secção sobre padrões tem um exemplo.
O que significam chan<- e <-chan em Go?
São tipos de channel com direção. chan<- Job é só de envio e <-chan Job é só de receção. Um chan Job bidirecional converte-se automaticamente em qualquer um deles, por isso crias o channel uma vez e passas a cada função apenas a direção de que ela precisa.
Um pipeline de webhooks com um produtor e uma etapa de entrega fica assim:
example.gogotype Result struct { JobID int Status int } func produce(ids []int, out chan<- Job) { for _, id := range ids { out <- Job{ID: id, URL: fmt.Sprintf("https://hooks.example.com/%d", id)} } close(out) } func deliver(in <-chan Job, results chan<- Result) { for job := range in { results <- Result{JobID: job.ID, Status: 200} } close(results) } func main() { jobs := make(chan Job) results := make(chan Result) go produce([]int{101, 102, 103}, jobs) go deliver(jobs, results) for r := range results { fmt.Printf("job %d: %d\n", r.JobID, r.Status) } }
example.texttextjob 101: 200 job 102: 200 job 103: 200
As assinaturas documentam quem é dono de cada ponta. Ao ler o deliver, sabes que nunca escreve em in e nunca o fecha. O compilador garante isso. Se o deliver tentar enviar para a sua entrada ou fechá-la:
example.gogopackage main type Job struct{ ID int } func deliver(in <-chan Job) { in <- Job{ID: 1} close(in) }
example.texttext./main.go:6:2: invalid operation: cannot send to receive-only channel <-chan Job in (variable of type <-chan Job) ./main.go:7:8: invalid operation: cannot close receive-only channel in (variable of type <-chan Job)
Fechar conta como uma operação do lado do envio, por isso só um chan<- ou um channel bidirecional pode ser fechado. Assim, a regra habitual de posse, «quem envia fecha», passa a ser algo que o sistema de tipos verifica por ti.
Como se fecha um channel em Go?
Chama close(ch). Fechar diz aos recetores que não vêm mais valores. Não deita fora os valores que já estão no buffer. Os recetores recebem esses primeiro, e a partir daí cada receção retorna de imediato com o valor zero:
example.gogofunc main() { events := make(chan string, 2) events <- "user.created" events <- "user.deleted" close(events) for i := 0; i < 3; i++ { ev, ok := <-events fmt.Printf("%q %v\n", ev, ok) } }
example.texttext"user.created" true "user.deleted" true "" false
O segundo valor, ok, é true para um valor real e false quando o channel está fechado e esvaziado. Um simples ev := <-events não consegue distinguir uma string vazia que foi enviada do valor zero de um channel fechado, por isso usa a forma com dois valores sempre que o channel possa estar fechado.
O for v := range ch faz a mesma verificação por ti. Recebe até o channel estar fechado e vazio, e depois sai do ciclo. É assim que o deliver acima sabe quando parar, e é por isso que o produce tem de fechar jobs quando fica sem IDs. Sem o fecho, o deliver ficaria à espera de um quarto job para sempre.
Quem deve fechar um channel em Go?
A goroutine que envia. Quem recebe não pode saber se vem aí outro valor, e fechar do lado da receção leva diretamente aos panics abaixo. Quando várias goroutines enviam para o mesmo channel, nenhuma delas o pode fechar sozinha. Uma goroutine à parte espera por todas com um sync.WaitGroup e fecha o channel no fim. A worker pool mais abaixo mostra essa estrutura.
Há três erros que provocam um panic em tempo de execução:
example.texttextpanic: send on closed channel panic: close of closed channel panic: close of nil channel
Não tens de fechar todos os channels. Um channel que ninguém referencia é recolhido pelo garbage collector, esteja fechado ou não. Fecha um channel quando os recetores precisam de saber que o fluxo terminou, como num ciclo range ou num sinal de encerramento.
O que acontece quando envias para um channel nil ou fechado?
A tabela reúne todas as combinações:
| Operação | Channel nil | Channel aberto | Channel fechado |
|---|---|---|---|
Envio ch <- v | Bloqueia para sempre | Bloqueia até um recetor ficar com o valor ou haver espaço livre no buffer | Panic |
Receção <-ch | Bloqueia para sempre | Devolve um valor do buffer, ou bloqueia até chegar um | Devolve os valores do buffer e depois o valor zero com ok == false |
close(ch) | Panic | Funciona | Panic |
Um case de select sobre um channel nil nunca é escolhido, e o exemplo de merge mais abaixo depende disso. Um channel fechado retorna de imediato para todos os recetores, o que faz do close um broadcast. Todas as goroutines que estão a receber do channel acordam ao mesmo tempo.
Porque é que o Go diz "all goroutines are asleep - deadlock!"?
Porque o runtime descobriu que todas as goroutines do programa estão bloqueadas e nenhuma delas pode voltar a acordar. A versão mais pequena é um envio num channel sem buffer sem nenhum recetor:
example.gogofunc main() { uploads := make(chan string) uploads <- "invoice-1001.pdf" fmt.Println("queued") }
example.texttextfatal error: all goroutines are asleep - deadlock! goroutine 1 [chan send]: main.main() /app/main.go:7 +0x34
O main é a única goroutine e está à espera de um envio que nada vai receber. O trace indica a operação bloqueada (chan send) e a linha. As correções são receber noutra goroutine, ou dar um buffer ao channel se só precisas de estacionar um único valor. O artigo sobre erros comuns em Go apresenta isto como o décimo erro.
O detetor só dispara quando todas as goroutines estão presas. Um servidor web tem sempre uma goroutine à espera da próxima ligação no network poller, e o detetor não a conta como presa, por isso um handler bloqueado num channel nunca o faz disparar. O handler simplesmente para, e a sua goroutine fica em memória até o processo terminar. A isto chama-se uma fuga de goroutines.
Como encontras fugas de goroutines em Go?
Uma fuga comum tem este aspeto. Uma página de checkout pede um orçamento a três transportadoras e fica com a resposta mais rápida:
example.gogofunc fastestQuote(ctx context.Context) (Quote, error) { ch := make(chan Quote) go func() { ch <- fetchQuote("ups", 50*time.Millisecond) }() go func() { ch <- fetchQuote("dhl", 80*time.Millisecond) }() go func() { ch <- fetchQuote("fedex", 300*time.Millisecond) }() select { case q := <-ch: return q, nil case <-ctx.Done(): return Quote{}, ctx.Err() } }
A função recebe uma vez e retorna. As duas goroutines mais lentas tentam depois enviar para um channel sem buffer que ninguém vai voltar a ler. Ficam bloqueadas enquanto o processo estiver a correr. Ao fim de 100 checkouts, há 200 goroutines presas.
O Go 1.27 tornou o perfil goroutineleak disponível para todos, depois de uma versão como experiência no Go 1.26 (notas de lançamento do Go 1.27). O garbage collector procura goroutines bloqueadas num channel ou num lock que já não é alcançável a partir de nenhuma goroutine executável, e reporta-as por stack:
example.gogopprof.Lookup("goroutineleak").WriteTo(os.Stdout, 1)
example.texttextgoroutineleak profile: total 200 100 @ 0x1044f99a8 0x104492854 0x104492468 0x104550560 0x1044ffc74 # 0x10455055f main.fastestQuote.func3+0x4f /app/main.go:27 100 @ 0x1044f99a8 0x104492854 0x104492468 0x1045505d0 0x1044ffc74 # 0x1045505cf main.fastestQuote.func2+0x4f /app/main.go:26
Aponta para as goroutines da DHL e da FedEx pela linha. Um servidor que importe net/http/pprof expõe os mesmos dados em /debug/pprof/goroutineleak. As novidades do Go 1.27 explicam o perfil com mais detalhe.
A correção é um buffer com espaço para todos os emissores:
example.gogoch := make(chan Quote, 3)
Agora todas as goroutines conseguem enviar e terminar, quer alguém leia o seu valor, quer não. Com esta alteração, o mesmo programa reporta goroutineleak profile: total 0. De forma mais geral, cada goroutine que inicias precisa de alguma forma de terminar. Normalmente é um recetor que lê sempre, um buffer suficientemente grande para todos os envios ou um select sobre ctx.Done().
Como funciona o select com channels em Go?
O select espera por várias operações em channels e executa a primeira que puder avançar. Se várias estiverem prontas ao mesmo tempo, escolhe uma ao acaso, para que nenhum case impeça os outros de avançar indefinidamente (especificação do Go).
Como acrescentas um timeout à receção de um channel?
Põe a receção e um channel de timeout no mesmo select. Com um context.Context, o channel de timeout é o ctx.Done():
example.gogofunc lookupPrice(ctx context.Context, sku string) (int, error) { result := make(chan int, 1) go func() { result <- slowPriceService(sku) }() select { case cents := <-result: return cents, nil case <-ctx.Done(): return 0, fmt.Errorf("price for %s: %w", sku, ctx.Err()) } } func main() { ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() fmt.Println(lookupPrice(ctx, "SKU-1001")) fmt.Println(lookupPrice(context.Background(), "SKU-1001")) }
example.texttext0 price for SKU-1001: context deadline exceeded 4999 <nil>
O slowPriceService demora 300ms, por isso a primeira chamada desiste ao fim de 100ms e a segunda espera pela resposta. O buffer de 1 em result é a correção da fuga da secção anterior. Depois de um timeout, a goroutine continua a poder enviar a resposta atrasada e terminar.
case <-time.After(2 * time.Second): também funciona quando não há context. Desde o Go 1.23, um timer que ninguém referencia pode ser recolhido pelo garbage collector antes de disparar, por isso o time.After dentro de um ciclo já não acumula timers (notas de lançamento do Go 1.23). O Go 1.27 removeu a definição GODEBUG asynctimerchan, que repunha o comportamento antigo. Em código que trata pedidos, prefere o context, porque também transporta o cancelamento vindo do cliente.
Como fazes um envio ou uma receção não bloqueante?
Acrescenta um case default. O select executa o default quando nenhum outro case está pronto, por isso a operação nunca espera. Um exportador de métricas que nunca pode atrasar o caminho dos pedidos usa isto para descartar amostras quando a fila está cheia:
example.gogotype Exporter struct { queue chan Metric dropped atomic.Int64 } func (e *Exporter) Record(m Metric) { select { case e.queue <- m: default: e.dropped.Add(1) } } func main() { e := &Exporter{queue: make(chan Metric, 3)} for i := range 5 { e.Record(Metric{Name: "http_requests_total", Value: float64(i)}) } fmt.Println("queued:", len(e.queue), "dropped:", e.dropped.Load()) }
example.texttextqueued: 3 dropped: 2
O Record corre em muitos handlers de pedidos ao mesmo tempo, por isso o contador de descartes é um atomic.Int64 e não um simples int. Mantém uma contagem dos descartes, porque um default silencioso esconde o momento em que o consumidor começa a ficar para trás.
Porque é que se põe um channel a nil num select?
Para desligar um case. Uma receção num channel nil bloqueia para sempre, por isso um case de select sobre um channel nil nunca pode ser escolhido. Quando juntas dois fluxos, põe cada um a nil assim que for fechado, e para quando ambos o estiverem:
example.gogofunc merge(a, b <-chan string) []string { var out []string for a != nil || b != nil { select { case v, ok := <-a: if !ok { a = nil continue } out = append(out, v) case v, ok := <-b: if !ok { b = nil continue } out = append(out, v) } } return out }
Sem a atribuição a nil, um channel fechado está sempre pronto, e o ciclo ficaria a girar sobre valores zero com o CPU no máximo.
Quais são os padrões de channels mais comuns em Go?
Quatro padrões cobrem a maior parte do código com channels em serviços de produção.
Worker pool
Um número fixo de goroutines lê de um único channel de jobs. Esta entrega webhooks com três workers:
example.gogofunc main() { hooks := make(chan Webhook) deliveries := make(chan Delivery) var wg sync.WaitGroup for worker := range 3 { wg.Go(func() { for h := range hooks { deliveries <- Delivery{WebhookID: h.ID, Worker: worker, Status: send(h)} } }) } go func() { for id := 1; id <= 9; id++ { hooks <- Webhook{ID: id, URL: fmt.Sprintf("https://partner.example.com/hooks/%d", id)} } close(hooks) }() go func() { wg.Wait() close(deliveries) }() ok := 0 for d := range deliveries { if d.Status == 200 { ok++ } } fmt.Println("delivered:", ok) }
example.texttextdelivered: 9
Há quatro detalhes que a tornam correta. O produtor fecha hooks, por isso o ciclo range de cada worker termina. O wg.Go, acrescentado no Go 1.25, inicia cada worker e acompanha-o numa só chamada. Uma goroutine à parte espera por todos os workers antes de fechar deliveries, porque nenhum worker sabe sozinho quando os outros terminaram. As closures capturam worker em segurança desde que o Go 1.22 deu a cada iteração do ciclo a sua própria variável (vê como funcionam as closures).
Fan-out e fan-in
A worker pool é fan-out: um channel alimenta muitas goroutines. O channel deliveries é fan-in: muitas goroutines escrevem num channel que um único ciclo lê. A função merge acima é fan-in para um número fixo de channels de entrada. O artigo sobre pipelines do blog do Go constrói cadeias mais longas a partir destas mesmas duas peças.
Semáforo
Um channel com buffer de structs vazias limita quantas goroutines fazem algo ao mesmo tempo. Por exemplo, um serviço de imagens que não pode redimensionar mais de três imagens em paralelo:
example.gogosem := make(chan struct{}, 3) var wg sync.WaitGroup for _, img := range images { wg.Go(func() { sem <- struct{}{} defer func() { <-sem }() resizeImage(img) }) } wg.Wait()
A quarta goroutine bloqueia em sem <- struct{}{} até uma das três primeiras libertar a sua posição. O modelo de memória garante isto para um channel de capacidade C: a k-ésima receção acontece antes de o (k+C)-ésimo envio terminar (modelo de memória do Go). Quando as goroutines também devolvem erros, o errgroup.Group com SetLimit de golang.org/x/sync faz o mesmo trabalho e recolhe o primeiro erro.
Sinal de conclusão com chan struct{}
Um channel que só é fechado, e nunca recebe envios, transmite um sinal a todos os recetores. O tipo dos elementos é struct{} porque ocupa zero bytes e deixa claro que não viajam dados (vê a palavra-chave struct em Go):
example.gogofunc flushLoop(done <-chan struct{}, flushed chan<- int) { ticker := time.NewTicker(50 * time.Millisecond) defer ticker.Stop() n := 0 for { select { case <-ticker.C: n++ case <-done: flushed <- n return } } } func main() { done := make(chan struct{}) flushed := make(chan int) go flushLoop(done, flushed) time.Sleep(175 * time.Millisecond) close(done) fmt.Println("flushes before shutdown:", <-flushed) }
example.texttextflushes before shutdown: 3
O context.Context assenta na mesma ideia. O ctx.Done() devolve um <-chan struct{} que é fechado no cancelamento, por isso funciona em qualquer select da mesma forma que o done acima.
Deves usar um channel ou um mutex em Go?
Usa um channel quando estás a passar a posse de dados ou a coordenar goroutines. Usa um sync.Mutex quando várias goroutines atualizam o mesmo estado no sítio. O provérbio do Go «Don't communicate by sharing memory, share memory by communicating», ou seja, não comuniques partilhando memória, partilha memória comunicando (Go Proverbs), descreve o primeiro caso, e a página da wiki do Go Use a sync.Mutex or a channel? diz claramente que o provérbio não exclui os mutexes.
Um contador de pedidos por rota é estado, não um fluxo de valores:
example.gogotype Stats struct { mu sync.Mutex counts map[string]int } func (s *Stats) Inc(route string) { s.mu.Lock() defer s.mu.Unlock() s.counts[route]++ }
A versão com channels precisa de uma goroutine que seja dona do map, de um channel de pedidos e de um channel de resposta para as leituras. É mais código, e cada incremento tem de esperar que outra goroutine o trate. Numa fila de jobs é ao contrário. Um slice protegido por um mutex precisa de um sync.Cond ou de polling para pôr os workers à espera de trabalho, enquanto um channel os bloqueia e acorda sem custo adicional.
| Usa um channel para | Usa um mutex para |
|---|---|
| Passar um job ou um resultado a outra goroutine | Contadores, caches e maps atualizados no sítio |
| Sinalizar encerramento, cancelamento ou conclusão | Proteger alguns campos dentro de uma struct |
| Limitar a concorrência ou construir pipelines | Secções críticas curtas em caminhos muito usados |
Testa os dois com go test -race. Deteta um map partilhado sem lock, e também deteta um emissor que continua a modificar uma struct depois de enviar um ponteiro para ela enquanto o recetor a lê.
Onde entra o LevelUpGo
O LevelUpGo ensina Go através de exercícios que executam código Go real no navegador. O Go Concurrency Fundamentals aborda goroutines, channels com e sem buffer, direções de channels, select, context e o race detector, com exercícios que entram em deadlock até os corrigires. O Go Design Patterns constrói como exercícios completos os padrões deste artigo: worker pool, fan-out e fan-in e encerramento controlado. O Training Ground tem exercícios curtos e independentes de concorrência, com código que entra em deadlock, tem fugas ou perde trabalho. Para as outras 24 palavras reservadas, vê Palavras-chave do Go: as 25 explicadas.
Perguntas frequentes
O chan é uma palavra-chave em Go?
Sim. O chan é uma das 25 palavras-chave reservadas do Go, por isso não o podes usar como nome de variável ou de função. Aparece em tipos de channel como chan Job, chan<- Job e <-chan Job. O operador <- não é uma palavra-chave, e o make também não, porque é uma função pré-declarada.
É obrigatório fechar um channel em Go?
Não. Fecha um channel só quando os recetores precisam de saber que não vêm mais valores, por exemplo para terminar um ciclo for range ou para transmitir um encerramento. Um channel por fechar que nada referencia é recolhido pelo garbage collector como qualquer outro valor.
Qual é a diferença entre um channel com buffer e um sem buffer?
Um channel sem buffer, make(chan T), faz o emissor esperar até um recetor ficar com o valor, por isso as duas goroutines encontram-se. Um channel com buffer, make(chan T, n), deixa até n valores à espera no channel, e o emissor só bloqueia quando o buffer está cheio.
O que acontece quando lês de um channel fechado em Go?
Primeiro recebes os valores que ainda estão no buffer. A partir daí, cada receção retorna de imediato com o valor zero do tipo dos elementos. A forma com dois valores v, ok := <-ch põe ok a false para esses valores zero, por isso consegues distingui-los dos valores reais.
Como verificas se um channel está fechado em Go?
Recebe dele com v, ok := <-ch e verifica o ok. Não existe nenhuma função que diga se um channel está fechado sem receber, e o len(ch) só conta os valores no buffer. O código que precisa dessa verificação costuma ter mais do que uma goroutine a fechar o channel, e é esse design que tens de corrigir.
Podes usar range sobre um channel em Go?
Sim. for v := range ch recebe valores até o channel estar fechado e vazio, e depois sai do ciclo. Se ninguém fechar o channel, o ciclo espera para sempre, por isso o emissor tem de chamar close quando termina.
Fontes
- The Go Programming Language Specification, Channel types: https://go.dev/ref/spec#Channel_types
- The Go Programming Language Specification, Send statements: https://go.dev/ref/spec#Send_statements
- The Go Programming Language Specification, Receive operator: https://go.dev/ref/spec#Receive_operator
- The Go Programming Language Specification, Close: https://go.dev/ref/spec#Close
- The Go Programming Language Specification, Select statements: https://go.dev/ref/spec#Select_statements
- The Go Memory Model, Channel communication: https://go.dev/ref/mem#chan
- Effective Go, Concurrency: https://go.dev/doc/effective_go#concurrency
- The Go Blog, Go Concurrency Patterns: Pipelines and cancellation: https://go.dev/blog/pipelines
- Go Wiki, Use a sync.Mutex or a channel?: https://go.dev/wiki/MutexOrChannel
- Go 1.23 Release Notes, Timer changes: https://go.dev/doc/go1.23#timer-changes
- Go 1.27 Release Notes: https://go.dev/doc/go1.27
- Go Proverbs: https://go-proverbs.github.io/
