Concorrência e Paralelismo com Golang
O objetivo deste post é ser uma introdução teórica e prática de como trabalhar com concorrência e ou paralelismo usando as Goroutines e Channels que na minha opinião são as melhores funcionalidades do famigerado Golang.
Não existe bala de prata
Uma das coisas que acho errado é pensar que determinada linguagem, tecnologia ou framework será a solução de todos os problemas. Cada tecnologia possui seus pontos fracos e fortes (trade-off) e é responsabilidade do desenvolvedor conhecê-los antes de utilizá-la. Pra isto, neste post eu pretendo introduzir alguns fundamentos que vocês precisam entender antes de iniciar no assunto. São eles:
O que é um processo?
Todo programa que você usa no seu computador, desde o Google Chrome até o seu jogo favorito roda em cima de um Processo. Um processo é uma instância(“espaço”) no sistema operacional que fornece um ambiente para que um programa execute corretamente.
Dentro desse ambiente acontece a Alocação de Memória pelo Sistema Operacional: Code — Instruções de Máquina, Data — Valores / Variáveis Globais, Heap — Alocação de memória dinâmica e Stack — Utilizado para guardar variáveis local de uma função.
O que são as Threads?
Threads são a menor unidade de execução que a CPU aceita.
-
Cada processo tem no mínimo uma Thread (que é a thread principal);
-
Um processo pode ter várias Threads;
-
Threads compartilham o mesmo espaço de endereçamento (precisamos nos atentar a isso);
-
Threads executam uma independente das outras ;
-
Threads podem executar em concorrência ou paralelamente;
Estados (life-cycle) de uma Thread:
-
Runnable — Quando um processo é criado, a Thread principal é colocada na fila como “pronta para ser executada”.
-
Running — Assim que a CPU ficar disponível, o Scheduler coloca a Thread para executar em um espaço de tempo. Se o tempo definido expirar, a Thread é colocada de novo na fila (Runnable).
-
Waiting — Se a Thread ficar bloqueada por operações I/O como ler e escrever em disco, requisições para internet ou esperando eventos de outros processos, ela é colocada em estado de Waiting enquanto a operação não terminar. Assim que a operação é finalizada, a Thread volta para o estado de Runnable.
Algumas limitações
-
Context Switching — A CPU gasta tempo copiando o contexto da thread atual em execução em memória e resgatando o contexto das próximas threads à executar. Porém é mais eficiente usar um processo que contém várias threads, uma vez que a criação do processo consome muito tempo e muitos recursos.
-
C10K Problem — O Scheduler do sistema aloca um espaço de tempo para a execução de cada processo. Esse tempo definido é dividido igualmente entre cada thread executada dentro desse processo. Quanto mais threads, menos tempo cada uma terá para executar, e assim, possivelmente a troca de contexto do sistema vai demorar mais para acontecer do que o tempo que cada thread vai ter para executar.
-
Stack Size — As Threads são alocadas com um tamanho fixo à Stack Size definida na máquina que está executando (Você consegue verificar esse valor usando o comando “ulimit -a” no Linux ou MacOS). No meu caso esse valor é de 8MB e tenho 8GB de memória RAM no meu computador, então teoricamente eu poderia criar no máximo 1.000 threads.
Goroutines
O que são Goroutines?
Uma goroutine é um encadeamento leve (logicamente um encadeamento de execução) gerenciado pelo runtime do Go.
Uma goroutine existe apenas no espaço virtual do Go e não no sistema operacional, sendo assim, o runtime do Go inicia com um número de goroutines para o garbage collector, scheduler e para os códigos do usuário.
Uma Thread é criada para manusear e cuidar dessas goroutines criadas na inicialização. Todas as goroutines executam na mesma thread, a não ser que uma destas goroutines se tornem bloqueantes (waiting), assim a cada goroutine bloqueada é instanciada uma thread para processar as outras goroutines enquanto essa que está bloqueada termine de ser executada.
Ou seja, se você tem 100 goroutines bloqueadas, você tem 100 threads bloqueadas e 1 thread em execução.
Goroutines usam paralelismo ou concorrência?
Para entender isso, primeiro vamos entender o que é cada uma delas.
Concorrência não é paralelismo. Paralelismo é quando duas ou mais threads executam o código simultaneamente através de diferentes cores do processador.
Concorrência executa o código concorrentemente entre threads em um único core do processador.
Imagine que você precisa lavar uma pilha de pratos e depois seca-los, você tem apenas uma pia para fazer esse trabalho, o que você provavelmente faria seria lavar os pratos e depois secar cada um deles, que seria uma analogia para Single Threaded.
Agora imagina que invés lavar e depois secar, você pede ajuda para um amigo ir secando os pratos enquanto você lava, isso seria a Concorrência, enquanto você está ocupado lavando os pratos, um outro agente está secando.
Agora em outro cenário, imagine que você tem duas pias, e você novamente pede ajuda a um amigo, vocês conseguem lavar e secar Paralelamente, sem interferir no trabalho do outro. Se cada um de vocês, pedir ajuda de outras pessoas, para secarem enquanto vocês lavam, em pias diferentes, vocês estão usando Paralelismo e Concorrência!
Agora que você entendeu a diferença, com goroutines você consegue utilizar Concorrência e ou Paralelismo!
Criando uma goroutine!
Pense em goroutine como uma thread, conceito que você provavelmente já está familiarizado ou aprendeu agora neste post. Em Go, você pode criar uma nova goroutine para executar código simultaneamente usando a keyword go:
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 5; i++ {
time.Sleep(100 * time.Millisecond)
fmt.Println(s)
}
}
func main() {
go say("world")
say("hello")
}
Execute este código clicando aqui.
Se você já estiver familiarizado com programação concorrente em outras linguagens, você pode ficar imediatamente impressionado com a simplicidade comparado a essas outras linguagens.
Execute novamente o código comentando a function “say” na linha 17, você provavelmente receberá uma mensagem “Program exited.” sem nenhum outro retorno.
Isso ocorre pois a nossa *goroutine *é independente e desconhecida pela *thread *principal. Devido a isto, a *thread *principal termina antes mesmo dos valores serem impressos. Vamos dar um jeito nisso…
Go Channels
Agora que aprendemos como criar novas *goroutines *concorrentes, é interessante aprender como faze-las comunicarem entre si.
Por exemplo, fazer uma *goroutine *recém criada avisar a goroutine principal que determinada tarefa já terminou. Para isso vamos usar o go channels:
package main
import (
"fmt"
"time"
)
func say(s string, done chan string) {
for i := 0; i < 5; i++ {
time.Sleep(100 * time.Millisecond)
fmt.Println(s)
}
done <- "Terminei"
}
func main() {
done := make(chan string)
go say("world", done)
fmt.Println(<-done)
}
Execute este código clicando aqui.
E aqui está, criamos um mecanismo de sincronização. A goroutine principal irá aguardar( ou bloquear) até receber uma mensagem da function “say” que está executando em sua própria goroutine.
Os Go Channels podem ser buffered(com buffer) ou unbufferred(sem buffer). Neste post, utilizamos apenas o unbuffered. Isso significa que, para uma *goroutine *enviar uma mensagem para um channel, outra *goroutine *deve está esperando para receber esta mensagem.
Aplicando concorrência e paralelismo
Para isto, vamos fazer um novo projetinho, onde vamos simular o processamento de arquivos, e iremos definir quantas threads ficarão responsáveis por esse processamento
package main
import (
"fmt"
"time"
)
func worker(workerId int, jobs <-chan int, results chan<- string) {
for job := range jobs {
time.Sleep(1 * time.Second)
results <- fmt.Sprintf("THREAD_%d: Finished file%d.txt", workerId, job)
}
}
func main() {
startProcess := time.Now()
concurrent := 1
files := 60
jobs := make(chan int, files)
results := make(chan string)
// Iniciando as goroutines
for workerId := 0; workerId < concurrent; workerId++ {
go worker(workerId+1, jobs, results)
}
// Enviando as tarefas para as goroutines
for i := 0; i < files; i++ {
jobs <- i
}
// Encerrando as tarefas
close(jobs)
for i := 0; i < files; i++ {
fmt.Println(<-results)
}
close(results)
endProccess := time.Now()
fmt.Println("Total time in seconds:", endProccess.Sub(startProcess).Seconds())
}
Execute este código clicando aqui.
Na function “worker” estamos simulando o processamento de arquivos que estamos recebendo via channel, pra isso utilizamos um “time.sleep” de um segundo, e passamos 60 “arquivos” para esta function.
Na linha 18 definimos concurrent como 1, então basicamente nosso programa irá instanciar apenas uma goroutine (o que acontece normalmente com programas single threaded), o programa irá demorar 60 segundos para terminar de processar todos os arquivos, já que definimos 60 arquivos e cada um leva 1 segundo para ser processado.
Agora troque o valor da variável *concurrent *para 60 e execute novamente. O programa terminará em 1 segundo! Aqui cabe uma explicação sobre o que está acontecendo por baixo.
O computador na qual estou executando tem apenas 4 núcleos de processamento, então 4 dessas 60 goroutines estão em paralelismo real, as outras 56 estão concorrentemente dentro desses 4 núcleos, executando no momento do time.sleep, enquanto espera o 1 segundo, dá tempo de executar outra concorrentemente!
Teoricamente podemos dizer que melhoramos em 60x a performance do nosso programa!!!
Clique aqui para acessar o repositório no Github
Se você chegou até aqui eu lhe agradeço, este post ficou maior que eu esperava.
Se quiser trocar uma ideia ou entrar em contato comigo, pode me chamar no Linkedin.
E para quem quiser ver meus outros projetos, basta acessar meu Github clicando aqui ;)
Grande abraço e até a próxima!