Concorrência

Você pode encontrar todos os códigos para esse capítulo aqui

A questão é a seguinte: um colega escreveu uma função, VerificaWebsites, que verifica o status de uma lista de URLs.

package concorrencia
type VerificadorWebsite func(string) bool
func VerificaWebsites(vw VerificadorWebsite, urls []string) map[string]bool {
resultados := make(map[string]bool)
for _, url := range urls {
resultados[url] = vw(url)
}
return resultados
}

Ela retorna um map de cada URL verificado com um valor booleano - true para uma boa resposta, false para uma resposta ruim.

Você também tem que passar um VerificadorWebsite como parâmetro, que leva um URL e retorna um boleano. Isso é usado pela função que verifica todos os websites.

Usando a injeção de dependência, conseguimos testar a função sem fazer chamadas HTTP de verdade, tornando o teste seguro e rápido.

Aqui está o teste que escreveram:

package concurrency
import (
"reflect"
"testing"
)
func mockVerificadorWebsite(url string) bool {
if url == "waat://furhurterwe.geds" {
return false
}
return true
}
func TestVerificaWebsites(t *testing.T) {
websites := []string{
"http://google.com",
"http://blog.gypsydave5.com",
"waat://furhurterwe.geds",
}
esperado := map[string]bool{
"http://google.com": true,
"http://blog.gypsydave5.com": true,
"waat://furhurterwe.geds": false,
}
resultado := VerificaWebsites(mockVerificadorWebsite, websites)
if !reflect.DeepEqual(esperado, resultado) {
t.Fatalf("esperado %v, resultado %v", esperado, resultado)
}
}

A função que está em produção está sendo usada para verificar centenas de websites. Só que seu colega começou a reclamar que está lento demais, e pediram sua ajuda para melhorar a velocidade dele.

Escreva o teste primeiro

Vamos usar um teste de benchmark para testar a velocidade de VerificaWebsites para que possamos ver o efeito das nossas alterações.

package concorrencia
import (
"testing"
"time"
)
func slowStubVerificadorWebsite(_ string) bool {
time.Sleep(20 * time.Millisecond)
return true
}
func BenchmarkVerificaWebsites(b *testing.B) {
urls := make([]string, 100)
for i := 0; i < len(urls); i++ {
urls[i] = "uma url"
}
for i := 0; i < b.N; i++ {
VerificaWebsites(slowStubVerificadorWebsite, urls)
}
}

O benchmark testa VerificaWebsites usando um slice de 100 URLs e usa uma nova implementação falsa de VerificadorWebsite. slowStubVerificadorWebsite é intencionalmente lento. Ele usa um time.Sleep para esperar exatamente 20 milissegundos e então retorna verdadeiro.

Quando executamos o benchmark com go test -bench=. (ou, se estiver no Powershell do Windows, go test -bench="."):

pkg: github.com/larien/learn-go-with-tests/concorrencia/v1
BenchmarkVerificaWebsites-4 1 2249228637 ns/op
PASS
ok github.com/larien/learn-go-with-tests/concorrencia/v1 2.268s

VerificaWebsites teve uma marca de 2249228637 nanosegundos - pouco mais de dois segundos.

Vamos torná-lo mais rápido.

Escreva código o suficiente para fazer o teste passar

Agora finalmente podemos falar sobre concorrência que, apenas para fins dessa situação, significa "fazer mais do que uma coisa ao mesmo tempo". Isso é algo que fazemos naturalmente todo dia.

Por exemplo, hoje de manhã fiz uma xícara de chá. Coloquei a chaleira no fogo e, enquanto esperava a água ferver, tirei o leite da geladeira, tirei o chá do armário, encontrei minha xícara favorita, coloquei o saquinho do chá e, quando a chaleira ferveu a água, coloquei a água na xícara.

O que eu não fiz foi colocar a chaleira no fogo e então ficar sem fazer nada só esperando a chaleira ferver a água, para depois fazer todo o restante quando a água tivesse fervido.

