Channels en Go: comunicació entre goroutines sense complicar-se

Channels en Go explicats: buffered, unbuffered, select, tancament de canals i patrons habituals. Comunicació concurrent clara.

Cover for Channels en Go: comunicació entre goroutines sense complicar-se

Don’t communicate by sharing memory; share memory by communicating.

Aquesta és la frase que més es repeteix quan parles de concurrència en Go. I els channels són exactament el mecanisme que ho fa possible. Són la forma nativa que té Go de passar dades entre goroutines de manera segura, sense locks explícits i sense compartir estat mutable.

Però hi ha un matís important que molts tutorials ometen: els channels no són la solució universal. De vegades un sync.Mutex és més clar, més simple i més eficient. Saber quan fer servir cada eina és el que marca la diferència entre codi concurrent que funciona i codi concurrent que s’entén.

Recorrerem els channels des de zero: què són, com funcionen, els patrons que de veritat es fan servir en producció, i les trampes que t’explotaran a la cara si no les coneixes.


Què és un channel

Un channel en Go és un conducte tipat que connecta goroutines. Pensa-hi com una canonada: una goroutine posa un valor per un extrem i una altra el treu per l’altre. El tipus del channel defineix què pot viatjar per aquesta canonada.

ch := make(chan string)

Això crea un channel que transporta strings. No hi pots posar un int; el compilador t’ho impedeix. Aquesta restricció de tipus és una de les coses que fan que els channels siguin predictibles: saps exactament què sortirà per l’altre costat.

Internament, un channel és una estructura amb un buffer (o sense), un lock intern i cues de goroutines esperant per enviar o rebre. Però no necessites pensar en això per fer-los servir. El que sí necessites entendre és la diferència entre buffered i unbuffered, perquè és aquí on la majoria de la gent es confon.


Unbuffered channels: comunicació síncrona

Quan crees un channel sense especificar capacitat, obtens un unbuffered channel:

ch := make(chan int)

El comportament és estrictament síncron. Quan una goroutine envia un valor a un unbuffered channel, es bloqueja fins que una altra goroutine estigui llesta per rebre’l. I al revés: una goroutine que intenta rebre es bloqueja fins que algú enviï.

func main() {
    ch := make(chan string)

    go func() {
        ch <- \"fet\" // es bloqueja fins que algú llegeixi
    }()

    msg := <-ch // es bloqueja fins que algú escrigui
    fmt.Println(msg)
}

Aquest bloqueig mutu és una sincronització implícita. No necessites un WaitGroup ni un mutex per coordinar les dues goroutines: el channel se n’encarrega. Quan msg := <-ch es completa, saps amb certesa que la goroutine anònima ja ha executat ch <- \"fet\".

Els unbuffered channels són perfectes quan vols un handoff directe: “et passo això, espero que ho agafis, i llavors continuo.” És com lliurar un paquet en mà: no el deixes a la porta, esperes que l’altra persona el rebi.


Buffered channels: comunicació asíncrona amb capacitat

Els buffered channels tenen una cua interna amb capacitat fixa:

ch := make(chan int, 5) // buffer de 5 elements

La diferència clau: un enviament només es bloqueja quan el buffer és ple, i una recepció només es bloqueja quan és buit. Mentre hi hagi espai, l’emissor pot continuar enviant sense esperar.

func main() {
    ch := make(chan string, 2)

    ch <- \"primer\"  // no es bloqueja, hi ha espai
    ch <- \"segon\"   // no es bloqueja, encara hi ha espai
    // ch <- \"tercer\" // això SÍ es bloquejaria: buffer ple

    fmt.Println(<-ch) // \"primer\"
    fmt.Println(<-ch) // \"segon\"
}

El buffer no fa que el channel sigui “més ràpid” en sentit general. El que fa és desacoblar temporalment l’emissor del receptor. L’emissor pot anar una mica per davant sense que s’aturi, sempre que no s’ompli el buffer.

Quan fer servir buffered vs unbuffered

No hi ha una regla universal, però sí una guia pragmàtica:

  • Unbuffered: quan vols sincronització explícita entre goroutines. L’enviament és un punt de trobada.
  • Buffered: quan vols absorbir pics de treball o desacoblar productor de consumidor. El buffer actua com amortidor.

