Vamos Lá: Paralelismo em Golang, Parte 1
Portuguese (Português) translation by Erick Patrick (you can also view the original English article)
Visão Geral
Toda linguagem de programação bem sucedida tem um recurso matador. O forte de Go é a programação paralela. Ela foi projetada com base num modelo teórico forte (PSC) e provê sintaxe a nível de linguagem na forma da palavra chave "go" que inicia uma tarefa assíncrona (sim, a linguagem se chama assim por isso) bem como uma forma embutida de comunicar-se entre tarefas simultâneas.
Nessa primeira parte, apresentaremos o modelo PSC, que é implementado pela Go, goroutines e como sincronizar a operação de múltiplas goroutines em cooperação. Na próxima parte, falaremos sobre canais em Go e como coordenar goroutines sem estruturas de dados sincronizadas.
PSC
PSC significa Processo Sequencial de Comunicação. Foi apresentado por Tony (C. A. R.) Hoare em 1978. PSC é uma framework de alto nível para descrição de sistemas concorrentes. É muito mais fácil criar programas paralelos corretamente operando em nível PSC de abstração que com o uso típico de linhas (threads) e travas (locks).
Goroutines
Goroutines é uma jogo com coroutines. Mas, não são o mesmo. Goroutine é uma função executada em uma linha separada da linha que a invocou, para não bloqueá-la. Várias goroutines podem compartilhar a mesma linha de OS. Ao contrário de coroutines, goroutines não podem passar controle, de forma explicita, para outra. O tempo de execução de Go o faz, implicitamente, quando uma goroutine bloquear acesso de E/S.
Vejamos algum código. O programa Go abaixo define uma função, chamada de "f", que dorme aleatoriamente até meio segundo e, então, imprime seu argumento. A função main() invoca f() em um laço de quatro iterações, e em cada iteração invoca f() três vezes com "1", "2" e "3", em sequênica. Como esperado, retorna:
1 |
--- Run sequentially as normal functions |
2 |
1 |
3 |
2 |
4 |
3 |
5 |
1 |
6 |
2 |
7 |
3 |
8 |
1 |
9 |
2 |
10 |
3 |
11 |
1 |
12 |
2 |
13 |
3 |
Então, main chama f() como uma goroutine em um laço similar. Agora, o resultado é diferente porque o tempo de execução de Go executará as goroutines f paralelamente e, pela dormida aleatória, os valores impressão não aparecem na ordem que f() foi invocada. Eis o retorno:
1 |
--- Run concurrently as goroutines |
2 |
2 |
3 |
2 |
4 |
3 |
5 |
1 |
6 |
3 |
7 |
2 |
8 |
1 |
9 |
3 |
10 |
1 |
11 |
1 |
12 |
3 |
13 |
2 |
14 |
2 |
15 |
1 |
16 |
3 |
O programa em si usa os pacotes "time" e "math/rand", da biblioteca padrão, para implementar durmida aleatória e esperar o fim de todas as gorotinas. Isso é importante porque quando a linha principal termina, o programa finaliza, mesmo que tenhamos goroutines executando.
1 |
package main |
2 |
|
3 |
import ( |
4 |
"fmt"
|
5 |
"time"
|
6 |
"math/rand"
|
7 |
)
|
8 |
|
9 |
var r = rand.New(rand.NewSource(time.Now().UnixNano())) |
10 |
|
11 |
func f(s string) { |
12 |
// Sleep up to half a second
|
13 |
delay := time.Duration(r.Int() % 500) * time.Millisecond |
14 |
time.Sleep(delay) |
15 |
fmt.Println(s) |
16 |
}
|
17 |
|
18 |
|
19 |
func main() { |
20 |
fmt.Println("--- Run sequentially as normal functions") |
21 |
for i := 0; i < 4; i++ { |
22 |
f("1") |
23 |
f("2") |
24 |
f("3") |
25 |
}
|
26 |
|
27 |
fmt.Println("--- Run concurrently as goroutines") |
28 |
for i := 0; i < 5; i++ { |
29 |
go f("1") |
30 |
go f("2") |
31 |
go f("3") |
32 |
}
|
33 |
|
34 |
// Wait for 6 more seconds to let all go routine finish
|
35 |
time.Sleep(time.Duration(6) * time.Second) |
36 |
fmt.Println("--- Done.") |
37 |
}
|
Grupo de Sincronização
Ao termos várias goroutines executando por todos os lugares, qureremos saber quando elas todas estão prontas.
Existem formas diferentes de fazer isso, mas uma das melhores é usar um WaitGroup. Um WaitGroup é um tipo definido no pacote "sync" que provê as operações Add(), Done() e Wait(). Ele funciona como um contador de quantas goroutines ainda estão ativas e espera até todas terminarem. Sempre que iniciarmos novas goroutines, adicionamos com Add(1) (pode-se adicionar mais que 1 se lançarmos várias goroutines). Quando uma goroutina termina, ela chama Done(), reduzindo em um o contador, e Wait() bloqueia até o contador chegar a zero.
Convertamos o exemplo anterior e o usemos WaitGroup ao invés de dormir por seis segundos. Note que a f() usa defer wg.Done() ao invés de wg.Done() diretamente. Isso é útil para garantir que wg.Done() sempre seja invocada, mesmo que haja um problema e a goroutine encerre antes. Caso contrário, o contador nunca chegará a zero e wg.Wait() bloqueará para sempre.
Outra pequena dica é invocar wg.Add(3) apenas uma vez antes de invocar f() três vezes. Note que invocamos wg.Add() mesmo quando invocamos f() como uma função normal. Isso é necessário por f() chamar wg.Done() não importa se executada como função ou goroutine.
1 |
package main |
2 |
|
3 |
import ( |
4 |
"fmt"
|
5 |
"time"
|
6 |
"math/rand"
|
7 |
"sync"
|
8 |
)
|
9 |
|
10 |
var r = rand.New(rand.NewSource(time.Now().UnixNano())) |
11 |
var wg sync.WaitGroup |
12 |
|
13 |
func f(s string) { |
14 |
defer wg.Done() |
15 |
// Sleep up to half a second
|
16 |
delay := time.Duration(r.Int() % 500) * time.Millisecond |
17 |
time.Sleep(delay) |
18 |
fmt.Println(s) |
19 |
}
|
20 |
|
21 |
|
22 |
func main() { |
23 |
fmt.Println("--- Run sequentially as normal functions") |
24 |
for i := 0; i < 4; i++ { |
25 |
wg.Add(3) |
26 |
f("1") |
27 |
f("2") |
28 |
f("3") |
29 |
|
30 |
}
|
31 |
|
32 |
fmt.Println("--- Run concurrently as goroutines") |
33 |
for i := 0; i < 5; i++ { |
34 |
wg.Add(3) |
35 |
go f("1") |
36 |
go f("2") |
37 |
go f("3") |
38 |
}
|
39 |
|
40 |
wg.Wait() |
41 |
}
|
Estruturas de Dados Sincronizadas
As goroutines no programa 1,2,3 não se comunicam nem operam sobre estruturas de dados compartilhados. No mundo real, geralmente isso é preciso. O pacote "sync" provê o tipo Mutex com os métodos Lock() e Unlock() provendo exclusão mútua. Um ótimo exemplo é o mapa padrão de Go.
Não é sincronizado por padrão. Isso significa que se múltiplas goroutines acessarem o mesmo mapa paralelamente sem sincronização externa, os resultados serão imprevisíveis. Mas, se as goroutines concordarem num mutex compartilhado antes de cada acesso e liberá-lo depois, o acesso será serializado.
Tudo Junto
Agora, tudo junto. O Tour do Go tem um exercício para construir um rastreador web. Eles proveem um ótimo framework com um buscador e resultados simulados, para forcar no problema em questão. Recomendamos bastante resolvê-lo sozinho.
Criamos soluções usando duas abordagens: com mapa sincronizado e com canais. O código fonte está disponível aqui.
Eis o mais relevante da solução sincronizada. Primeiro, criemos um mapa com uma estrutura mutex para guardar as URLs buscadas. Notemos a interessante sintaxe onde um tipo anônimo é criado, inicializado e atribuído a uma variável de uma vez só.
1 |
var fetchedUrls = struct { |
2 |
urls map[string]bool |
3 |
m sync.Mutex |
4 |
}{urls: make(map[string]bool)} |
Agora, o código pode travar o mutex m antes de acessar o mapa de URL e liberá-lo ao terminar.
1 |
// Check if this url has already been fetched (or being fetched)
|
2 |
fetchedUrls.m.Lock() |
3 |
if fetchedUrls.urls[url] { |
4 |
fetchedUrls.m.Unlock() |
5 |
return
|
6 |
}
|
7 |
|
8 |
// OK. Let's fetch this url
|
9 |
fetchedUrls.urls[url] = true |
10 |
fetchedUrls.m.Unlock() |
Não é totalmente seguro porque qualquer um pode acessar a variável fetchedUrls e esquecer de travar e liberar. Um projeto mais robusto proverá uma estrutura de dados que suporte operações seguras, travando/liberando automaticamente.
Conclusão
Go tem um suporte excelente a paralelismo com as leves goroutines. É muito mais fácil que as linhas tradicionais. Quando é preciso acesso sincronizado para compartilhar estruturas de dados, Go ajuda com sync.Mutex.
Tem muito mais que isso no paralelismo em Go. Fique ligado...