Se conseguir entender por que é mais rápido fazer chá da primeira forma, então você é capaz de entender como vamos tornar o VerificaWebsites mais rápido. Ao invés de esperar por um website responder antes de enviar uma requisição para o próximo website, vamos dizer para nosso computador fazer a próxima requisição enquanto espera pela primeira.

Normalmente, em Go, quando chamamos uma função fazAlgumaCoisa(), esperamos que ela retorne alguma coisa (mesmo se não tiver valor para retornar, ainda esperamos que ela termine). Chamamos essa operação de bloqueante - espera algo acabar para terminar seu trabalho. Uma operação que não bloqueia no Go vai rodar em um processo separado, chamado de goroutine. Pense no processo como uma leitura de uma página de código Go de cima para baixo, 'entrando' em cada função quando é chamado para ler o que essa página faz. Quando um processo separado começa, é como se outro leitor começasse a ler o interior da função, deixando o leitor original continuar lendo a página.

Para dizer ao Go começar uma nova goroutine, transformamos a chamada de função em uma declaração go colocando a palavra-chave go na frente da função: go fazAlgumaCoisa().

package concurrency
type VerificadorWebsite func(string) bool
func VerificaWebsites(vw VerificadorWebsite, urls []string) map[string]bool {
resultados := make(map[string]bool)
for _, url := range urls {
go func() {
resultados[url] = vw(url)
}()
}
return resultados
}

Já que a única forma de começar uma goroutine é colocar go na frente da chamada de função, costumamos usar funções anônimas quando queremos iniciar uma goroutine. Uma função anônima literal é bem parecida com uma declaração de função normal, mas (obviamente) sem um nome. Você pode ver uma acima no corpo do laço for.

Funções anônimas têm várias funcionalidades que as tornam úteis, duas das quais estamos usando acima. Primeiramente, elas podem ser executadas assim que fazemos sua declaração - que é o () no final da função anônima. Em segundo lugar, elas mantém acesso ao escopo léxico em que são definidas - todas as variáveis que estão disponíveis no ponto em que a função anônima é declarada também estão disponíveis no corpo da função.

O corpo da função anônima acima é quase o mesmo da função no laço utilizada anteriormente. A única diferença é que cada iteração do loop vai iniciar uma nova goroutine, concorrente com o processo atual (a função VerificadorWebsite), e cada uma vai adicionar seu resultado ao map de resultados.

