Un contexto de Go le avisa a cada goroutine de un trabajo cuándo parar. Aprende cancelación, timeouts y valores por petición, y después arma un worker pool, un pipeline, fan-out, un errgroup y un semáforo que nunca dejan fugas.
Arrancar una goroutine es fácil. Lo difícil es detenerla en el momento justo. Un usuario cierra la pestaña del navegador, una consulta a la base de datos tarda demasiado, o falla una de cinco peticiones en paralelo. En cada caso algunas goroutines deberían rendirse, y algo tiene que avisarles.
En Go, ese algo es context.Context. Este post cubre primero la cancelación, los timeouts y los valores por petición, y después cinco patrones de concurrencia construidos sobre ellos. Da por hecho que ya viste goroutines, canales, select y sync.WaitGroup en las tres partes anteriores. Cada programa de abajo se ejecutó en Go 1.26, y su salida está copiada de esa ejecución.
Un contexto es una señal que dice «para»
Un contexto es un valor que le pasas a una función para que pueda saber cuándo dejar de trabajar. El más pequeño que sirve de algo sale de context.WithCancel:
package main
import (
"context"
"fmt"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
fmt.Println("before:", ctx.Err())
cancel()
<-ctx.Done() // closed now, so this returns at once
fmt.Println("after:", ctx.Err())
cancel() // calling it again does nothing
fmt.Println("again:", ctx.Err())
}
Imprime:
before: <nil>
after: context canceled
again: context canceled
context.Background() es el contexto raíz vacío. Nunca termina y no lleva nada. WithCancel lo envuelve y te devuelve dos cosas: un contexto nuevo, y una función cancel que lo termina.
Dos métodos te dicen si un contexto terminó. ctx.Done() devuelve un canal que se cierra cuando el contexto termina. ctx.Err() devuelve nil mientras sigue vivo, y el motivo cuando ya no lo está. Llamar a cancel dos veces es seguro. La segunda llamada no hace nada.
Explicado como si tuvieras diez años
Imagina a una jefa que arranca un trabajo grande. Le da un walkie-talkie a cada trabajador del proyecto. Algunos trabajadores contratan ayudantes, y les dan walkie-talkies en el mismo canal.
Cuando la jefa dice «paren» por su walkie-talkie, todos los que tienen uno de esa cadena la oyen. Los trabajadores dejan sus herramientas.
Un timeout es un despertador pegado con cinta a un walkie-talkie. Cuando suena, dice «paren» por ti, aunque nadie haya apretado el botón.
La versión precisa
Los contextos forman un árbol. Cada función With… recibe un padre y devuelve un hijo. Cuando un contexto se cancela, Go cierra su canal Done y después cancela a todos sus hijos, y a los hijos de estos, hasta abajo del todo.
La cancelación solo viaja hacia abajo. Cancelar un hijo nunca toca a su padre, y nunca toca a sus hermanos. La función cancel que recibes también suelta el enlace del hijo con su padre, y por eso siempre la llamas, casi siempre con defer cancel().
Dónde falla la analogía: un trabajador con walkie-talkie no puede evitar oírlo. Una goroutine sí puede. Cerrar Done no detiene nada por sí solo. Tu código tiene que revisar ctx.Done() o ctx.Err() y retornar. Una goroutine que nunca lo mira simplemente sigue corriendo.
Timeouts y deadlines
Un timeout es la razón más común por la que termina un contexto, y context.WithTimeout lo configura en una sola llamada. La función de abajo respeta la cancelación. Espera a su trabajo o a ctx.Done(), lo que llegue primero:
package main
import (
"context"
"errors"
"fmt"
"time"
)
// work pretends to do a job that takes d, but gives up if ctx ends first.
func work(ctx context.Context, d time.Duration) error {
select {
case <-time.After(d):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
err := work(ctx, 2*time.Second)
fmt.Println("slow job:", err)
fmt.Println(errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled))
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel2()
fmt.Println("quick job:", work(ctx2, time.Millisecond))
past := time.Now().Add(-time.Minute)
ctx3, cancel3 := context.WithDeadline(context.Background(), past)
defer cancel3()
fmt.Println("deadline already gone:", work(ctx3, time.Millisecond))
}
Imprime:
slow job: context deadline exceeded
true false
quick job: <nil>
deadline already gone: context deadline exceeded
El primer trabajo necesita 2 segundos y recibe 20 milisegundos, así que gana el contexto. ctx.Err() devuelve context.DeadlineExceeded. Los márgenes son amplios a propósito. Una diferencia de 100 veces significa que el resultado nunca depende de qué tan ocupada esté la computadora.
Compara errores con errors.Is, como mostró la parte sobre errores. context.Canceled significa que alguien llamó a cancel. context.DeadlineExceeded significa que se acabó el tiempo. El código que reintenta suele tratarlos distinto: un timeout puede merecer otro intento, pero una cancelación significa que quien llamó ya se fue.
WithDeadline recibe un momento en el tiempo en lugar de una duración. WithTimeout(ctx, d) es exactamente WithDeadline(ctx, time.Now().Add(d)). Un deadline que ya pasó te da un contexto que terminó antes de que tu función empiece.
time.After dentro de un select era antes una pequeña trampa de memoria, porque el timer seguía vivo hasta que se disparaba. Desde Go 1.23, un timer al que nada hace referencia puede ser recolectado por el recolector de basura, así que este patrón ahora está bien.
La cancelación baja por el árbol
El árbol importa más cuando varias goroutines comparten un trabajo. En este programa, root tiene dos hijos. A tiene un timeout de 20ms y dos workers debajo, A1 y A2. B es otro hijo con su propio worker. Fíjate hasta dónde llega el timeout de A, y hasta dónde no:
Un árbol de contextos con un timeout en una rama. Cuando vence el timer de 20ms de A, A termina y la señal baja a sus workers A1 y A2, que se detienen con DeadlineExceeded. B, que es un hermano, y root, que es el padre, siguen corriendo. Solo cuando main cancela root la señal llega a B.
Estos son los mismos pasos en palabras, por si la animación no se reproduce:
root,A,A1,A2yBestán todos vivos. Cada worker está en un bucle conselect, haciendo pequeñas unidades de trabajo.- Vence el timer de 20ms de
A.Atermina, yA.Err()devuelvecontext deadline exceeded. - La señal baja.
A1yA2ven cerrarse sus canalesDone, y los dos se detienen con el mismo error. Byrootno cambian. La cancelación bajó desdeA, no subió arootni fue de lado aB.mainllama acancelRoot(). Ahora la señal baja desderoothastaB, que se detiene concontext canceled.
Este es el programa que sigue la animación:
package main
import (
"context"
"fmt"
"time"
)
// worker does small units of work until its context ends.
func worker(ctx context.Context, name string, report chan<- string) {
tick := time.NewTicker(time.Millisecond)
defer tick.Stop()
for {
select {
case <-ctx.Done():
report <- name + " stopped: " + ctx.Err().Error()
return
case <-tick.C:
// one small unit of work
}
}
}
func main() {
root, cancelRoot := context.WithCancel(context.Background())
defer cancelRoot()
a, cancelA := context.WithTimeout(root, 20*time.Millisecond)
defer cancelA()
a1, cancelA1 := context.WithCancel(a)
defer cancelA1()
a2, cancelA2 := context.WithCancel(a)
defer cancelA2()
b, cancelB := context.WithCancel(root)
defer cancelB()
repA1 := make(chan string)
repA2 := make(chan string)
repB := make(chan string)
go worker(a1, "A1", repA1)
go worker(a2, "A2", repA2)
go worker(b, "B", repB)
fmt.Println(<-repA1)
fmt.Println(<-repA2)
fmt.Println("A:", a.Err())
fmt.Println("B:", b.Err())
fmt.Println("root:", root.Err())
cancelRoot()
fmt.Println(<-repB)
}
Imprime:
A1 stopped: context deadline exceeded
A2 stopped: context deadline exceeded
A: context deadline exceeded
B: <nil>
root: <nil>
B stopped: context canceled
Mira el orden de los eventos en main. Espera a que los dos workers de A avisen, y después revisa B y root. Los dos siguen en nil, aunque toda una rama del árbol terminó a su lado. Solo cancelRoot() llega a B.
Cada worker es la forma que conviene copiar. Es un bucle for alrededor de un select, con un case para el trabajo y otro para ctx.Done(). Mientras cada paso que bloquea esté dentro de un select así, la goroutine siempre puede oír «para».
Dónde va ctx: primer parámetro, nunca un campo de struct
La biblioteca estándar y casi todo el código Go coinciden en una convención. Una función que se puede cancelar recibe un context.Context como su primer parámetro, llamado ctx:
func (s *Store) LoadUser(ctx context.Context, id int) (User, error)
No guardes un contexto en un struct. Un contexto pertenece a una llamada, como una petición HTTP, pero un struct suele vivir más que muchas llamadas. Un contexto guardado termina siendo el equivocado: cancelado demasiado pronto para la siguiente petición, o nunca cancelado. Pasarlo de forma explícita también deja claro qué funciones se pueden detener.
Dos reglas más. Nunca pases un contexto nil. Si todavía no tienes uno, usa context.TODO(), que se comporta como Background() pero marca el lugar para después. Y llama siempre a la función cancel. go vet revisa esa segunda regla por ti. Con ctx, _ := context.WithTimeout(context.Background(), time.Second), reporta:
$ go vet .
main.go:10:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak
Por qué se detuvo: Cause y AfterFunc
ctx.Err() solo dice “canceled” o “deadline exceeded”, y muchas veces eso es muy poco para depurar. Go 1.20 agregó WithCancelCause, y Go 1.21 agregó WithTimeoutCause y AfterFunc:
package main
import (
"context"
"errors"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancelCause(context.Background())
cleaned := make(chan struct{})
context.AfterFunc(ctx, func() {
close(cleaned) // runs in its own goroutine once ctx ends
})
cancel(errors.New("server shutting down"))
<-cleaned
fmt.Println("cleanup ran")
fmt.Println("Err: ", ctx.Err())
fmt.Println("Cause:", context.Cause(ctx))
slow := errors.New("payment provider took too long")
ctx2, cancel2 := context.WithTimeoutCause(context.Background(), 20*time.Millisecond, slow)
defer cancel2()
<-ctx2.Done()
fmt.Println("Err: ", ctx2.Err())
fmt.Println("Cause:", context.Cause(ctx2))
}
Imprime:
cleanup ran
Err: context canceled
Cause: server shutting down
Err: context deadline exceeded
Cause: payment provider took too long
El cancel de WithCancelCause recibe un error. ctx.Err() sigue devolviendo context.Canceled, así que las revisiones que ya existen siguen funcionando, y context.Cause(ctx) devuelve tu error. WithTimeoutCause hace lo mismo para un timeout. En una línea de log, “payment provider took too long” es mucho más útil que “context deadline exceeded”.
context.AfterFunc registra una función que corre cuando el contexto termina. Corre en su propia goroutine, y por eso main espera en el canal cleaned antes de imprimir. Imprimir desde dentro del callback competiría con la salida del propio main. AfterFunc devuelve una función stop que quita el callback si todavía no corrió.
Valores por petición
Un contexto también puede llevar valores, y context.WithValue es la forma en que datos como un ID de petición viajan por una cadena de llamadas sin agregarlos a la firma de cada función:
package main
import (
"context"
"fmt"
)
// An unexported key type: no other package can make a key equal to this one.
type requestIDKey struct{}
func withRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey{}, id)
}
func requestID(ctx context.Context) string {
id, ok := ctx.Value(requestIDKey{}).(string)
if !ok {
return "no-request-id"
}
return id
}
func loadUser(ctx context.Context, userID int) {
fmt.Printf("[%s] loading user %d\n", requestID(ctx), userID)
}
func main() {
ctx := withRequestID(context.Background(), "req-7f3a")
loadUser(ctx, 42)
loadUser(context.Background(), 43)
}
Imprime:
[req-7f3a] loading user 42
[no-request-id] loading user 43
La clave es un tipo struct vacío y privado. Dos paquetes que usen el string "id" como clave se pisarían entre sí. Una clave de un tipo no exportado no puede chocar con la clave de nadie más. ctx.Value devuelve any, así que revisas el tipo con comma-ok y manejas el caso en que falta.
Usa los valores solo para datos de la petición. Es decir, cosas que describen la petición y cruzan los límites de una API: un ID de petición, un ID de traza, el usuario autenticado. No los uses para una conexión a la base de datos, un logger o un flag de configuración. Esas son dependencias reales, y van en parámetros o campos de struct donde el compilador las pueda ver. Value no tiene tipo, y se encuentra subiendo por el árbol de padre en padre, así que las dependencias escondidas ahí fallan en tiempo de ejecución y no al compilar.
Worker pool
Un worker pool corre un número fijo de goroutines que toman trabajos de un mismo canal. Pone un tope a cuánto trabajo pasa a la vez, lleguen los trabajos que lleguen:
package main
import (
"cmp"
"fmt"
"slices"
"sync"
)
type result struct {
job, square int
}
func worker(jobs <-chan int, results chan<- result) {
for j := range jobs {
results <- result{job: j, square: j * j}
}
}
func main() {
jobs := make(chan int)
results := make(chan result)
var wg sync.WaitGroup
for range 3 {
wg.Go(func() { worker(jobs, results) })
}
go func() {
for j := range 9 {
jobs <- j + 1
}
close(jobs) // workers' range loops end
}()
go func() {
wg.Wait()
close(results) // main's range loop ends
}()
var all []result
for r := range results {
all = append(all, r)
}
slices.SortFunc(all, func(x, y result) int { return cmp.Compare(x.job, y.job) })
fmt.Println(len(all), "results")
fmt.Println(all)
}
Imprime:
9 results
[{1 1} {2 4} {3 9} {4 16} {5 25} {6 36} {7 49} {8 64} {9 81}]
Tres workers comparten el canal jobs. Cada trabajo va a exactamente uno de ellos. Dos cierres hacen que todo termine. Cerrar jobs termina el bucle range de cada worker. Una goroutine aparte espera a todos los workers y después cierra results, lo que termina el bucle de main.
Los workers terminan los trabajos en el orden que elija el scheduler, así que los resultados llegan en un orden distinto en cada ejecución. Cada resultado lleva su número de trabajo, y ordenar por ese número hace que la salida sea igual todas las veces. Imprimir los resultados a medida que llegan no lo sería.
Pipeline
Un pipeline es una cadena de etapas unidas por canales, donde cada etapa es una goroutine que lee de un canal y escribe en el siguiente. Este tiene tres: un generador, una etapa que eleva al cuadrado y una que suma. El generador nunca se detiene por sí solo, así que el contexto es la única forma de apagarlo:
package main
import (
"context"
"fmt"
)
// naturals sends 1, 2, 3, ... until ctx ends. It never stops on its own.
func naturals(ctx context.Context) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := 1; ; n++ {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out
}
// square sends the square of every value it receives, until in closes or ctx ends.
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}
// sumFirst adds up the first k values from in.
func sumFirst(in <-chan int, k int) int {
total := 0
for range k {
total += <-in
}
return total
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
squares := square(ctx, naturals(ctx))
fmt.Println("sum of first 5 squares:", sumFirst(squares, 5))
cancel()
for range squares {
// drain until square closes its channel
}
fmt.Println("both stages stopped")
}
Imprime:
sum of first 5 squares: 55
both stages stopped
sumFirst toma cinco valores y retorna, y deja dos goroutines sin nadie que lea su salida. Sin ctx, las dos se bloquearían en out <- … para siempre. Con él, cancel() le da al select de cada etapa una segunda salida. Retornan, y corre su defer close(out).
El bucle vacío for range squares lo demuestra. Solo termina cuando square cierra su canal, y square solo lo cierra después de detenerse. Si la cancelación no funcionara, el programa se quedaría colgado ahí en lugar de imprimir su última línea.
Fan-out y fan-in
Fan-out significa que varias goroutines leen del mismo canal para repartirse el trabajo. Fan-in significa juntar sus canales de salida separados de nuevo en uno solo:
package main
import (
"context"
"fmt"
"slices"
"sync"
)
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}
// merge copies every value from every input onto one channel.
func merge(ctx context.Context, inputs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, in := range inputs {
wg.Go(func() {
for v := range in {
select {
case out <- v:
case <-ctx.Done():
return
}
}
})
}
go func() {
wg.Wait()
close(out)
}()
return out
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
src := make(chan int)
go func() {
defer close(src)
for n := range 10 {
src <- n + 1
}
}()
// fan out: three workers read the same channel
w1 := square(ctx, src)
w2 := square(ctx, src)
w3 := square(ctx, src)
// fan in: merge their outputs
var got []int
for v := range merge(ctx, w1, w2, w3) {
got = append(got, v)
}
slices.Sort(got)
fmt.Println(got)
}
Imprime:
[1 4 9 16 25 36 49 64 81 100]
Tres etapas square leen de src, así que los diez números se reparten entre ellas. merge arranca una goroutine por cada entrada, copia los valores en out, y cierra out cuando todas las entradas se vaciaron. Es la misma forma de WaitGroup y luego close que en el worker pool.
Los valores juntados llegan mezclados y sin un orden fijo, así que main los ordena antes de imprimir. Cada etapa también recibe ctx. Aquí el programa corre hasta el final y nada se cancela, pero si main dejara de leer antes, el cancel diferido liberaría a todas las goroutines.
Un errgroup construido con WaitGroup
Algo que se necesita seguido es correr varias tareas a la vez, detenerlas todas cuando una falla, y devolver ese primer error. El paquete errgroup hace esto, pero vive en golang.org/x/sync, no en la biblioteca estándar. La idea es lo bastante pequeña para construirla con sync.WaitGroup, sync.Once y un contexto:
package main
import (
"context"
"fmt"
"sync"
"time"
)
// Group runs tasks in goroutines, keeps the first error,
// and cancels the shared context when that error happens.
type Group struct {
wg sync.WaitGroup
once sync.Once
err error
cancel context.CancelCauseFunc
}
func WithContext(parent context.Context) (*Group, context.Context) {
ctx, cancel := context.WithCancelCause(parent)
return &Group{cancel: cancel}, ctx
}
func (g *Group) Go(task func() error) {
g.wg.Go(func() {
if err := task(); err != nil {
g.once.Do(func() {
g.err = err
g.cancel(err)
})
}
})
}
func (g *Group) Wait() error {
g.wg.Wait()
g.cancel(g.err) // release the context even if nothing failed
return g.err
}
func main() {
g, ctx := WithContext(context.Background())
status := make([]string, 3)
for i := range 3 {
g.Go(func() error {
if i == 1 {
status[i] = "failed"
return fmt.Errorf("fetch %d: connection refused", i)
}
select {
case <-time.After(2 * time.Second):
status[i] = "finished"
return nil
case <-ctx.Done():
status[i] = "stopped early"
return ctx.Err()
}
})
}
err := g.Wait()
fmt.Printf("%q\n", status)
fmt.Println("first error:", err)
fmt.Println("cause:", context.Cause(ctx))
}
Imprime:
["stopped early" "failed" "stopped early"]
first error: fetch 1: connection refused
cause: fetch 1: connection refused
La tarea 1 falla de inmediato. once.Do guarda su error y cancela el contexto compartido con ese error como causa. Las tareas 0 y 2 están esperando un trabajo de 2 segundos, ven cerrarse ctx.Done(), y se detienen antes de tiempo.
Esas dos también devuelven un error, context.Canceled. Nunca reemplaza al error real. sync.Once corre su función exactamente una vez, y cualquier otra llamada espera hasta que esa primera ejecución termine. Para cuando una tarea cancelada llega a once.Do, el primer error ya está guardado. Así que aquí “first error” significa el error que causó la cancelación, siempre.
Cada tarea escribe en su propio elemento de status, así que no hace falta un mutex. main lee el slice solo después de que Wait retorna.
Concurrencia limitada con un semáforo
A veces tienes muchas tareas pero solo puedes correr unas pocas a la vez, porque una API tiene un límite de peticiones o una base de datos tiene diez conexiones. Un canal con buffer sirve como semáforo simple, con un lugar por cada unidad de su capacidad:
package main
import (
"fmt"
"sync"
"time"
)
func main() {
const limit = 2
sem := make(chan struct{}, limit)
var (
mu sync.Mutex
running int
peak int
wg sync.WaitGroup
)
results := make([]int, 6)
for i := range 6 {
sem <- struct{}{} // take a slot; blocks while two are busy
wg.Go(func() {
defer func() { <-sem }() // give the slot back
mu.Lock()
running++
peak = max(peak, running)
mu.Unlock()
time.Sleep(5 * time.Millisecond) // pretend to call a slow service
results[i] = i * 10
mu.Lock()
running--
mu.Unlock()
})
}
wg.Wait()
fmt.Println(results)
fmt.Println("never more than", limit, "at once:", peak <= limit)
}
Imprime:
[0 10 20 30 40 50]
never more than 2 at once: true
Enviar a sem ocupa un lugar. Con una capacidad de 2, el tercer envío se bloquea hasta que una tarea en curso termina y recibe de sem para liberar su lugar. Ocupar el lugar antes de wg.Go significa que nunca hay más de dos goroutines en total, y no seis goroutines con cuatro de ellas esperando.
El programa imprime peak <= limit, no peak. Que dos tareas se hayan solapado de verdad depende del scheduler. Es probable, pero no está garantizado. El límite, en cambio, se cumple en cada ejecución. Para que la espera se pueda cancelar, ocupa el lugar dentro de un select con un case <-ctx.Done():.
Toda goroutine necesita una forma de detenerse
Una goroutine bloqueada para siempre en un canal es una fuga. Retiene su stack y todo a lo que apunta, y nada la va a liberar nunca. Aquí está la fuga, junto a la solución:
package main
import (
"context"
"fmt"
"time"
)
// leaky sends three values, with no way to give up.
func leaky(out chan<- int, exited chan<- struct{}) {
defer close(exited)
for i := range 3 {
out <- i
}
}
// fixed sends three values, but stops as soon as ctx ends.
func fixed(ctx context.Context, out chan<- int, exited chan<- struct{}) {
defer close(exited)
for i := range 3 {
select {
case out <- i:
case <-ctx.Done():
return
}
}
}
func report(name string, exited <-chan struct{}) {
select {
case <-exited:
fmt.Println(name, "goroutine exited")
case <-time.After(100 * time.Millisecond):
fmt.Println(name, "goroutine still stuck on its send")
}
}
func main() {
out := make(chan int)
exited := make(chan struct{})
go leaky(out, exited)
fmt.Println("leaky got", <-out) // take one value, then walk away
report("leaky", exited)
ctx, cancel := context.WithCancel(context.Background())
out2 := make(chan int)
exited2 := make(chan struct{})
go fixed(ctx, out2, exited2)
fmt.Println("fixed got", <-out2)
cancel() // walk away, but say so
report("fixed", exited2)
}
Imprime:
leaky got 0
leaky goroutine still stuck on its send
fixed got 0
fixed goroutine exited
Los dos productores quieren enviar tres valores, y main toma solo uno de cada uno. leaky no tiene forma de rendirse, así que se queda en out <- 1 por el resto del programa. report espera 100ms, mucho más de lo que necesita una goroutine para salir, y sigue ahí. fixed pone el envío dentro de un select con ctx.Done(), así que cancel() le permite retornar.
Antes de escribir go, responde una pregunta: ¿qué hace que esta goroutine retorne? Las buenas respuestas son «su canal de entrada se cierra», «su contexto se cancela» o «termina un trabajo acotado». «Alguien lee su salida» solo es una buena respuesta si ese alguien no puede parar antes.
Go 1.26 agrega un perfil experimental goroutineleak a runtime/pprof, que reporta goroutines bloqueadas en algo que nada más puede alcanzar. Solo está disponible cuando compilas con GOEXPERIMENT=goroutineleakprofile. Sin eso, pprof.Lookup("goroutineleak") devuelve nil, y llamar a WriteTo sobre él provoca un panic, así que revisa primero si es nil.
Qué recordar
- Un contexto lleva una señal de parar, y a veces un deadline, hacia abajo por un árbol. La cancelación va solo a los hijos, nunca sube a los padres ni pasa a los hermanos.
- Cancelar no detiene una goroutine por sí solo. Pon cada paso que bloquea en un
selectconcase <-ctx.Done():y devuelvectx.Err(). context.Canceledsignifica que alguien llamó acancel, ycontext.DeadlineExceededsignifica que se acabó el tiempo. Usacontext.Causepara decir por qué.- Pasa
ctxcomo primer parámetro, nunca lo guardes en un struct, y llama siempre acancel.go vetdetecta uno descartado. - Deja los valores de contexto para datos de la petición, con un tipo de clave no exportado.
- Los worker pools, los pipelines, el fan-in y los errgroups terminan todos igual: un
WaitGroupespera, y después unclosele avisa al lector que se acabó. Ordena los resultados que llegan sin un orden fijo. - Antes de cada sentencia
go, ten claro qué hace que esa goroutine retorne.
Cada goroutine que arrancas necesita una forma de detenerse, y un contexto suele ser esa forma.