Concurrència en Go: goroutines explicades per a backend developers
Goroutines, concurrència vs paral·lelisme, patrons reals per a backend i errors freqüents. Concurrència simple i efectiva en Go.

A Python tens asyncio. A Java, threads i executors. A Go, escrius go davant d’una crida a una funció. I aquesta simplicitat és, crec, alhora una de les millors decisions de disseny del llenguatge i una de les trampes més perilloses per a qui l’adopta sense entendre què hi ha a sota.
Porto anys construint backends en Kotlin i Python. He treballat amb coroutines de Kotlin, amb CompletableFuture de Java, amb asyncio de Python. I puc dir, almenys des de la meva experiència, que la concurrència en Go no elimina la complexitat. El que fa és que puguis treballar-hi sense sentir que estàs lluitant amb el llenguatge. La sintaxi s’aparta, les abstraccions són poques, i el model mental és sorprenentment directe. Però directe no significa trivial, i és aquí on molta gent es perd.
Aquest article cobreix goroutines, la diferència real entre concurrència i paral·lelisme, els mecanismes de sincronització del paquet sync, patrons reals per a backend i els errors que cometràs. Si ja saps programar i vols entendre la concurrència en Go des de la perspectiva d’algú que construeix serveis reals, això és per a tu. Si estàs començant amb Go, potser vols llegir primer aprendre Go.
Concurrència vs paral·lelisme: la distinció que importa
Abans d’escriure una sola goroutine, crec que necessites tenir clar un concepte que la majoria de desenvolupadors barregen (jo ho vaig barrejar durant anys): concurrència i paral·lelisme no són el mateix.
Concurrència és la capacitat de gestionar múltiples tasques alhora. No significa que s’executin al mateix temps. Significa que el teu programa està estructurat per poder progressar en diverses tasques sense que una bloquegi les altres.
Paral·lelisme és execució simultània real. Dues coses corrent literalment al mateix temps en dues CPUs diferents.
Rob Pike, un dels creadors de Go, ho resumeix en una frase que repeteixo cada cop que algú barreja ambdós conceptes:
“Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.”
A la pràctica, per a backend, la concurrència importa molt més que el paral·lelisme. I sent honestos, la majoria dels nostres serveis passen el 90% del temps esperant: esperant respostes de bases de dades, esperant crides HTTP a altres serveis, esperant I/O. Si el teu programa pot fer alguna cosa útil mentre espera, guanyes rendiment. No perquè executis coses en paral·lel, sinó perquè no desperdicies temps bloquejat.
Go et dóna totes dues coses. Les goroutines permeten concurrència estructural. El runtime de Go, si té múltiples CPUs disponibles, les pot executar en paral·lel. Però el valor fonamental és en la concurrència: el teu codi pot estar fent deu peticions HTTP alhora sense necessitar deu threads del sistema operatiu.
Un exemple concret. El teu servei rep una petició i necessita:
- Consultar la base de dades per obtenir un usuari
- Cridar un servei extern per obtenir els seus permisos
- Consultar una memòria cau per obtenir les seves preferències
De forma seqüencial, si cada operació tarda 100ms, necessites 300ms. De forma concurrent, pots llançar les tres alhora i esperar que acabin: 100ms. No perquè executis en paral·lel (pot ser que sí, però no és el rellevant), sinó perquè no esperes que una acabi per començar la següent.
Què és una goroutine
Una goroutine és una funció que s’executa de forma concurrent amb la resta del programa. Tècnicament és un lightweight thread gestionat pel runtime de Go, no pel sistema operatiu.
Això és important, i és aquí on Go marca la diferència. A Java, cada thread és un thread del SO. Crear-los és car (típicament 1-2 MB de stack per thread), fer context switch entre ells és car, i tenir milers alhora és problemàtic. A Go, una goroutine comença amb un stack d’uns 8 KB que creix dinàmicament. Pots tenir centenars de milers corrent sense problemes. El runtime de Go les multiplexa sobre un nombre molt menor de threads del SO utilitzant el seu propi scheduler.
El model és M:N. M goroutines mapejades sobre N threads del SO. El runtime de Go decideix quan i com distribuir-les. No has de gestionar thread pools, ni configurar el nombre de threads, ni pensar en context switches. Llances goroutines i el runtime s’encarrega.
func processar(id int) {
fmt.Printf(\"processant tasca %d\n\", id)
time.Sleep(100 * time.Millisecond) // simula treball
fmt.Printf(\"tasca %d completada\n\", id)
}Aquesta funció no té res d’especial. És una funció normal. La màgia és en com la crides.
Llançar goroutines: la paraula clau go
Llançar una goroutine és l’operació més simple del model de concurrència de Go:
go processar(1)Això és tot. La funció processar s’executa en una nova goroutine. L’execució del codi que va fer la crida continua immediatament sense esperar que processar acabi.
func main() {
fmt.Println(\"inici\")
go processar(1)
go processar(2)
go processar(3)
fmt.Println(\"goroutines llançades\")
time.Sleep(1 * time.Second) // espera poc elegant
}Aquest exemple llança tres goroutines. Les tres s’executen concurrentment. El time.Sleep al final és necessari perquè si main acaba, el programa acaba, i les goroutines moren amb ell. Evidentment, time.Sleep no és la forma correcta de sincronitzar goroutines. És un hack que veuràs en tutorials i que no hauries d’usar en producció. Però serveix per il·lustrar el punt: llançar goroutines és trivial.
També pots llançar funcions anònimes com a goroutines:
go func() {
fmt.Println(\"executant-me en una goroutine\")
}()
go func(msg string) {
fmt.Println(msg)
}(\"hola des de goroutine\")El patró de passar arguments a la funció anònima és important. Si captures variables de l’scope exterior directament en lloc de passar-les com a argument, pots acabar amb race conditions. Més sobre això a la secció d’errors comuns.
El problema: goroutines sense sincronització
Aquí és on la simplicitat de go es converteix en trampa. És tan fàcil llançar goroutines que molts desenvolupadors —i m’hi incloc al principi— les llancen sense pensar en com es sincronitzen. I llavors comencen els problemes.
func main() {
comptador := 0
for i := 0; i < 1000; i++ {
go func() {
comptador++
}()
}
time.Sleep(1 * time.Second)
fmt.Println(comptador) // 1000? No necessàriament.
}Aquest codi té una race condition. Mil goroutines intenten incrementar la mateixa variable alhora. comptador++ no és una operació atòmica: llegeix el valor, l’incrementa, i l’escriu. Si dues goroutines llegeixen el mateix valor abans que cap escrigui, una de les escriptures es perd.
El resultat pot ser 1000, o 987, o 953. Depèn del scheduling del runtime, de la càrrega del sistema, del nombre de CPUs. És no-determinístic, i sent honestos, aquesta és la pitjor classe de bug que existeix: el que funciona a la teva màquina i falla en producció.
Sense sincronització, les goroutines són un generador de bugs. La regla fonamental de la concurrència en Go és simple: si dues goroutines accedeixen a la mateixa variable i almenys una la modifica, necessites sincronització.
sync.WaitGroup: esperar que les goroutines acabin
El primer mecanisme de sincronització que necessites és sync.WaitGroup. Resol el problema més bàsic: saber quan un grup de goroutines ha acabat.
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf(\"goroutine %d treballant\n\", id)
time.Sleep(100 * time.Millisecond)
}(i)
}
wg.Wait() // bloqueja fins que totes les goroutines cridin Done()
fmt.Println(\"totes les goroutines han acabat\")
}WaitGroup té tres mètodes:
Add(n): incrementa el comptador enn. El crides abans de llançar la goroutine.Done(): decrementa el comptador en 1. El crides quan la goroutine acaba. Usardeferés la convenció.Wait(): bloqueja fins que el comptador arribi a 0.
Errors habituals amb WaitGroup:
- Cridar
Adddins de la goroutine en comptes d’abans de llançar-la. Si la goroutine no s’ha schedulat encara quan crides aWait, el comptador pot estar a 0 iWaitretorna prematurament.
// MAL
go func() {
wg.Add(1) // pot executar-se després de wg.Wait()
defer wg.Done()
// treball
}()
// BÉ
wg.Add(1)
go func() {
defer wg.Done()
// treball
}()Oblidar
Done(). Si una goroutine no cridaDone,Waites bloqueja per sempre. Usadefer wg.Done()sempre com a primera línia de la goroutine.Passar
WaitGroupper valor.WaitGroupno s’ha de copiar. Si el passes a una funció, passa’l com a punter.
// MAL: wg es copia, Done() no afecta l'original
func worker(wg sync.WaitGroup) {
defer wg.Done()
// treball
}
// BÉ: passa el punter
func worker(wg *sync.WaitGroup) {
defer wg.Done()
// treball
}Un patró real que uso constantment: llançar N crides HTTP concurrents i esperar que totes acabin.
func fetchAll(urls []string) []Response {
var wg sync.WaitGroup
results := make([]Response, len(urls))
for i, url := range urls {
wg.Add(1)
go func(idx int, u string) {
defer wg.Done()
results[idx] = fetch(u)
}(i, url)
}
wg.Wait()
return results
}Observa que cada goroutine escriu en una posició diferent del slice results. No hi ha race condition perquè no comparteixen posicions. Si totes escriguessin en la mateixa variable, necessitaries un mutex.
sync.Mutex: protegir estat compartit
Quan dues o més goroutines necessiten llegir i escriure la mateixa variable, necessites un sync.Mutex. Un mutex (mutual exclusion) garanteix que només una goroutine accedeix a la secció crítica alhora.
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
c.count++
c.mu.Unlock()
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}Ara l’exemple del comptador funciona correctament:
func main() {
counter := &SafeCounter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Increment()
}()
}
wg.Wait()
fmt.Println(counter.Value()) // sempre 1000
}sync.RWMutex: lectures concurrents
Si el teu cas d’ús té moltes lectures i poques escriptures, sync.RWMutex és més eficient. Permet múltiples lectors concurrents però només un escriptor exclusiu.
type Cache struct {
mu sync.RWMutex
items map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.items[key]
return val, ok
}
func (c *Cache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = value
}Múltiples goroutines poden cridar Get simultàniament. Però quan una crida Set, bloqueja totes les altres fins que acabi.
Regles pràctiques per a mutex
Mantén la secció crítica el més petita possible. No posis I/O dins d’un Lock. Fes el Lock, modifica la variable, fes l’Unlock. Si necessites fer una crida HTTP, copia les dades que necessites dins del Lock i fes la crida fora.
Usa
deferper a Unlock. És la forma idiomàtica i et protegeix d’oblidar l’Unlock si hi ha un early return o un panic.Mai copies un Mutex. Igual que amb WaitGroup, passa sempre punters.
No facis Lock dins d’un Lock del mateix mutex. És un deadlock immediat. Go no té mutexes reentrantes a propòsit.
// DEADLOCK: Lock dins de Lock
func (c *SafeCounter) Bad() {
c.mu.Lock()
// ...
c.mu.Lock() // es bloqueja aquí per sempre
}Quan usar mutex vs channels
Go té dos mecanismes principals de sincronització: mutexes i channels. La pregunta de quan usar cadascun genera debats interminables, i no crec que hi hagi una resposta única. Però la meva regla és pragmàtica:
Usa mutex quan protegeixes un estat compartit. Si tens una variable que diverses goroutines necessiten llegir i escriure, un mutex és la solució més directa. Una memòria cau en memòria, un comptador, un mapa de sessions actives.
Usa channels quan coordines fluxos de treball. Si necessites passar dades d’una goroutine a una altra, senyalitzar que alguna cosa ha acabat, o implementar un patró productor-consumidor, els channels són l’abstracció correcta.
La frase famosa és “Don’t communicate by sharing memory; share memory by communicating.” És un bon principi, però portat a l’extrem produeix codi artificialment complex. He vist —i confesso que he escrit— implementacions d’un simple comptador amb channels en comptes d’un mutex, i el resultat era il·legible.
// Mutex: simple, directe, correcte
var mu sync.Mutex
var count int
func increment() {
mu.Lock()
count++
mu.Unlock()
}
// Channels per a un comptador: sobreenginyeria
func counterManager(inc <-chan struct{}, get <-chan chan int) {
count := 0
for {
select {
case <-inc:
count++
case reply := <-get:
reply <- count
}
}
}El segon exemple és tècnicament correcte, i en alguns contextos pot tenir sentit. Però per a la gran majoria de casos, crec que el mutex és més llegible, més ràpid i més fàcil de depurar.
Una guia ràpida:
| Situació | Usa |
|---|---|
| Protegir lectura/escriptura d’una variable | sync.Mutex o sync.RWMutex |
| Passar resultats entre goroutines | Channels |
| Senyalitzar que alguna cosa ha acabat | sync.WaitGroup o un channel |
| Fan-out / fan-in | Channels |
| Worker pool | Channels |
| Memòria cau en memòria | sync.RWMutex |
| Limitar concurrència | Buffered channel com a semàfor |
Si estàs començant amb Go, domina primer WaitGroup i Mutex. Després passa a channels en Go i worker pools en Go. No intentis aprendre-ho tot alhora.
Patrons reals de backend: crides concurrents i queries paral·leles
Anem al que importa: patrons que usaràs en serveis reals.
Crides HTTP concurrents
El teu servei necessita cridar tres APIs externes per compondre una resposta. Fer-ho seqüencialment és malbaratar temps.
type UserProfile struct {
User User
Permissions []Permission
Preferences Preferences
}
func GetUserProfile(ctx context.Context, userID string) (*UserProfile, error) {
var (
wg sync.WaitGroup
user User
permissions []Permission
preferences Preferences
userErr error
permErr error
prefErr error
)
wg.Add(3)
go func() {
defer wg.Done()
user, userErr = fetchUser(ctx, userID)
}()
go func() {
defer wg.Done()
permissions, permErr = fetchPermissions(ctx, userID)
}()
go func() {
defer wg.Done()
preferences, prefErr = fetchPreferences(ctx, userID)
}()
wg.Wait()
if userErr != nil {
return nil, fmt.Errorf(\"fetching user: %w\", userErr)
}
if permErr != nil {
return nil, fmt.Errorf(\"fetching permissions: %w\", permErr)
}
if prefErr != nil {
return nil, fmt.Errorf(\"fetching preferences: %w\", prefErr)
}
return &UserProfile{
User: user,
Permissions: permissions,
Preferences: preferences,
}, nil
}Aquest patró és el pa de cada dia del backend amb Go. Tres crides que abans tardaven 300ms ara tarden 100ms. Les variables d’error són separades perquè cada goroutine escriu a la seva. No hi ha race condition.
Per a un patró més robust amb cancel·lació, pots usar errgroup del paquet golang.org/x/sync:
func GetUserProfile(ctx context.Context, userID string) (*UserProfile, error) {
g, ctx := errgroup.WithContext(ctx)
var user User
var permissions []Permission
var preferences Preferences
g.Go(func() error {
var err error
user, err = fetchUser(ctx, userID)
return err
})
g.Go(func() error {
var err error
permissions, err = fetchPermissions(ctx, userID)
return err
})
g.Go(func() error {
var err error
preferences, err = fetchPreferences(ctx, userID)
return err
})
if err := g.Wait(); err != nil {
return nil, err
}
return &UserProfile{
User: user,
Permissions: permissions,
Preferences: preferences,
}, nil
}errgroup és millor que WaitGroup per a aquest cas perquè cancel·la el context si qualsevol de les goroutines falla. Si la crida a permisos falla, les altres goroutines reben el senyal de cancel·lació a través del context i poden aturar-se en lloc de continuar treballant inútilment.
Queries concurrents a base de dades
Mateix patró, però per a consultes a PostgreSQL:
func GetDashboardData(ctx context.Context, db *sql.DB, userID int64) (*Dashboard, error) {
g, ctx := errgroup.WithContext(ctx)
var orders []Order
var stats Stats
var notifications []Notification
g.Go(func() error {
var err error
orders, err = getRecentOrders(ctx, db, userID)
return err
})
g.Go(func() error {
var err error
stats, err = getUserStats(ctx, db, userID)
return err
})
g.Go(func() error {
var err error
notifications, err = getUnreadNotifications(ctx, db, userID)
return err
})
if err := g.Wait(); err != nil {
return nil, fmt.Errorf(\"loading dashboard: %w\", err)
}
return &Dashboard{
Orders: orders,
Stats: stats,
Notifications: notifications,
}, nil
}Tres queries que abans s’executaven seqüencialment, ara s’executen concurrentment. Si el teu connection pool de PostgreSQL té prou connexions, això redueix la latència dràsticament.
Processar un batch amb concurrència limitada
De vegades necessites processar milers d’items però no pots llançar milers de goroutines alhora (per exemple, perquè la base de dades o l’API externa té un límit de connexions). Un buffered channel actua com a semàfor:
func processBatch(ctx context.Context, items []Item) error {
const maxConcurrency = 10
sem := make(chan struct{}, maxConcurrency)
g, ctx := errgroup.WithContext(ctx)
for _, item := range items {
item := item // captura la variable del loop
g.Go(func() error {
sem <- struct{}{} // adquireix slot
defer func() { <-sem }() // allibera slot
return processItem(ctx, item)
})
}
return g.Wait()
}El channel sem té un buffer de 10. Quan 10 goroutines han escrit en ell, la següent es bloqueja fins que una de les anteriors llegeixi del channel (alliberant un slot). És un patró de semàfor simple i efectiu. Per a patrons més avançats d’aquest tipus, pots llegir worker pools en Go.
Errors comuns: goroutine leaks, race conditions i oblits
Després de treballar amb Go en producció, puc dir que el 80% dels bugs de concurrència cauen en unes poques categories. Les enumero aquí perquè conèixer-les d’avantmà et pot estalviar setmanes de depuració.
Goroutine leaks
Una goroutine leak ocorre quan llances una goroutine que mai acaba. Es queda allà, consumint memòria, esperant alguna cosa que mai arribarà.
// LEAK: si ningú llegeix del channel, la goroutine es bloqueja per sempre
func leakyFunction() {
ch := make(chan int)
go func() {
result := expensiveComputation()
ch <- result // es bloqueja si ningú llegeix
}()
// la funció retorna sense llegir de ch
// la goroutine queda bloquejada per sempre
}El fix depèn del cas. De vegades necessites un buffered channel. De vegades necessites un select amb un ctx.Done(). De vegades necessites assegurar-te que algú llegeix del channel.
// FIX: buffered channel de mida 1
func fixedFunction() {
ch := make(chan int, 1) // buffer d'1: la goroutine pot escriure i acabar
go func() {
result := expensiveComputation()
ch <- result
}()
// fins i tot si ningú llegeix, la goroutine no es bloqueja
}Una altra causa comuna de leaks: goroutines que escolten un channel que ningú tanca.
// LEAK: si ningú tanca ch, la goroutine mai acaba
go func() {
for item := range ch {
process(item)
}
}()Regla: si llances una goroutine, tingues clar quina és la seva condició d’acabament. Si no pots explicar quan i per què acabarà, tens un leak potencial.
Captura de variables del loop
Aquest és un clàssic que ha mossegat tot desenvolupador de Go alguna vegada. Des de Go 1.22, les variables del loop es capturen correctament en la majoria dels casos, però és important entendre el problema perquè encara hi ha codi legacy que el té.
// PROBLEMA (Go < 1.22): totes les goroutines imprimeixen el mateix valor
for _, url := range urls {
go func() {
fetch(url) // url és la variable del loop, no una còpia
}()
}
// SOLUCIÓ: passar com a argument
for _, url := range urls {
go func(u string) {
fetch(u)
}(url)
}Abans de Go 1.22, la variable url del loop era la mateixa en cada iteració. Les goroutines capturaven una referència a aquella variable, i per quan s’executaven, url ja tenia l’últim valor. Des de Go 1.22, cada iteració crea una nova variable, però passar com a argument continua sent el patró més explícit i segur.
Accés no sincronitzat a maps
Els maps en Go no són thread-safe. I això pot sorprendre: dues goroutines escrivint al mateix map simultàniament provoquen un panic en runtime, no un resultat incorrecte silenciós. Un crash directe.
// PANIC: concurrent map writes
m := make(map[string]int)
for i := 0; i < 100; i++ {
go func(n int) {
m[fmt.Sprintf(\"key-%d\", n)] = n // panic
}(i)
}Solucions:
sync.Mutexper protegir l’accés al map.sync.Mapsi tens un cas d’ús amb moltes lectures i poques escriptures i les keys són relativament estables.- Redissenyar perquè cada goroutine tingui el seu propi map i els combinis al final.
// Opció 1: Mutex
type SafeMap struct {
mu sync.Mutex
m map[string]int
}
func (sm *SafeMap) Set(key string, val int) {
sm.mu.Lock()
sm.m[key] = val
sm.mu.Unlock()
}Oblidar passar el context
En serveis backend, el context (context.Context) és el teu mecanisme de cancel·lació. Si llances una goroutine que fa una crida HTTP o una query a la base de dades i no li passes el context, aquella goroutine no s’assabentarà que la request original va ser cancel·lada.
// MAL: la goroutine continua treballant tot i que el client hagi cancel·lat
go func() {
result, err := http.Get(url) // sense context
// ...
}()
// BÉ: usa el context del request
go func() {
req, _ := http.NewRequestWithContext(ctx, \"GET\", url, nil)
result, err := http.DefaultClient.Do(req)
// ...
}()Més sobre això a context en Go.
El race detector: go test -race
Go té una eina integrada que detecta race conditions en runtime. És una de les millors coses del tooling de Go i hauries d’usar-la sempre.
go test -race ./...
go run -race main.go
go build -race -o myappEl race detector instrumenta el teu codi per detectar accessos concurrents no sincronitzats a la mateixa variable. Quan detecta una race condition, imprimeix un informe detallat amb els stacks de les goroutines involucrades:
WARNING: DATA RACE
Read at 0x00c0000a4000 by goroutine 7:
main.main.func1()
/home/roger/app/main.go:15 +0x3c
Previous write at 0x00c0000a4000 by goroutine 6:
main.main.func1()
/home/roger/app/main.go:15 +0x52
Goroutine 7 (running) created at:
main.main()
/home/roger/app/main.go:13 +0x84
Goroutine 6 (finished) created at:
main.main()
/home/roger/app/main.go:13 +0x84Et diu exactament quina variable, quines goroutines, i en quina línia de codi. És, crec, una de les millors eines de tot l’ecosistema de Go.
Quan i com usar-lo
En tests, sempre. El teu CI hauria d’executar go test -race ./... en cada commit. No hi ha excusa per no fer-ho. La penalització de rendiment existeix (el codi corre 2-10x més lent i usa més memòria), però en tests això no importa.
En desenvolupament, freqüentment. Compila amb -race quan estiguis treballant en codi concurrent. El race detector només detecta races que realment ocorren durant l’execució, no les possibles, així que necessites que el codi conflictiu s’executi.
En producció, no. La penalització de rendiment i memòria és massa alta per a producció. Però si tens un entorn de staging, considera córrer amb -race allà.
Un detall important: el race detector troba races que ocorren durant l’execució. Si una race condition només es manifesta sota càrrega alta i els teus tests no generen aquella càrrega, el detector no la trobarà. Per això és important tenir tests que exercitin els paths concurrents del teu codi.
func TestConcurrentAccess(t *testing.T) {
counter := &SafeCounter{}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
counter.Increment()
_ = counter.Value()
}
}()
}
wg.Wait()
if counter.Value() != 10000 {
t.Errorf(\"expected 10000, got %d\", counter.Value())
}
}Aquest test no només verifica el resultat: en córrer amb -race, també verifica que no hi ha accessos no sincronitzats.
Goroutines i servidors HTTP: una goroutine per request
Si uses net/http (o frameworks com Gin, Chi, Echo), cada request HTTP es gestiona en la seva pròpia goroutine. No has de fer res perquè això passi. El servidor estàndard de Go llança una goroutine per cada connexió entrant.
func main() {
http.HandleFunc(\"/api/users\", handleUsers)
http.ListenAndServe(\":8080\", nil)
}
func handleUsers(w http.ResponseWriter, r *http.Request) {
// aquesta funció ja està corrent en la seva pròpia goroutine
// no necessites llançar una goroutine addicional per gestionar la request
}Això té implicacions pràctiques:
El teu handler ja és concurrent. Mil requests simultànies signifiquen mil goroutines executant el teu handler. Si el teu handler accedeix a estat global mutable (una variable de paquet, un map compartit), necessites sincronització.
El context del request es cancel·la quan el client es desconnecta.
r.Context()et dóna un context que es cancel·la si el client tanca la connexió. Passa’l a totes les teves operacions downstream (queries, crides HTTP, etc.).Pots llançar goroutines dins del handler, però ves amb compte. Si llances una goroutine que sobreviu al handler, necessites assegurar-te que no usa el
http.ResponseWriterni el*http.Requestdesprés que el handler retorni, perquè seran reciclats.
func handleUsers(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// BÉ: goroutines que acaben abans que el handler retorni
g, ctx := errgroup.WithContext(ctx)
var users []User
var count int
g.Go(func() error {
var err error
users, err = getUsers(ctx)
return err
})
g.Go(func() error {
var err error
count, err = getUserCount(ctx)
return err
})
if err := g.Wait(); err != nil {
http.Error(w, \"internal error\", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]any{
\"users\": users,
\"count\": count,
})
}Un error que veig sovint en codi de principiants:
func handleUsers(w http.ResponseWriter, r *http.Request) {
// MAL: goroutine que escriu a w després que el handler retorni
go func() {
users, _ := getUsers(r.Context())
json.NewEncoder(w).Encode(users) // w pot haver estat reciclat
}()
// el handler retorna immediatament, el ResponseWriter ja no és vàlid
}Si necessites fer treball en background que sobrevisqui al request (enviar un correu electrònic, actualitzar una memòria cau), no uses el ResponseWriter ni el Request. Copia les dades que necessitis i usa un context independent.
func handleOrder(w http.ResponseWriter, r *http.Request) {
order := processOrder(r)
// Respon al client immediatament
json.NewEncoder(w).Encode(order)
// Treball en background: usa context.Background(), no r.Context()
go func(orderID string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
sendConfirmationEmail(ctx, orderID)
}(order.ID)
}Performance: quantes goroutines són massa
La resposta curta, i probablement no la que esperes: més de les que creus que pots tenir.
He vist benchmarks amb milions de goroutines corrent simultàniament. Cada goroutine comença amb un stack d’uns 8 KB, de manera que un milió de goroutines consumeix uns 8 GB de memòria només en stacks. A la pràctica, per a un servei backend típic, tenir desenes de milers de goroutines actives és completament normal i no hauria de preocupar-te.
El que sí hauria de preocupar-te no és el nombre de goroutines sinó el que fan:
- Goroutines esperant I/O: són barates. Una goroutine bloquejada en una lectura de xarxa gairebé no consumeix CPU. Pots tenir-ne milers.
- Goroutines fent treball de CPU: són cares. Si tens 8 cores i 10.000 goroutines fent càlculs, només 8 poden córrer simultàniament. La resta espera. L’overhead de scheduling comença a importar.
- Goroutines que creen més goroutines sense límit: perilloses. Si cada request llança N goroutines i reps M requests, tens M*N goroutines. Si N o M són grans, pots quedar-te sense memòria.
Monitoritzar goroutines
En producció, monitoritza el nombre de goroutines actives:
import \"runtime\"
func metricsHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, \"goroutines: %d\n\", runtime.NumGoroutine())
}Si veus que el nombre creix contínuament sense baixar, tens un goroutine leak. És una de les primeres mètriques que configuro en qualsevol servei Go.
Amb pprof, pots inspeccionar exactament què estan fent les teves goroutines:
import _ \"net/http/pprof\"
func main() {
go http.ListenAndServe(\":6060\", nil)
// el teu servidor principal en un altre port
}Després pots accedir a http://localhost:6060/debug/pprof/goroutine?debug=1 per veure un dump de totes les goroutines actives, agrupades per stack trace. És inestimable per diagnosticar leaks.
GOMAXPROCS
GOMAXPROCS controla quants threads del SO utilitza el runtime de Go per executar goroutines. Per defecte, és el nombre de CPUs disponibles. Rarament necessites canviar-lo, però és bo saber que existeix.
import \"runtime\"
func main() {
fmt.Println(\"CPUs:\", runtime.NumCPU())
fmt.Println(\"GOMAXPROCS:\", runtime.GOMAXPROCS(0)) // 0 = només consultar, no canviar
}En contenidors Docker, abans de Go 1.19, GOMAXPROCS podia agafar el nombre de CPUs del host en lloc del contenidor. Si el teu contenidor té 2 CPUs però el host té 64, Go creava 64 threads. La llibreria automaxprocs d’Uber era la solució estàndard. Des de Go 1.19, el runtime respecta els límits de CPU del contenidor.
Següents passos: channels, context, worker pools
La concurrència en Go no acaba amb goroutines i mutexes. De fet, acabo de cobrir la base. Però la situació canvia força quan incorpores els mecanismes que fan que la concurrència en Go sigui realment poderosa:
Channels en Go: el mecanisme de comunicació entre goroutines. Buffered vs unbuffered, direccionalitat, el pattern select, i com tancar channels de forma segura. Si sync.WaitGroup i sync.Mutex són el nivell 1 de la concurrència en Go, els channels són el nivell 2.
Context en Go: cancel·lació, timeouts i propagació de valors. En un servei backend, el context és el que permet que quan un client es desconnecta, totes les operacions downstream s’aturin. És el pegament invisible que manté la concurrència sota control.
Worker pools en Go: quan necessites processar milers de tasques amb una concurrència limitada. Workers que llegeixen d’un channel, resultats que s’envien per un altre channel, i cancel·lació neta. És el patró més important per a processament en batch.
El que recomano: practica primer amb WaitGroup i Mutex fins que et surtin sense pensar. Escriu tests amb -race per a tot. Quan això sigui natural, passa a channels. I quan dominis channels, els worker pools i el context encaixen sols.
La concurrència en Go no és màgia. És un model simple amb eines simples que, combinades correctament, et permeten escriure backend que aprofita al màxim els recursos del teu hardware. La complexitat no desapareix —seria ingenu pensar això—, però per primera vegada en molt de temps, sento que el llenguatge és al meu costat en lloc d’en contra meva. I no perquè Go sigui millor que tot el demès. Sinó perquè el seu model de concurrència encaixa de forma natural amb el que necessito construir la majoria dels dies.