Un error habitual és posar un buffer gran “per si de cas.” Si no saps quina capacitat necessites, comença amb unbuffered. Si veus que el rendiment no és suficient i el profiling t’ho confirma, llavors afegeix buffer. No al revés.


Enviar i rebre: la sintaxi de la fletxa

L’operació sobre channels fa servir l’operador <-. La direcció de la fletxa et diu què està passant:

ch <- valor   // enviar valor al channel
valor := <-ch // rebre valor del channel

És intuïtiu un cop ho veus: la fletxa apunta cap on va el dada. Si apunta al channel, estàs enviant. Si surt del channel, estàs rebent.

Channels direccionals

Pots restringir un channel perquè només permeti enviament o recepció. Això és especialment útil en les signatures de funcions:

func productor(out chan<- int) {
    // només pot enviar
    out <- 42
}

func consumidor(in <-chan int) {
    // només pot rebre
    valor := <-in
    fmt.Println(valor)
}

chan<- és un channel de només escriptura. <-chan és de només lectura. El compilador t’impedeix fer-los servir malament. Això no és només estètic: fa que la intenció del codi sigui òbvia i prevé bugs on una funció tanca o escriu en un channel que no hauria de tocar.

Go converteix automàticament un chan int bidireccional a chan<- int o <-chan int quan el passes a una funció que espera un channel direccional. No cal casting explícit.


Tancar channels: quan i per què

Tancar un channel indica que no s’hi enviaran més valors:

close(ch)

Després de tancar:

  • Rebre continua funcionant: retorna els valors que quedin al buffer, i després retorna el valor zero del tipus.
  • Enviar provoca un panic. No hi ha marxa enrere.
  • Tancar de nou també provoca un panic.

Pots comprovar si un channel està tancat fent servir la forma de dos valors:

valor, ok := <-ch
if !ok {
    fmt.Println(\"channel tancat\")
}

Quan ok és false, el channel es va tancar i no queden valors.

Regla fonamental: només l’emissor tanca

El patró correcte és que qui envia és qui tanca el channel. Mai el receptor. La raó és simple: si el receptor tanca el channel i l’emissor intenta enviar, tens un panic. Si l’emissor tanca, el receptor simplement detecta que ok és false i continua amb la seva vida.

Si tens múltiples emissors, no tanquis des de cap d’ells. Fes servir un mecanisme de coordinació extern (un sync.WaitGroup, per exemple) per saber quan tots els emissors han acabat, i tanca des d’una goroutine coordinadora.

func main() {
    ch := make(chan int)
    var wg sync.WaitGroup

    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            ch <- id
        }(i)
    }

    go func() {
        wg.Wait()
        close(ch)
    }()

    for val := range ch {
        fmt.Println(val)
    }
}

La goroutine coordinadora espera que els tres emissors acabin i llavors tanca. Net, sense race conditions.


Range sobre channels: consumir fins que es tanqui

El for range sobre un channel itera rebent valors fins que el channel es tanca:

ch := make(chan int)

go func() {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch) // sense això, range es bloqueja per sempre
}()

for val := range ch {
    fmt.Println(val) // 0, 1, 2, 3, 4
}

És la forma idiomàtica de consumir un channel quan saps que l’emissor l’acabarà tancant. Sense el close(), el range es quedaria bloquejat esperant el següent valor indefinidament, i tindries un deadlock.

for range sobre channels és net i expressiu. Però requereix que algú tanqui el channel. Si no pots garantir-ho, fes servir select amb un cas de cancel·lació.


Select: multiplexar channels

El select és com un switch per a operacions de channel. Espera que una de diverses operacions estigui llesta i executa aquell cas:

select {
case msg := <-ch1:
    fmt.Println(\"rebut de ch1:\", msg)
case msg := <-ch2:
    fmt.Println(\"rebut de ch2:\", msg)
case ch3 <- \"ping\":
    fmt.Println(\"enviat a ch3\")
}

Si diversos casos estan llestos al mateix temps, Go en tria un a l’atzar. No hi ha prioritat. Això és intencionat per evitar starvation.

Default: operació no bloquejant

Pots afegir un cas default perquè el select no es bloquegi:

select {
case msg := <-ch:
    fmt.Println(msg)
default:
    fmt.Println(\"no hi ha res disponible ara\")
}

Això converteix una operació bloquejant en non-blocking. És útil per fer polling o per intentar enviar sense quedar-se parat:

select {
case ch <- valor:
    // enviat amb èxit
default:
    // el channel està ple o ningú no llegeix, descartem
    log.Println(\"valor descartat\")
}

Aquest patró és comú quan tens un canal de mètriques o logging i prefereixes perdre un dada abans que bloquejar la goroutine principal.


Timeout amb select i time.After

Un dels patrons més útils amb select és implementar timeouts. No necessites llibreries externes ni timers complicats:

select {
case resultat := <-processarAlguna():
    fmt.Println(\"resultat:\", resultat)
case <-time.After(3 * time.Second):
    fmt.Println(\"timeout: l'operació ha trigat massa\")
}

time.After retorna un channel que rep un valor després del temps especificat. Si l’operació es completa abans, fas servir aquell resultat. Si no, el timeout guanya.

Timeout en un loop

Si processes missatges en un bucle i vols detectar inactivitat:

for {
    select {
    case msg := <-ch:
        fmt.Println(\"processant:\", msg)
    case <-time.After(5 * time.Second):
        fmt.Println(\"5 segons sense activitat, sortint\")
        return
    }
}

Compte amb això: cada iteració del loop crea un nou timer. Si el bucle itera ràpid, estàs creant milers de timers que el garbage collector ha de netejar. Per a bucles d’alta freqüència, fes servir time.NewTimer i reinicia’l manualment:

timer := time.NewTimer(5 * time.Second)
defer timer.Stop()

for {
    select {
    case msg := <-ch:
        fmt.Println(\"processant:\", msg)
        if !timer.Stop() {
            <-timer.C
        }
        timer.Reset(5 * time.Second)
    case <-timer.C:
        fmt.Println(\"timeout d'inactivitat\")
        return
    }
}

Més verbós, però no genera escombraries a cada iteració.


Patró fan-out, fan-in

Fan-out i fan-in és un dels patrons de concurrència en Go més usats en producció. La idea és simple:

  • Fan-out: llances diverses goroutines que llegeixen del mateix channel d’entrada.
  • Fan-in: reculls els resultats de múltiples goroutines en un únic channel de sortida.

Fan-out

func worker(id int, jobs <-chan int, results chan<- int) {
    for job := range jobs {
        fmt.Printf(\"worker %d processant job %d\n\", id, job)
        time.Sleep(time.Second) // simula treball
        results <- job * 2
    }
}

Llances diversos workers que competeixen pels jobs:

jobs := make(chan int, 10)
results := make(chan int, 10)

for w := 1; w <= 3; w++ {
    go worker(w, jobs, results)
}

for j := 1; j <= 9; j++ {
    jobs <- j
}
close(jobs)

for r := 1; r <= 9; r++ {
    fmt.Println(<-results)
}

Tres workers processen 9 jobs en paral·lel. El channel jobs actua com a cua de treball i Go s’encarrega que cada job el processi exactament un worker. Si vols aprofundir en aquest patró, tinc un article dedicat a worker pools en Go.

Fan-in

Fan-in combina múltiples channels en un:

func fanIn(channels ...<-chan string) <-chan string {
    var wg sync.WaitGroup
    merged := make(chan string)

    output := func(ch <-chan string) {
        defer wg.Done()
        for val := range ch {
            merged <- val
        }
    }

    wg.Add(len(channels))
    for _, ch := range channels {
        go output(ch)
    }

    go func() {
        wg.Wait()
        close(merged)
    }()

    return merged
}

La funció rep N channels, llança una goroutine per cada un que reenvía al channel merged, i una goroutine coordinadora que tanca merged quan tots els inputs s’han esgotat. Net i reutilitzable.


Quan els channels NO són la resposta: l’alternativa del mutex

Aquí és on molts articles de channels s’aturen. Però la realitat és que no tot problema concurrent necessita channels. De vegades un sync.Mutex és més clar.

Els channels són millors quan

  • Estàs passant ownership de dades: una goroutine produeix, una altra consumeix, ningú comparteix estat.
  • Necessites coordinar el flux entre goroutines: pipelines, fan-out/fan-in, timeouts.
  • Vols senyalitzar esdeveniments: “he acabat”, “cancel·la això”, “hi ha treball nou”.

El Mutex és millor quan

  • Estàs protegint accés a un recurs compartit: un mapa, un comptador, una caché en memòria.
  • L’operació és simple: llegeix o escriu un valor i continua.
  • No hi ha flux entre goroutines, només accés concurrent al mateix estat.
// Amb mutex: simple i directe
type SafeCounter struct {
    mu sync.Mutex
    v  map[string]int
}

func (c *SafeCounter) Inc(key string) {
    c.mu.Lock()
    c.v[key]++
    c.mu.Unlock()
}

func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.v[key]
}

Intentar fer això amb channels seria forçar una abstracció on no encaixa. Hauries de crear un channel de peticions, un altre de respostes, una goroutine que escolti peticions i actualitzi el mapa… per fer alguna cosa que un lock resol en tres línies.

La regla que faig servir: si l’operació és “accedir a un dada compartit”, mutex. Si l’operació és “passar dades o coordinar goroutines”, channels.


Errors comuns: deadlocks, panics i goroutine leaks

Deadlock: ningú escolta

L’error més freqüent amb channels és el deadlock. Passa quan totes les goroutines estan bloquejades esperant en operacions de channel que mai es completaran:

func main() {
    ch := make(chan int)
    ch <- 1 // deadlock: main es bloqueja enviant, ningú rep
    fmt.Println(<-ch)
}

Go detecta aquest cas i acaba el programa amb fatal error: all goroutines are asleep - deadlock!. Però només detecta deadlocks globals (quan totes les goroutines estan bloquejades). Si tens un deadlock parcial on algunes goroutines continuen vives, Go no t’avisa. Aquelles goroutines queden penjades consumint memòria silenciosament.

Enviar a un channel tancat: panic

ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel

No hi ha forma de comprovar si un channel està tancat abans d’enviar sense introduir una race condition. La solució és disseny: estructura el teu codi perquè només l’emissor tanqui, i que l’emissor sàpiga quan deixar d’enviar.

Goroutine leaks

Aquest és el més insidiós perquè no dona error. Una goroutine que es queda bloquejada en un channel que ningú llegirà mai:

func consultar(ctx context.Context) string {
    ch := make(chan string)

    go func() {
        resultat := operacioLenta()
        ch <- resultat // si ningú llegeix això, la goroutine queda penjada
    }()

    select {
    case res := <-ch:
        return res
    case <-ctx.Done():
        return \"cancel·lat\"
    }
}

Si el context es cancel·la abans que acabi operacioLenta(), la funció consultar retorna “cancel·lat”, però la goroutine anònima continua executant-se. Quan intenti enviar el resultat a ch, es quedarà bloquejada per sempre perquè ningú no escolta.

La solució: fes servir un buffered channel de capacitat 1:

ch := make(chan string, 1)

Ara, tot i que ningú llegeixi, l’enviament no es bloqueja (hi ha espai al buffer). La goroutine acaba, ch es reculli pel garbage collector eventualment, i no hi ha leak.


Exemple pràctic: rate-limited API caller amb channels

Construïm alguna cosa que combina diversos patrons: un sistema que fa crides a una API externa respectant un rate limit, processant els resultats de forma concurrent.

L’escenari: tens una llista de URLs a consultar, però l’API permet màxim 5 peticions per segon. Vols processar-les en paral·lel amb un pool de workers, però sense superar el límit.

package main

import (
    \"fmt\"
    \"io\"
    \"net/http\"
    \"sync\"
    \"time\"
)

type APIResult struct {
    URL    string
    Status int
    Body   string
    Err    error
}

func rateLimitedCaller(
    urls []string,
    maxPerSecond int,
    workers int,
) []APIResult {
    jobs := make(chan string, len(urls))
    results := make(chan APIResult, len(urls))

    // Rate limiter: un ticker que emet a la freqüència desitjada
    interval := time.Second / time.Duration(maxPerSecond)
    limiter := time.NewTicker(interval)
    defer limiter.Stop()

    // Workers
    var wg sync.WaitGroup
    for w := 0; w < workers; w++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            client := &http.Client{Timeout: 10 * time.Second}

            for url := range jobs {
                // Esperar el ticker abans de cada petició
                <-limiter.C

                resp, err := client.Get(url)
                if err != nil {
                    results <- APIResult{URL: url, Err: err}
                    continue
                }

                body, _ := io.ReadAll(resp.Body)
                resp.Body.Close()

                results <- APIResult{
                    URL:    url,
                    Status: resp.StatusCode,
                    Body:   string(body[:min(len(body), 200)]),
                }
            }
        }(w)
    }

    // Enviar jobs
    for _, url := range urls {
        jobs <- url
    }
    close(jobs)

    // Tancar results quan tots els workers acabin
    go func() {
        wg.Wait()
        close(results)
    }()

    // Recollir resultats
    var allResults []APIResult
    for r := range results {
        allResults = append(allResults, r)
    }

    return allResults
}

func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}

func main() {
    urls := []string{
        \"https://httpbin.org/get\",
        \"https://httpbin.org/status/200\",
        \"https://httpbin.org/status/404\",
        \"https://httpbin.org/delay/1\",
        \"https://httpbin.org/status/500\",
        \"https://httpbin.org/get\",
        \"https://httpbin.org/status/200\",
        \"https://httpbin.org/get\",
    }

    start := time.Now()
    results := rateLimitedCaller(urls, 5, 3)
    elapsed := time.Since(start)

    for _, r := range results {
        if r.Err != nil {
            fmt.Printf(\"[ERROR] %s: %v\n\", r.URL, r.Err)
        } else {
            fmt.Printf(\"[%d] %s\n\", r.Status, r.URL)
        }
    }

    fmt.Printf(\"\n%d peticions en %s\n\", len(results), elapsed)
}

Analitzem què està passant:

  1. Channel jobs: buffered amb capacitat per a totes les URLs. Els workers llegeixen d’aquí.
  2. Channel results: buffered també. Els workers escriuen resultats aquí.
  3. time.NewTicker: emet un tick cada 1s / maxPerSecond. Cada worker espera un tick abans de fer la petició. Això distribueix el rate limit entre tots els workers.
  4. sync.WaitGroup: coordina el tancament de results. Quan tots els workers acaben, la goroutine coordinadora tanca el channel.
  5. for range results: recull tot fins que es tanca.

El truc és que el ticker és compartit entre tots els workers. Quan un worker fa <-limiter.C, consumeix un tick. Si els tres workers intenten consumir alhora, només un rep cada tick. Això garanteix que el ritme global no supera maxPerSecond, independentment de quants workers tinguis.

Aquest patró escala bé: si necessites més throughput, puges el maxPerSecond. Si necessites més paral·lelisme en el processament de respostes, puges els workers. Els channels fan que tot encaixi sense locks explícits.


Saber quan fer servir un channel i quan no

Els channels són una de les eines més expressives de Go, però també una de les més mal utilitzades. He vist (i escrit) codi on un channel complicava alguna cosa que un mutex resolia en dues línies. La distinció que sempre m’ha servit és aquesta: si estic passant dades entre goroutines, un channel és l’eina natural. Si estic protegint l’accés a un recurs compartit, probablement vull un mutex.

El que em va costar més interioritzar va ser la disciplina al voltant del tancament de channels: només l’emissor tanca, mai el receptor. I que els goroutine leaks són completament silenciosos. Go detecta deadlocks globals, però els parcials te’ls empasses sense adonar-te’n. Fer servir buffered channels de capacitat 1 quan el receptor pot abandonar és un hàbit que m’ha estalviat hores de debugging.

Si estàs començant, el meu consell és anar amb unbuffered channels i select primer. Afegeix buffer quan ho necessitis de debò, no per defecte. I compte amb time.After dins d’un loop: crea un timer nou a cada iteració. time.NewTimer és el que vols si el loop és ràpid.

Articles relacionats

OshyTech

Enginyeria backend i de dades orientada a sistemes escalables, automatització i IA.

Navegació

Copyright 2026 OshyTech. Tots els drets reservats