--- FAIL: TestVerificaWebsites (0.00s)
VerificaWebsites_test.go:31: esperado map[http://google.com:true http://blog.gypsydave5.com:true waat://furhurterwe.geds:false], resultado map[]
FAIL
exit status 1
FAIL github.com/larien/learn-go-with-tests/concorrencia/v2 0.010s

Uma breve visita ao universo paralelo...

Você pode não ter obtido esse resultado. Você pode obter uma mesnagem de pânico, que vamos falar sobre em breve. Não se preocupe se isso aparecer para você, basta você executar o teste até você de fato receber o resultado acima. Ou faça de conta que você recebeu. Escolha sua. Boas vindas à concorrência: quando não for trabalhada da forma correta, é difícil prever o que vai acontecer. Não se preocupe, é por isso que estamos escrevendo testes: para nos ajudar a saber quando estamos trabalhando com concorrência de forma previsível.

... e estamos de volta.

Acabou que os testes originais do VerificadorWebsite agora estão devolvendo um map vazio. O que deu de errado?

Nenhuma das goroutines que nosso loop for iniciou teve tempo de adicionar seu resultado ao map resultados; a função VerificadorWebsite é rápida demais para eles, e por isso retorna o map vazio.

Para consertar isso, podemos apenas esperar enquanto todas as goroutines fazem seu trabalho, para depois retornar. Dois segundos devem servir, certo?

package concurrency
import "time"
type VerificadorWebsite func(string) bool
func VerificaWebsites(vw VerificadorWebsite, urls []string) map[string]bool {
resultados := make(map[string]bool)
for _, url := range urls {
go func() {
resultados[url] = vw(url)
}()
}
time.Sleep(2 * time.Second)
return resultados
}

Agora, quando os testes forem executados, você vai ver (ou não - leia a mensagem no início do tópico):

--- FAIL: TestVerificaWebsites (0.00s)
VerificaWebsites_test.go:31: esperado map[http://google.com:true http://blog.gypsydave5.com:true waat://furhurterwe.geds:false], resultado map[waat://furhurterwe.geds:false]
FAIL
exit status 1
FAIL github.com/larien/learn-go-with-tests/concorrencia/v1 0.010s

Isso não é muito bom - por que só um resultado? Podemos arrumar isso aumentando o tempo de espera - pode tentar se preferir. Não vai funcionar. O problema aqui é que a variável url é reutilizada para cada iteração do laço for - ele recebe um valor novo de urls a cada vez. Mas cada uma das goroutines tem uma referência para a variável url - eles não têm sua própria cópia independente. Logo, todas estão escrevendo o valor que url tem no final da iteração - o último URL. E é por isso que o resultado que obtemos é a última URL.

Para consertar isso:

package concorrencia
import (
"time"
)
type VerificadorWebsite func(string) bool
func VerificaWebsites(vw VerificadorWebsite, urls []string) map[string]bool {
resultados := make(map[string]bool)
for _, url := range urls {
go func(u string) {
resultados[u] = vw(u)
}(url)
}
time.Sleep(2 * time.Second)
return resultados
}

Ao passar cada função anônima como parâmetro para a URL - como u - e chamar a função anônima com url como argumento, nos certificamos de que o valor de u está fixado como o valor de url para cada iteração do laço de url e não pode ser modificado.

Agora, se você tiver sorte, vai obter:

PASS
ok github.com/larien/learn-go-with-tests/concorrencia/v1 2.012s

No entanto, se não tiver sorte (isso é mais provável se estiver rodando o código com o benchmark, já que haverá mais tentativas):

fatal error: concurrent map writes
goroutine 37 [running]:
runtime.throw(0x6d74f3, 0x15)
/usr/local/go/src/runtime/panic.go:608 +0x72 fp=0xc000034718 sp=0xc0000346e8 pc=0x42d4e2
runtime.mapassign_faststr(0x67dbe0, 0xc000082660, 0x6d33cb, 0x7, 0x0)
/usr/local/go/src/runtime/map_faststr.go:275 +0x3bf fp=0xc000034780 sp=0xc000034718 pc=0x4139ff
github.com/larien/learn-go-with-tests/concorrencia/v2.VerificaWebsites.func1(0x6e6580, 0xc000082660, 0x6d33cb, 0x7)
/home/larien/go/src/github.com/larien/learn-go-with-tests/concorrencia/v2/VerificaWebsites.go:17 +0x7f fp=0xc0000347c0 sp=0xc000034780 pc=0x64035f
runtime.goexit()
/usr/local/go/src/runtime/asm_amd64.s:1333 +0x1 fp=0xc0000347c8 sp=0xc0000347c0 pc=0x45c661
created by github.com/larien/learn-go-with-tests/concorrencia/v2.VerificaWebsites
/home/larien/go/src/github.com/larien/learn-go-with-tests/concorrencia/v2/VerificaWebsites.go:16 +0xa9
... e mais um monte de linhas assustadoras ...

Isso pode ser enorme e assustador, mas tudo o que precisamos fazer é respirar com calma e ler o stacktrace: fatal error: concurrent map writes (erro fatal: escrita concorrente no map). Às vezes, quando executamos nossos testes, duas das goroutines escrevem no map resultados ao mesmo tempo. Maps em Go não gostam quando mais de uma coisa tenta escrever algo neles ao mesmo tempo, então o erro fatal é gerado.

Essa é uma condição de corrida, um bug que aparece quando a saída do nosso software depende do timing e da sequência de eventos que não temos controle sobre. Por não termos controle exato sobre quando cada goroutine escreve no map resultados, ficamos vulneráveis à situação de duas goroutines escreverem nele ao mesmo tempo.

O Go nos ajuda a encontrar condições de corrida com seu detector de corrida nativo. Para habilitar essa funcionalidade, execute os testes com a flag race: go test -race.

Você deve ver uma saída parecida com essa:

==================
WARNING: DATA RACE
Write at 0x00c000120089 by goroutine 6:
reflect.typedmemmove()
/usr/local/go/src/runtime/mbarrier.go:177 +0x0
reflect.Value.MapIndex()
/usr/local/go/src/reflect/value.go:1124 +0x2ae
reflect.deepValueEqual()
/usr/local/go/src/reflect/deepequal.go:118 +0x13be
reflect.DeepEqual()
/usr/local/go/src/reflect/deepequal.go:196 +0x2f0
github.com/larien/learn-go-with-tests/concorrencia/v2.TestVerificaWebsites()
/home/larien/go/src/github.com/larien/learn-go-with-tests/concorrencia/v2/VerificaWebsites_test.go:30 +0x1ad
testing.tRunner()
/usr/local/go/src/testing/testing.go:827 +0x162
Previous write at 0x00c000120089 by goroutine 8:
github.com/larien/learn-go-with-tests/concorrencia/v2.VerificaWebsites.func1()
/home/larien/go/src/github.com/larien/learn-go-with-tests/concorrencia/v2/VerificaWebsites.go:17 +0x97
Goroutine 6 (running) created at:
testing.(*T).Run()
/usr/local/go/src/testing/testing.go:878 +0x659
testing.runTests.func1()
/usr/local/go/src/testing/testing.go:1119 +0xa8
testing.tRunner()
/usr/local/go/src/testing/testing.go:827 +0x162
testing.runTests()
/usr/local/go/src/testing/testing.go:1117 +0x4ee
testing.(*M).Run()
/usr/local/go/src/testing/testing.go:1034 +0x2ee
main.main()
_testmain.go:44 +0x221
Goroutine 8 (finished) created at:
github.com/larien/learn-go-with-tests/concorrencia/v2.VerificaWebsites()
/home/larien/go/src/github.com/larien/learn-go-with-tests/concorrencia/v2/VerificaWebsites.go:16 +0xb2
github.com/larien/learn-go-with-tests/concorrencia/v2.TestVerificaWebsites()
/home/larien/go/src/github.com/larien/learn-go-with-tests/concorrencia/v2/VerificaWebsites_test.go:28 +0x17f
testing.tRunner()
/usr/local/go/src/testing/testing.go:827 +0x162
==================

Os detalhes ainda assim são bem difíceis de serem lidos - mas o WARNING: DATA RACE (CUIDADO: CONDIÇÃO DE CORRIDA) é bem claro. Lendo o corpo do erro podemos ver duas goroutines diferentes performando escritas em um map:

Write at 0x00c000120089 by goroutine 6:

está escrevendo no mesmo bloco de memória que:

Previous write at 0x00c000120089 by goroutine 8:

Além disso, conseguimos ver a linha de código onde a escrita está acontecendo:

/home/larien/go/src/github.com/larien/learn-go-with-tests/concorrencia/v2/VerificaWebsites.go:17 +0x97

e a linha de código onde as goroutines 6 e 7 foram iniciadas:

/home/larien/go/src/github.com/larien/learn-go-with-tests/concorrencia/v2/VerificaWebsites.go:16 +0xb2

Tudo o que você precisa saber está impresso no seu terminal - tudo o que você tem que fazer é ser paciente o bastante para lê-lo.

Canais

Podemos resolver essa condição de corrida coordenando nossas goroutines usando canais. Canais são uma estrutura de dados em Go que pode receber e enviar valores. Essas operações, junto de seus detalhes, permitem a comunicação entre processos diferentes.

Nesse caso, queremos pensar sobre a comunicação entre o processo pai e cada uma das goroutines criadas por ele de forma que façam o trabalho de executar a função VerificadorWebsite com a URL.

package concurrency
type VerificadorWebsite func(string) bool
type resultado struct {
string
bool
}
func VerificaWebsites(vw VerificadorWebsite, urls []string) map[string]bool {
resultados := make(map[string]bool)
canalResultado := make(chan resultado)
for _, url := range urls {
go func(u string) {
canalResultado <- resultado{u, vw(u)}
}(url)
}
for i := 0; i < len(urls); i++ {
resultado := <-canalResultado
resultados[resultado.string] = resultado.bool
}
return resultados
}

Junto do map resultados, agora temos um canalResultados, que criamos da mesma forma usando make. O chan resultado é o tipo do canal - um canal de resultado. O tipo novo, resultado, foi criado para associar o retorno de VerificadorWebsite com a URL sendo verificada - é uma estrutura que contém uma string e um bool. Já que não precisamos que nenhum valor tenha um nome, cada um deles é anônimo dentro da struct; isso pode ser útil quando for difícil saber que nome dar a um valor.

Agora que iteramos pelas URLs, ao invés de escrever no map diretamente, enviamos uma struct resultado para cada chamada de vw para o canalResultado com uma sintaxe de envio. Essa sintaxe usa o operador <-, usando um canal à esquerda e um valor à direita:

// Sintaxe de envio
canalResultado <- resultado{u, vw(u)}

O próximo laço for itera uma vez sobre cada uma das URLs. Dentro, estamos usando uma expressão de recebimento, que atribui um valor recebido por um canal a uma variável. Essa expressão também usa o operador <-, mas com os dois operandos ao posições invertidas: o canal agora fica à direita e a variável que está recebendo o valor dele fica à esquerda:

// Expressão recebida
resultado := <-canalResultado

E depois usamos o resultado recebido para atualizar o map.

Ao enviar os resultados para um canal, podemos controlar o timing de cada escrita dentro do map resultados, garantindo que só aconteça uma por vez. Apesar de cada uma das chamadas de vw e cada envio ao canal resultado estar acontecendo em paralelo dentro de seu próprio processo, cada resultado está sendo resolvido de cada vez enquanto tiramos o valor do canal resultado com a expressão recebida.

Paralelizamos um pedaço do código que queríamos tornar mais rápida, enquanto mantivemos a parte que não pode acontecer em paralelo ainda acontecendo linearmente. E comunicamos diversos processos envolvidos utilizando canais.

Agora podemos executar o benchmark:

pkg: github.com/larien/learn-go-with-tests/concorrencia/v3
BenchmarkVerificaWebsites-8 100 23406615 ns/op
PASS
ok github.com/larien/learn-go-with-tests/concorrencia/v3 2.377s

23406615 nanossegundos - 0.023 segundos, cerca de 100 vezes mais rápida que a função original. Um sucesso enorme.

Resumo

Esse exercício foi um pouco mais leve na parte do TDD que o restante. Levamos um bom tempo refatorando a função VerificaWebsites; as entradas e saídas não mudaram, ela apenas ficou mais rápida. Mas, com os testes que já tinhamos escrito, assim como com o benchmark que escrevemos, fomos capazes de refatorar o VerificaWebsites de forma que mantivéssemos a confiança de que o software ainda estava funcionando, enquanto demonstramos que ela realmente havia ficado mais rápida.

Tornando as coisas mais rápidas, aprendemos sobre:

  • goroutines, a unidade básica de concorrência em Go, que nos permite verificar mais do que um site ao mesmo tempo.

  • funções anônimas, que usamos para iniciar cada um dos processos concorrentes que verificam os sites.

  • canais, para nos ajudar a organizar e controlar a comunicação entre diferentes processos, nos permitindo evitar um bug de condição de corrida.

  • o detector de corrida, que nos ajudou a desvendar problemas com código concorrente.

Torne-o rápido

Uma formulação da forma ágil de desenvolver software, erroneamente atribuida a Kent Beck, é:

Faça funcionar, faça da forma certa, torne-o rápido (em inglês)

Onde 'funcionar' é fazer os testes passarem, 'forma certa' é refatorar o código e 'tornar rápido' é otimizar o código para, por exemplo, tornar sua execução rápida. Só podemos 'torná-lo rápido' quando fizermos funcionar da forma certa. Tivemos sorte que o código que estudamos já estava funcionando e não precisava ser refatorado. Nunca devemos tentar 'torná-lo rápido' antes das outras duas etapas terem sido feitas, porque:

Otimização prematura é a raiz de todo o mal -- Donald Knuth