Coloque go antes de uma chamada de função e essa função passa a rodar em concorrência com o resto do programa, gerenciada pelo runtime do Go em vez do sistema operacional. Iniciar uma é a parte fácil. A parte difícil é fazer duas goroutines se passarem um resultado sem pisar no estado uma da outra, e é esse o trabalho dos channels.
O que é uma goroutine?
Uma goroutine é uma função que roda de forma independente da que a iniciou, gerenciada pelo runtime do Go e não pelo sistema operacional. Uma thread do sistema operacional reserva um megabyte ou mais de stack e custa ao kernel tempo real para ser escalonada. Uma goroutine começa com uma stack de cerca de 2KB que cresce conforme necessário, e o runtime multiplexa muitas goroutines em um punhado de threads do sistema operacional. Iniciar milhares delas é normal.
| |
Execute isso algumas vezes e a ordem de “hi, alice” e “hi, bob” muda. Quem decide é o scheduler. O time.Sleep só mantém main viva o tempo suficiente para as goroutines imprimirem: remova-o e o programa pode terminar antes que qualquer uma das duas rode, porque quando main retorna o processo acaba, não importa o que ainda esteja em andamento. O sleep é uma gambiarra, não uma sincronização. Um channel é o que o substitui.
Como os channels conectam goroutines?
Um channel é um tubo tipado: uma goroutine envia um valor para dentro, outra o recebe, e o runtime cuida da passagem. Imagine uma esteira de uma única pista entre dois operários. Quem envia coloca um item na esteira e não pode colocar um segundo até que o primeiro seja retirado. Isso é um channel sem buffer, e essa passagem bloqueante é todo o mecanismo de sincronização.
| |
make(chan string) cria um channel de strings sem buffer. O envio (ch <- "payload ready") bloqueia até que algo receba, e o recebimento (<-ch) bloqueia até que algo envie. Nenhum dos dois lados avança até que ambos estejam prontos, que é exatamente a passagem que o time.Sleep acima só estava simulando.
Dê uma capacidade ao channel e a esteira passa a suportar mais de um item antes de travar:
| |
O envio de um channel com buffer bloqueia só quando o buffer está cheio; o recebimento bloqueia só quando está vazio. Isso afrouxa o acoplamento entre quem envia e quem recebe, o que ajuda a absorver picos de trabalho. Não elimina a necessidade de coordenação. Só move o ponto em que o bloqueio começa.
Como fechar um channel e fazer range sobre ele?
Quem envia fecha um channel para dizer “não vem mais nada”:
| |
for v := range ch continua recebendo até o channel ser fechado e esvaziado, e então sai por conta própria. Sem valor sentinela, sem contagem manual. Para diferenciar um channel fechado e vazio de um que entregou um valor zero, use a forma de dois valores: v, ok := <-ch define ok como false apenas quando o channel está fechado e não tem mais nada para entregar.
close tem três formas de entrar em panic, e o compilador não detecta nenhuma delas:
- Enviar para um channel fechado entra em panic.
- Fechar um channel já fechado entra em panic.
- Fechar um channel
nilentra em panic.
Por isso quem fecha é sempre quem envia, nunca quem recebe. Quem recebe não tem como saber se outra goroutine ainda pretende enviar algo.
Como esperar que várias goroutines terminem?
range funciona quando um channel sinaliza o fim do trabalho. Quando você iniciou várias goroutines e só precisa saber que todas terminaram, use sync.WaitGroup:
| |
Add(1) incrementa o contador antes de cada goroutine começar, Done() o decrementa (com defer, então roda mesmo se a função entrar em panic), e Wait() bloqueia até o contador chegar a zero. Mova o Add para dentro da goroutine e você escreveu uma race: main pode chegar ao Wait(), ver o contador em zero e retornar antes que qualquer worker o tenha incrementado.
Como o select funciona com vários channels?
select espera em várias operações de channel ao mesmo tempo e executa a que ficar pronta primeiro. Timeouts e cancelamento em Go são construídos sobre isso.
| |
Se mais de um case estiver pronto, select escolhe um aleatoriamente, não na ordem em que foi escrito, então a ordem dos case não dá nenhuma prioridade. Sem default e sem nenhum case pronto, select bloqueia; um select{} vazio bloqueia para sempre. Adicione um case default e toda a instrução se torna não bloqueante: se nada estiver pronto nesse momento, o default roda.
Um worker pool combina quase tudo isso. Um número fixo de goroutines pega jobs de um channel e envia resultados para outro, de modo que o trabalho concorrente fica limitado independentemente de quantos jobs chegarem.
| |
Três workers, nove jobs. close(jobs) depois do último envio permite que o loop range de cada worker saia assim que a fila esvazia. As assinaturas usam jobs <-chan int (só recebimento) e results chan<- int (só envio). A seta mostra a direção e o compilador a impõe, então um worker que por engano envia para jobs falha ao compilar em vez de se comportar mal em tempo de execução.
Por que goroutines dão leak ou deadlock?
Leaks de goroutines. Uma goroutine bloqueada para sempre em um envio ou recebimento que nunca vai acontecer não trava com erro. Ela fica ali, retendo sua stack e tudo que capturou por closure, pelo resto da vida do programa. A causa mais comum é uma goroutine que envia um resultado para um channel que ninguém mais lê, porque quem deveria recebê-lo já desistiu depois de um timeout. Dê à goroutine uma saída, geralmente um context.Context sobre o qual ela também faz select, para que possa abandonar o envio.
Deadlock. fatal error: all goroutines are asleep - deadlock! significa que toda goroutine, incluindo main, está bloqueada em um channel ou lock sem mais nada que possa desbloqueá-la. O menor exemplo é um envio sem buffer em main sem nenhuma outra goroutine rodando para recebê-lo.
Races que channels não evitam. Um channel sincroniza a passagem de um valor. Ele não protege uma variável que você continua tocando fora do channel. Duas goroutines incrementando o mesmo contador capturado continuam em race, com ou sem channel, exatamente como no caso das threads explicado em o que é uma race condition e como corrigi-la. O Go traz um detector para isso: compile ou teste com -race e o runtime reporta acessos concorrentes à mesma memória onde pelo menos um é uma escrita.
| |
Ele só detecta races que realmente acontecem durante a execução, então encontra mais sob carga concorrente realista do que em um teste unitário tranquilo. Essa carga também é onde testes carregados de goroutines ficam flaky, o mesmo tipo de instabilidade abordado em teste unitário vs teste de integração vs end-to-end. Espere várias vezes mais memória e CPU com -race ativo. Rode-o na CI, não no binário que você distribui.
Channel ou mutex: qual usar?
Os dois tornam o acesso concorrente seguro, mas a partir de extremos opostos. Um sync.Mutex protege o estado compartilhado no lugar: trava, lê ou escreve, destrava, e o dado nunca se move. Um channel transfere a posse de um valor de uma goroutine para outra, de modo que só uma goroutine o toca por vez, por construção. A própria orientação do Go: use channels para coordenar ou passar trabalho entre goroutines, e um mutex para proteger um único dado compartilhado que várias goroutines atualizam no lugar, como um cache ou um contador. Se uma função precisa das duas coisas, provavelmente está fazendo dois trabalhos que ficariam melhores separados.
Se você vem do JavaScript, o modelo é diferente. Uma única thread executa seu código e o await só cede o controle nos pontos que você vê no código-fonte; async/await e o event loop do JavaScript explicados mostra onde estão esses pontos de suspensão. O Go te dá paralelismo real, então a escolha da primitiva é sua.
Para seu próximo batch job (redimensionar uma pasta de imagens, ou chamar uma API uma vez por linha de um CSV), comece pelo worker pool acima. Escolha o número de workers, feche o channel de jobs quando terminar de enviar, e adicione um context.Context ao select na primeira vez que precisar cancelar.