Go Internals - Concorrência
Introdução
Em Go Internals - Scheduler vimos que bloquear num channel ou num sync.Mutex estaciona um G em vez de prender uma thread do SO. Reflection fechou o arco de metadados de tipo. Este artigo é a camada de sync onde esses parks realmente caem: channels, select e o semáforo por baixo de sync.
Você já sabe usar ch <- x e mu.Lock(). Aqui a pergunta é o que runtime.chansend, selectgo e semacquire fazem com a goroutine quando a operação não pode completar na hora — e o que a acorda quando pode.
Channels são hchan
make(chan T, n) termina em runtime.makechan. O objeto concreto é hchan em chan.go:
// runtime/chan.go — simplified
type hchan struct {
qcount uint // elements currently in buffer
dataqsiz uint // buffer capacity
buf unsafe.Pointer // circular buffer of dataqsiz elems
elemsize uint16
closed uint32
elemtype *_type
sendx uint // send index into buf
recvx uint // recv index into buf
recvq waitq // waiting receivers
sendq waitq // waiting senders
lock mutex
}
Channels sem buffer têm dataqsiz == 0 e não usam buf para payload. Channels com buffer alocam um anel contíguo de n elementos junto (ou alcançável a partir) do hchan. Tamanho e tipo do elemento vêm do descritor de tipo de channel do compilador — a mesma história de metadados de slices e maps, só que apontada por elemtype.
Duas filas de espera ficam no channel: recvq e sendq. Cada entrada é um sudog — um waiter que liga um G, ponteiro opcional de elemento e links da fila. O cabeçalho de chan.go declara as invariantes de frente: pelo menos uma de sendq / recvq está vazia, salvo um caso estreito de select, e em channels com buffer um buffer não vazio implica nenhum receiver esperando, enquanto slots livres implicam nenhum sender esperando.
flowchart LR
subgraph hchan["hchan"]
BUF["buf ring"]
RQ["recvq"]
SQ["sendq"]
end
S["sender G"] -->|chansend| hchan
R["receiver G"] -->|chanrecv| hchan
RQ -.->|"sudog"| R2["parked receiver"]
SQ -.->|"sudog"| S2["parked sender"]
hchan.lock protege os campos e os sudogs estacionados naquele channel. O comentário ao lado do lock importa na prática: não mude o status de outro G enquanto segura o lock (em particular, não chame goready sob o lock). Shrink de stack corre contra waiters de channel; o runtime é cuidadoso com a ordem unlock-depois-ready por causa disso.
Caminhos de send e receive
ch <- v vira chansend. <-ch vira chanrecv. Ambos recebem uma flag block para que selects não bloqueantes e select com default possam falhar sem estacionar.
Ordem aproximada de um send bloqueante com o channel já locked:
- Se o channel está closed → panic (
send on closed channel). - Se há um receiver esperando em
recvq→ direct send: copia o elemento parasudog.elemdo receiver, remove o sudog da fila, unlock,goreadyno receiver. Buffer não entra. - Senão, se o buffer tem espaço → copia para
buf[sendx], avança índices, unlock, retorna. - Senão → enfileira um sudog em
sendq,gopark(liberando o lock do channel no park commit) e espera ser acordado por um receive futuro ou por close.
Receive espelha isso: sender esperando → direct recv; senão tira do buffer; senão estaciona em recvq.
func ping() {
ch := make(chan int) // unbuffered
go func() {
ch <- 1 // parks until someone receives
}()
v := <-ch // direct handoff from the parked sender
_ = v
}
Send/recv sem buffer é portanto um rendezvous: um lado copia para a stack (ou slot no heap) do outro e os dois seguem. Ops com buffer são uma fila até o anel encher ou esvaziar; depois disso caem na mesma máquina de park/ready.
Fechar (close(ch) → closechan) seta closed, acorda todo waiter nas duas filas e faz receives seguintes retornarem o zero value com ok == false. Sends depois de close panicam. Fechar um channel já fechado também panica. São regras da linguagem impostas no runtime, não convenções.
Channels nil são um caso especial que vale lembrar ao ler stack traces: send ou receive num channel nil estaciona para sempre (waitReasonChanSendNilChan / ChanReceiveNilChan). select só com channels nil faz o mesmo. É intencional — é assim que você desliga um case setando a variável do channel para nil.
select e selectgo
Um select com vários caminhos não tenta os cases na ordem do fonte. O compilador emite uma chamada a runtime.selectgo com um array de scase (channel + ponteiro do elemento) mais scratch para ordem de lock/poll.
// runtime/select.go — simplified
type scase struct {
c *hchan
elem unsafe.Pointer
}
selectgo grosso modo roda três passes:
- Poll — trava cada channel envolvido numa ordem estável (por endereço do
hchan) para dois selects não deadlockedarem um ao outro. Embaralha a ordem dos cases aleatoriamente e procura um case que possa seguir na hora (peer esperando, espaço/dados no buffer, ou channel fechado no recv). - Enqueue — se nada está pronto e não há
default, estaciona sudogs na fila de espera de cada case e entãogopark. - Dequeue dos perdedores — quando um case ganha a corrida de wake-up (
g.selectDoneCAS para waiters de select), remove esteGdas filas dos outros channels para não ser acordado duas vezes.
sequenceDiagram
participant G as selecting G
participant SG as selectgo
participant C1 as hchan A
participant C2 as hchan B
G->>SG: select { case <-A / case B<- }
SG->>C1: lock (address order)
SG->>C2: lock
SG->>SG: shuffle + poll ready cases
alt a case is ready
SG->>G: return chosen index
else block
SG->>C1: enqueue sudog
SG->>C2: enqueue sudog
SG->>G: gopark
Note over G: peer op or close readies one sudog
SG->>C1: dequeue losers
SG->>C2: dequeue losers
end
Fairness vem do shuffle: selects repetidos não preferem permanentemente o primeiro case do fonte. A ordem de lock por endereço do channel é o que impede deadlocks de ordem de lock entre selects aninhados/competindo. select de um case só e select só com default são otimizados pelo compilador para formas mais simples antes de bater no caminho completo de selectgo.
sudog: o waiter compartilhado
Channels e semáforos bloqueiam via sudog (runtime2.go). Pense nele como um nó de fila que o runtime pode estacionar e acordar sem prender um M à espera:
// runtime/runtime2.go — simplified
type sudog struct {
g *g
next, prev *sudog
elem maybeTraceablePtr // channel payload may point into stack
isSelect bool
success bool // channel: delivered vs woken by close
// ... semaRoot tree links, timing, ticket ...
}
Em channels, elem pode apontar para a stack da goroutine que espera. Por isso a cópia de stack precisa cooperar com locks de channel (activeStackChans, parkingOnChan no caminho de select/park commit). Em semáforos, sudogs penduram num treap de semaRoot indexado pelo endereço de espera em vez de numa waitq de hchan.
Quando um peer completa a operação, o runtime chama goready no g do sudog. As run queues do artigo de scheduler assumem a partir daí — primitivas de concorrência só produzem Gs runnable; elas mesmas não agendam o G num P.
sync.Mutex e o semáforo
sync.Mutex no Go 1.26 é um wrapper fino em volta de internal/sync.Mutex: uma word int32 de state mais uma word uint32 de semáforo.
// internal/sync/mutex.go — simplified
type Mutex struct {
state int32
sema uint32
}
const (
mutexLocked = 1 << iota
mutexWoken
mutexStarving
mutexWaiterShift = iota
)
Lock sem contenção é um único CAS de state de 0 para mutexLocked. O caminho contido (lockSlow) pode spinar um pouco enquanto o holder está rodando, depois incrementa a contagem de waiters e chama runtime_SemacquireMutex em sema. Unlock limpa o bit locked e, se há waiters, faz Semrelease para um G estacionado poder rodar.
Dois modos vivem nos comentários daquele arquivo:
- Normal — waiters em FIFO, mas um waiter acordado ainda compete com novos arrivals que já estão na CPU. Bom throughput; pode starvationar um waiter sob contenção contínua.
- Starvation — depois de ~1ms esperando, um waiter liga
mutexStarving. Unlock entrega ownership direto para a cabeça da fila; newcomers entram na fila em vez de furar. Latência de cauda deixa de ser patológica; throughput cai.
var mu sync.Mutex
var count int
func inc() {
mu.Lock()
count++
mu.Unlock()
}
Não há um objeto futex do SO por mutex como API principal. Esperas com contenção passam por runtime/sema.go: semacquire / semrelease hasheiam o endereço do semáforo numa semTable de semaRoots, cada um com um treap de sudogs. Sleep e wakeup são pareados mesmo se o wakeup corre na frente do sleep — o modelo de semáforo do Plan 9 que o arquivo cita — então o G estaciona no runtime e o P roda outra coisa, exatamente como numa espera de channel.
RWMutex, WaitGroup e Once todos desembocam na mesma primitiva de semáforo (mais atomics). Once é o padrão de livro: fast path atômico em done, mutex só na primeira execução para callers tardios esperarem f terminar em vez de passar por uma init incompleta.
WaitGroup empacota contador e contagem de waiters num uint64 atômico, com um sema para Waiters bloqueados. Go 1.26 também documenta WaitGroup.Go como o jeito preferido de spawnar tasks contadas; Add/Done ficam para call sites antigos. Add fora de ordem depois que o contador zerou enquanto Wait ainda está pendente continua sendo footgun em forma de panic — o runtime checa, não adivinha sua intenção.
Channels vs mutexes
Nenhum dos dois é "mais Go." Eles resolvem formas diferentes de coordenação:
| Situação | Costuma caber |
|---|---|
| Entregar ownership de um valor entre goroutines | channel (especialmente rendezvous sem buffer) |
| Proteger uma estrutura de dados in-place | Mutex / RWMutex |
| Fan-in / fan-out / pipelines de cancelamento | channels (+ context) |
| Seções críticas curtas em estado compartilhado | mutex |
| Esperar um conjunto de tasks terminar | WaitGroup (ou um wrapper tipo error-group) |
Um channel de tamanho 1 usado como lock funciona, mas custa um hchan, sudogs e cópias que você não precisava. Um mutex usado para implementar uma work queue funciona, mas empurra a bookkeeping da fila para o seu código. Profile decide: designs pesados em channel aparecem em chansend/chanrecv/selectgo; designs pesados em lock aparecem em mutex block profiles (go test -mutexprofile ou runtime.SetMutexProfileFraction).
Uma propriedade compartilhada: ambos estacionam Gs. Nenhum é desculpa para ignorar o memory model — um unlock de mutex "synchronizes before" o próximo lock; um send de channel synchronizes before o receive correspondente. Cruzar essas bordas sem um happens-before ainda é data race, mesmo se o programa "geralmente" parece ok.
Conclusão
Concorrência no runtime de Go é sobretudo filas de sudogs mais Gs sendo readied. Channels ligam essas filas a um hchan (anel opcional, handoff direto quando um peer espera). select trava channels por ordem de endereço, embaralha cases e estaciona em todos até um vencer. Mutexes e amigos fazem CAS numa word de state e, sob contenção, dormem na tabela de semáforos do runtime que estaciona do mesmo jeito que channels.
Próximo — e último — na série é Compiler: SSA, inlining, bounds-check elimination, e o que a toolchain emite para esta máquina do runtime ter stack maps, descritores de tipo e call sites com que trabalhar.