Concorrência em Go - Parte 2
O que é concorrência em GoLang para você?
Se você acha que concorrência nada mais é do que apenas uma competição entre duas ou mais pessoas, sinto-lhe dizer, mas você ainda não está preparado para seguir com a parte 2 dessa lição 😞
Sendo assim, recomendo que você volte para a lição que fala sobre Concorrência em Go - Parte 1, e aprenda um pouco mais goroutines, concorrência, paralelismo, select, channels, WaitGroup e muito mais!
Agora se você já veio da primeira parte dessa lição e não caiu aqui de paraquedas, então partiu aprender um pouco mais sobre concorrência em Go 🥳
Vamos começar?
O que é DeadLock?
Durante a lição anterior, mencionamos que a falta de cuidados ao se criar e gerenciar goroutines pode ocasionar um deadlock 😭
Um DeadLock é uma situação em que duas ou mais partes de um programa (feito com Go) estão esperando umas pelas outras, o que impede que qualquer uma delas continue a sua execução.
Em outras palavras o seu programa fica travado, pois todos estão esperando algo que nunca virá, e com isso ninguém consegue prosseguir a diante.
Vamos ver um pequeno exemplo disso:
package main
import "fmt"
func main() {
ch := make(chan int)
ch <- 1 // Aqui o programa vai travar (deadlock)
fmt.Println("Nunca será impresso")
}
No código acima, nos criamos dentro da variável ch
um canal não bufferizado, ou seja, ele bloqueia até que alguém esteja pronto para receber o valor.
Entretanto, como podemos perceber, não existe nenhuma go routine
(nenhuma função que faz o uso do comando go) recebendo aquele canal.
Esse é o tipo de coisa que trava o programa, pois ele fica esperando alguém receber esse valor 1, tipo de coisa que nunca vai acontecer.
A solução seria usar uma go routine
para o produtor e o consumidor da seguinte forma:
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 1
}()
fmt.Println(<-ch)
}
Note que dentro da função anônima que funciona de forma assíncrona (via goroutine) retorna de maneira correta o sinal de volta ao nosso canal.
Um outro grande exemplo de deadlock, é quando temos dois canais e só enviamos o sinal apenas para um deles:
func main() {
a := make(chan int)
b := make(chan int)
go func() {
<-b // espera receber algo de b
a <- 1
}()
<-a // espera receber algo de a
}
No código acima, a goroutine está esperando receber b
que nunca chega... Diferente de a
que retorna pelo menos um valor.
No final das contas, você como desenvolvedor, uma hora ou outra vai ficar sabendo que a sua aplicação travou, seja por algum erro (panic) ou pela simples falta de retorno.
Pensando nisso, eu separei algumas dicas relevantes que podem te ajudar a evitar deadlock em GoLang:
- Sempre garanta que cada envio (
chan <-
) tenha um recebimento (<- chan
) ativo. - Utilize select com default para operações não bloqueantes.
- Prefira canais com buffer quando possível (você ainda precisa tomar cuidado).
- Use timeouts com
select
etime.After
.
Channels com Range e Close
Você sabia que é possível criar um laço de repetição (usando for ou range) em cima de um canal em Go?
Quando usamos range
em um canal, o laço fica aguardando novos valores até que o canal seja fechado explicitamente com close(chan)
. E assim que o canal for fechado, o range
termina automaticamente.
Vejamos isso na prática:
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // Importante: fecha o canal para encerrar o range
}()
for val := range ch {
fmt.Println("Recebido:", val)
}
fmt.Println("Canal fechado, fim do range.")
}
No código acima nós criamos um canal chamado ch
, e em seguida executamos uma goroutine que conta com um laço de repetição, onde dentro desse laço, estamos enviando ao menos 5 sinais de volta ao nosso canal.
Como estamos enviando diversos sinais por diversas vezes dentro da goroutine, somos obrigado ao dizer ao sistema que o canal foi fechado, e para isso fazemos o uso do close(ch)
.
Sem o close(ch)
você ainda captura os sinais enquanto eles estiverem sendo enviados. O problema é que o range
nunca vai parar sem o close
, então ele vai travar quando o canal estiver vazio mas não fechado.
É importante ressaltar que o range
sobre um canal não espera um array
. Ele apenas escuta os valores sendo enviados. Cada vez que um valor é enviado no canal, o range
recebe esse valor individualmente.
Padrões de Concorrência em Go
No ano de 2012, durante uma conferência do Google.io, foi apresentado uma das grandes novidades da linguagem Go, os famosos padrões de concorrência 🤩
Um padrão de concorrência (ou concurrency patterns) representam estratégias reutilizáveis que nós desenvolvedores podemos usar para resolver problemas comuns, e que estão relacionados à execução paralela ou assíncrona de tarefas.
Tais padrões lidam muito bem com nossas goroutines, channels e sincronização de dados que acontecem durante a execução de nossas aplicações.
Mas nesta altura do campeonato você pode estar se perguntando: Mas por que eu devo usar padrões de concorrência?
E as respostas são mais simples do que você imagina...
- Eles reduzem erros como deadlocks, race conditions ou goroutines que estão vazando,
- Eles melhoram desempenho aproveitando múltiplos núcleos,
- Eles fazem com que o desenvolvedor tenha que escrever um código mais conciso e organizado para a execução paralela,
- Eles reutilizam soluções testadas para problemas recorrentes.
No GoLang existe diversos tipos de padrões de concorrência que podemos adotar em nosso projetos, são eles:
- Fan-Out / Fan-In
- Pipeline
- Worker Pool
- Timeout / Cancelamento com context.Context
- Generator Pattern
- Multiplexador
Veremos o funcionamento de cada um deles nos próximos tópicos 😉
Generator Pattern
No GoLang, nós temos um padrão de projeto conhecido como Generator Pattern, que é responsável por criar criar sequências de dados sob demanda, frequentemente usando goroutines e canais (channels) para enviar valores de forma assíncrona e preguiçosa (lazy).
Um generator em Go é basicamente uma função que retorna um canal. Internamente, ela roda uma goroutine que envia valores para esse canal, um por vez, podendo ser consumido como um iterador.
Vejamos um exemplo de um generator de números:
package main
import (
"fmt"
)
// Função generator que retorna um canal com números de 1 a n
func generateNumbers(n int) <-chan int {
ch := make(chan int)
go func() {
defer close(ch) // Fecha o canal após enviar os dados
for i := 1; i <= n; i++ {
ch <- i
}
}()
return ch
}
func main() {
for num := range generateNumbers(5) {
fmt.Println(num)
}
}
Sua saída será:
1
2
3
4
5
Note que na lógica acima, nós criamos uma função chamada generateNumbers
que retorna um canal com números que vão de 1
a n
.
Note também que estamos fechando nosso canal por meio do comando defer close (ch)
.
Vejamos um outro exemplo onde estamos usando o padrão generator em conjunto com cancelamento, para não deixar que nossas goroutines fiquem presas em loops infinitos:
func countWithStop(stop <-chan struct{}) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for i := 0; ; i++ {
select {
case <-stop:
return
case ch <- i:
}
}
}()
return ch
}
func main() {
stop := make(chan struct{})
numbers := countWithStop(stop)
for i := 0; i < 3; i++ {
fmt.Println(<-numbers)
}
close(stop) // Interrompe o generator
}
Note que na lógica acima, estamos interrompendo o generator usando o comando close(stop)
de modo a impedir que nossas goroutines fiquem presas.
Fan-Out / Fan-In
Um outro padrão de concorrência muito utilizado no GoLang, é o Fan-Out e Fan-In.
Fan-Out: várias goroutines recebem dados de um canal para processar em paralelo.
Fan-In: múltiplas goroutines enviam resultados para um único canal de saída.
Vejamos um pequeno exemplo da sua utilização:
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * 2
}
}
func main() {
jobs := make(chan int, 5)
results := make(chan int, 5)
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
for r := 1; r <= 5; r++ {
fmt.Println(<-results)
}
}
O padrão acima é bastante útil para processamentos paralelos com agregação de resultados.
Pipeline
Já o padrão pipeline, ele é responsável por executar uma sequência de etapas de processamento em canais que estão conectados entre si.
No caso dele, cada etapa é representada por uma goroutine, que por sua vez, transforma os dados e os envia para o próximo estágio.
Vejamos um pequeno exemplo:
func gen() <-chan int {
out := make(chan int)
go func() {
for i := 1; i <= 5; i++ {
out <- i
}
close(out)
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for v := range in {
out <- v * v
}
close(out)
}()
return out
}
func main() {
for result := range square(gen()) {
fmt.Println(result)
}
}
Timeout / Cancelamento com context.Context
Neste tipo de padrão, ele evita que operações concorrentes travem indefinidamente.
Aqui usando o context.Context
com timeout
para cancelar uma operação demorada, como se fosse uma chamada de API ou consulta a banco de dados.
Vejamos um exemplo:
package main
import (
"context"
"fmt"
"time"
)
func longRunningTask(ctx context.Context) {
fmt.Println("Iniciando tarefa longa...")
select {
case <-time.After(5 * time.Second): // Simula tarefa que demora 5 segundos
fmt.Println("✅ Tarefa concluída com sucesso.")
case <-ctx.Done(): // Timeout ou cancelamento
fmt.Println("⛔ Tarefa cancelada:", ctx.Err())
}
}
func main() {
// Cria um contexto com timeout de 2 segundos
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // Libera recursos quando terminar
longRunningTask(ctx)
}
A saída esperada será:
Iniciando tarefa longa...
⛔ Tarefa cancelada: context deadline exceeded
Tudo começando quando o comando context.WithTimeout
cria um contexto que expira automaticamente após 2 segundos da execução da aplicação.
Em seguida, fazemos o uso do comando ctx.Done()
para que o canal seja fechado quando o contexto expirar ou ser cancelado.
Por fim, temos o ctx.Err()
que tem por objetivo retornar o motivo de cancelamento (deadline exceeded
).
Worker Pool
Um Worker Pool é um outro tipo de padrão de concorrência onde você tem:
- Um conjunto fixo de workers (goroutines) preparados para executar tarefas.
- Um canal de entrada onde as tarefas são enviadas.
- Um canal de saída (opcional) onde os resultados são coletados.
Sendo ideal para processar arquivos e requisições em massa, trabalhos que envolvem I/O (leitura de banco e rede), além de ajudar a controlar os limites da concorrência.
Para entendermos melhor, vamos criar um programa que simula o processamento de 10 tarefas usando apenas 3 workers simultâneos:
package main
import (
"fmt"
"math/rand"
"time"
)
// Worker: executa uma tarefa recebida via canal
func worker(id int, jobs <-chan int, results chan<- int) {
for job := range jobs {
fmt.Printf("Worker %d: iniciando trabalho %d\n", id, job)
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond) // simula trabalho
fmt.Printf("Worker %d: finalizou trabalho %d\n", id, job)
results <- job * 2 // exemplo: resultado fictício
}
}
func main() {
rand.Seed(time.Now().UnixNano())
const totalJobs = 10
const numWorkers = 3
jobs := make(chan int, totalJobs)
results := make(chan int, totalJobs)
// Inicia os workers
for w := 1; w <= numWorkers; w++ {
go worker(w, jobs, results)
}
// Envia os jobs para os workers
for j := 1; j <= totalJobs; j++ {
jobs <- j
}
close(jobs) // importante: sinaliza que não virão mais tarefas
// Recebe os resultados
for r := 1; r <= totalJobs; r++ {
fmt.Printf("Resultado recebido: %d\n", <-results)
}
}
Veja um possível resultado:
Worker 1: iniciando trabalho 1
Worker 2: iniciando trabalho 2
Worker 3: iniciando trabalho 3
Worker 1: finalizou trabalho 1
Worker 1: iniciando trabalho 4
Worker 2: finalizou trabalho 2
Worker 2: iniciando trabalho 5
...
Resultado recebido: 2
Resultado recebido: 4
...
Tudo começa quando criamos um canal onde serão enviadas todas as nossas tarefas, e para isso usamos o comando jobs := make(chan int)
.
Em seguida, criamos um outro canal (results := make(chan int)
) onde cada worker
enviará os resultados de nossas goroutines.
Além disso, temos uma função worker(w, jobs, results)
que fica responsável por receber tarefas e processá-las.
Por fim, executamos o comando close(jobs)
, fundamental para que nossos workers saibam quando parar.
Multiplexador
O multiplexador, também conhecido como fan-in (já vimos sobre ele em tópicos anteriores), é um padrão de concorrência onde você combina múltiplos canais de entrada em um único canal de saída.
Pense no seguinte cenário: você tem várias goroutines produzindo dados, e quer receber todos esses dados em uma só estrutura para processar.
O multiplexador resolve este cenário 🥳
Vamos ver um exemplo onde estamos multiplexando dois canais:
package main
import (
"fmt"
"time"
)
func producer(name string, delay time.Duration) <-chan string {
ch := make(chan string)
go func() {
for i := 1; i <= 5; i++ {
time.Sleep(delay)
ch <- fmt.Sprintf("%s produz: %d", name, i)
}
close(ch)
}()
return ch
}
func multiplex(ch1, ch2 <-chan string) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for ch1 != nil || ch2 != nil {
select {
case val, ok := <-ch1:
if !ok {
ch1 = nil
continue
}
out <- val
case val, ok := <-ch2:
if !ok {
ch2 = nil
continue
}
out <- val
}
}
}()
return out
}
func main() {
a := producer("A", 500*time.Millisecond)
b := producer("B", 700*time.Millisecond)
mux := multiplex(a, b)
for val := range mux {
fmt.Println(val)
}
}
Saída esperada (pode variar no seu computador):
A produz: 1
B produz: 1
A produz: 2
A produz: 3
B produz: 2
...
Tudo começa quando duas goroutines (A e B) produzem valores em velocidades diferentes. A função multiplex
usa o select
para escutar dois canais simultaneamente.
Já quando qualquer uma dessas goroutines envia um dado, ele é repassado para o canal out
, onde por fim, ambos os canais são fechados, e por consequência disso o out
também é fechado.
Agora sim!
Finalmente podemos fechar essa lição de concorrência em GoLang com chave de OURO! 🥳
Apesar de ser um dos temas que requerem mais atenção, eu garanto para você que o domínio dos conceitos de concorrência vai te abrir diversas portas no mercado de trabalho.
E sim, por incrível que pareça, conceitos assíncronos costumam causa um certo medo entre os desenvolvedores, seja por envolver assuntos complexos, seja por ser um tipo de coisa que não é para qualquer sistema.
De todo modo, aprender sobre concorrência em GoLang vai te dar uma grande vantagem competitiva em relação aos seus colegas de trabalho!
Portanto, volte nesta lição sempre quando precisar! 😉
Repositório da lição
Todos os arquivos relacionados com esta lição, podem ser encontrados nos seguintes repositórios abaixo:
Conclusão
Nesta lição, aprendemos assuntos um pouco mais complexos relacionados a concorrência em GoLang, como: deadlock a padrões de concorrência.
Na próxima lição, vamos aprender um pouco sobre banco de dados em Go, e como fazer com que nossos dados persistam dentro das nossas aplicações.
Até daqui a pouco 🤓