A simplicidade da concorrência em Go

A simplicidade da concorrência em Go

Várias linguagens de programação possuem formas distintas de escrever código paralelo e/ou de fazer com que processos possam ser projetados de forma concorrente.

Em minha opinião, Golang trata de forma bem simplificada em comparação com outras stacks, já que você pode escrever código sequencial e a comunicação entre eles é o que fará a mágica.

Neste texto vamos falar de como Golang trata esse tópico.

Concorrência não é paralelismo

Às vezes, podemos pensar que concorrência e paralelismo são análogos, mas na verdade esse pensamento é equivocado.

Concorrência não é sobre execução e sim sobre design e estrutura.

Paralelismo é sobre a execução simultânea de múltiplos processos.7

De forma simplificada você pode pensar em um sistema operacional que gerencia múltiplos dispositivos I/O (teclado, mouse, fone de ouvido). Esse software foi projetado de forma que todos esses dispositivos possam funcionar juntos. Entretanto se o hardware possuir apenas um processador, apesar de parecer que todos os dispositivos estão funcionado em conjunto, na prática apenas um deles é gerenciado por vez. Ou seja, o SO foi arquitetado de uma forma concorrente.

Enquanto no caso de um exemplo de paralelismo, podemos pensar em uma arquitetura com dois processadores e eles executam ao mesmo tempo um cálculo super complexo até chegarem na resposta.

CSP - Communicating sequential processes

Em 1978 Tony Hoary escreveu um artigo científico que contém a base de vários conceitos utilizados hoje em Golang a respeito de concorrência.

  • O texto ilustra que o processo é uma lógica individual que precisa de uma entrada (input) e produz uma saída (output). A beleza e simplicidade desse conceito permite que código sequencial seja escrito, ou seja, sem ter que alterar a forma que estamos acostumados a desenvolver para fazer esse conceito funcionar.
  • Cada processo tem um local state de operação exclusiva. Isto significa que diferentes processos não compartilham seus states com outros. Desta maneira você evita deadlocks (quando um processo fica completamente impedido de continuar sua execução) e race conditions (quando os processos dependem de uma ordem especifica para que possam ser executados). Caso seja necessário enviar dados de um processo a outro você não compartilha, mas sim envia uma cópia. Por consequência você não acopla os diferentes processos, afinal por mais que ele possua uma informação de outro ela não pode ser manipulada por outro processo.
  • Escala adicionando mais cópias do processo.

Várias apresentações e documentações de Go explicam da seguinte maneira:

Do not communicate by sharing memory; instead, share memory by communicating.

Outras linguagens de programação concorrência é essencialmente ter múltiplos threads rodando em paralelo e executando alguma tarefa complexa. Nesse modelo é necessário que os diferentes threads tenham acesso a alguma estrutura de dados compartilhada entre elas (lista, fila, dicionário). Esse compartilhamento faz com que seja necessário travar (lock) um pedaço específico de memória para que duas ou mais threads não acessem e modifiquem a mesma memória ao mesmo tempo. Tudo isso causa vários problemas como as race conditions explicadas previamente.

Em Golang por padrão você não compartilha a memória para o outro processo e sim uma cópia dela. Os componentes envolvidos (pensando num emissor e receptor) irão esperar até que o dado chegue em seu destino antes de continuar a execução. Essa espera força a sincronização entre os processos a respeito do dado sendo comunicado.

Goroutines

Depois dos vários conceitos acima explicados, vamos mergulhar no mundo do Go e entender na prática os conceitos do seu toolkit relacionados a concorrência. Vou supor que você já entende a sintaxe básica de go, funções, variáveis, loops, etc.

Uma goroutine é segundo a documentação:

[…]a lightweight thread managed by the Go runtime.

Lembram-se quando falamos de processos com seu próprio estado? A goroutine oferece essa capacidade.

Imagine que você quer construir um monitor (listener) de hashtag para poder monitorar as mensagens no twitter. No código abaixo temos a função getMessages, responsável por hipoteticamente imprimir a última mensagem na hashtag passada através da variável hashtag no twitter. Temos também um time.Sleep(time.Second) que faz com que a mensagem seja imprimida a cada um segundo, caso contrário como o for está em loop infinito (com o condicional true) você veria apenas um flood gigante no seu terminal.

package main

import (
	"fmt"
	"strconv"
	"time"
)

func getMessages(hashtag string, socialMedia string) {
	for i := 1; true; i++ {
		time.Sleep(time.Second)
		fmt.Println("The last message that we got from twitter on #" + hashtag + ". Number " + strconv.Itoa(i))
	}
}

func main() {
	getMessages("cats", "twitter")
	getMessages("cats", "facebook")
}

‌Saída no terminal:

➜ The last message that we got from twitter on #cats. Number 1
➜ The last message that we got from twitter on #cats. Number 2
➜ The last message that we got from twitter on #cats. Number 3
➜ The last message that we got from twitter on #cats. Number 4
➜ The last message that we got from twitter on #cats. Number 5

‌Só que agora não queremos apenas ver as mensagens do twitter e sim de diversas redes sociais. Neste caso vamos adicionar uma variável socialMedia a função getMessages e também alterar a função de getMessages para mostrar o novo parâmetro.

package main

import (
	"fmt"
	"strconv"
	"time"
)

func getMessages(hashtag string, socialMedia string) {
	for i := 1; true; i++ {
		time.Sleep(time.Second)
		fmt.Println("The last message that we got from " + socialMedia + " on #" + hashtag + ". Number " + strconv.Itoa(i))
	}
}

func main() {
	getMessages("cats", "twitter")
	getMessages("cats", "facebook")
}

‌  Saída no terminal:

➜ The last message that we got from twitter on #cats. Number 1
➜ The last message that we got from twitter on #cats. Number 2
➜ The last message that we got from twitter on #cats. Number 3
➜ The last message that we got from twitter on #cats. Number 4
➜ The last message that we got from twitter on #cats. Number 5

‌ Notou algo estranho? O trecho getMessages("cats", "facebook") não foi executado. Isso aconteceu devido ao Go estar muito ocupado executando o trecho  getMessages("cats", "twitter"). Uma forma de resolver esse problema é iniciar um desses trechos de código com uma goroutine. Desta forma eles serão executados em diferentes threads.

package main

import (
	"fmt"
	"time"
)

func getMessages(hashtag string, socialMedia string) {
	for i := 1; true; i++ {
		time.Sleep(time.Second)
		fmt.Println("The last message that we got from " + socialMedia + " on #" + hashtag + ". Number " + strconv.Itoa(i))
	}
}

func main() {
	go getMessages("cats", "twitter")
	getMessages("cats", "facebook")
}

‌  Saída no terminal:

➜ The last message that we got from twitter on #cats. Number 1
➜ The last message that we got from facebook on #cats. Number 1
➜ The last message that we got from twitter on #cats. Number 2
➜ The last message that we got from facebook on #cats. Number 2
➜ The last message that we got from facebook on #cats. Number 3
➜ The last message that we got from twitter on #cats. Number 3
➜ The last message that we got from facebook on #cats. Number 4
➜ The last message that we got from twitter on #cats. Number 4
➜ The last message that we got from facebook on #cats. Number 5
➜ The last message that we got from twitter on #cats. Number 5
...

‌  Agora temos o resultado que gostaríamos! In a nutshell usamos a go keyword para executarmos uma função em uma go routine. Ela será jogada no background da aplicação e a go runtime vai executa-la sempre que conseguir.

GO channels

Algo que pode parecer estranho no código anterior é o fato da função getMessages na verdade imprimir as mensagens diretamente no terminal. Vamos fazer uma alteração para que a mensagem seja passada de volta para a função main. Dessa forma, precisamos que a função getMessages se comunique com a main. Vamos introduzir o conceito de channel.

Segundo a documentação do Go

Channels are a typed conduit through which you can send and receive values with the channel operator, <-.

Traduzindo: canais possibilitam que façamos envio de informações entre diferentes goroutines. É importante ressaltar que por padrão um dos lados (emissor e receptor) espera (lock) o programa até que a informação chega no local desejado. É isto que permite a sincronização facilitada do Go. Nós falamos disso mais acima.

Para fazer uso de um channel é preciso cria-lo da seguinte forma:

ch := make(chan int)

Vamos inserir esse conceito no nosso código anterior:

package main

import (
	"strconv"
	"time"
)

func getMessages(hashtag string, socialMedia string, out chan string) {
	for i := 1; i < 6; i++ {
		time.Sleep(time.Second)
		var message = "The last message that we got from " + socialMedia + " on #" + hashtag + ". Number " + strconv.Itoa(i)
		out <- message
	}
}

func main() {
	ch := make(chan string)
	go getMessages("cats", "twitter", ch)
	getMessages("cats", "facebook", ch)

}

Alteramos nossa função getMessages para compor a mensagem e inseri-la dentro do nosso channel. Importante ressaltar que a informação é transmitida na direção da seta. Neste caso a mensagem está sendo inserida no channel out <- message. Também limitamos o nosso for para que sejam enviados apenas 5 mensagens no channel.

Agora precisamos receber a informação enviada na função main:

package main

import (
	"fmt"
	"strconv"
	"time"
)

func getMessages(hashtag string, socialMedia string, out chan string) {
	for i := 1; i < 6; i++ {
		time.Sleep(time.Second)
		var message = "The last message that we got from " + socialMedia + " on #" + hashtag + ". Number " + strconv.Itoa(i)
		out <- message
	}
}

func main() {
	ch := make(chan string)
	go getMessages("cats", "twitter", ch)
	message := <- ch
	fmt.Println(message)
}

Para evitarmos um problema de deadlock também removemos a chamada a função getMessages sem a goroutine.

A saída será a seguinte:

The last message that we got from twitter on #cats. Number 1

Pode-se notar que perdemos o efeito do for dentro da função getMessages, afinal só temos uma impressão da mensagem no console. Isso acontece devido ao channel ter sido lido apenas uma vez e a execução ter parado. Vamos manter a leitura do channel ativa durante a execução da função main usando um for loop:

package main

import (
	"fmt"
	"strconv"
	"time"
)

func getMessages(hashtag string, socialMedia string, out chan string) {
	for i := 1; i < 6; i++ {
		time.Sleep(time.Second)
		var message = "The last message that we got from " + socialMedia + " on #" + hashtag + ". Number " + strconv.Itoa(i)
		out <- message
	}
}

func main() {
	ch := make(chan string)
	go getMessages("cats", "twitter", ch)
	for {
		message := <-ch
		fmt.Println(message)
	}
}

Agora temos 2 fors. O da função getMessages que envia a mensagem 5 vezes no channel out e a da função main que é nada mais que um while para continuar lendo as mensagens conforme elas são enviadas.

Agora teremos a saída:

The last message that we got from twitter on #cats. Number 1
The last message that we got from twitter on #cats. Number 2
The last message that we got from twitter on #cats. Number 3
The last message that we got from twitter on #cats. Number 4
The last message that we got from twitter on #cats. Number 5
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
        main.go:21 +0xd0

O programa executou conforme esperado porém obtivemos um deadlock! Por que isso aconteceu?

O for loop da função main tentou continuar lendo o channel após ele ser fechado e isso fere uma das regras quando estamos trabalhando com essa funcionalidade. É importante lembrar das seguintes regras:

  • Channels só devem ser fechados do lado do emissor.
  • O receptor sempre sabe quando um channel está fechado, porém o emissor não.
  • Se você fechar um channel enquanto outra goroutine está tentando enviar dados para o channel, a runtime irá crashar.

Para resolver esse problema podemos fechar manualmente o channel.

package main

import (
	"fmt"
	"strconv"
	"time"
)

func getMessages(hashtag string, socialMedia string, out chan string) {
	for i := 1; i < 6; i++ {
		time.Sleep(time.Second)
		var message = "The last message that we got from " + socialMedia + " on #" + hashtag + ". Number " + strconv.Itoa(i)
		out <- message
	}
	close(out)
}

func main() {
	ch := make(chan string)
	go getMessages("cats", "twitter", ch)
	for {
		message, ok := <-ch
		if !ok {
			break
		}
		fmt.Println(message)
	}
}

Agora estamos fechando o channel manualmente com a função close. Quando a usamos o channel retorna uma outra variável que nos informa se o ele está fechado, atribuimos aqui o nome a ela de ok. Também inserimos um checagem no for loop da função main para parar o loop caso ele esteja fechado.

Vamos executar novamente:

The last message that we got from twitter on #cats. Number 1
The last message that we got from twitter on #cats. Number 2
The last message that we got from twitter on #cats. Number 3
The last message that we got from twitter on #cats. Number 4
The last message that we got from twitter on #cats. Number 5

Tudo certo! Agora não temos mais deadlock. Podemos ainda simplificar fazendo o uso da range keyword no nosso for da função main:

package main

import (
	"fmt"
	"strconv"
	"time"
)

func getMessages(hashtag string, socialMedia string, out chan string) {
	for i := 1; i < 6; i++ {
		time.Sleep(time.Second)
		var message = "The last message that we got from " + socialMedia + " on #" + hashtag + ". Number " + strconv.Itoa(i)
		out <- message
	}
	close(out)
}

func main() {
	ch := make(chan string)
	go getMessages("cats", "twitter", ch)
	for message := range ch {
		fmt.Println(message)
	}
}

Temos um código mais sucinto e o mesmo resultado quando executamos:

The last message that we got from twitter on #cats. Number 1
The last message that we got from twitter on #cats. Number 2
The last message that we got from twitter on #cats. Number 3
The last message that we got from twitter on #cats. Number 4
The last message that we got from twitter on #cats. Number 5

Você deve estar sentindo falta de quando removemos a nossa outra chamada a getMessages. No momento estamos recuperando apenas as informações do twitter, quando na verdade gostaríamos do twitter e do facebook. Aqui entra o conceito do select, mas vamos deixar ele para um próximo texto.

Essa é apenas uma introdução ao mundo de concorrência com Go. Trabalhamos aqui somente com casos hipotéticos, afinal não há uma conexão com as APIs reais das redes sociais o que faria tudo ficar mais interessante. Em um caso real nosso programa receberia essas mensagens e realizaria alguma ação com cada mensagem recebida.

Espero que tenham gostado do artigo, até a próxima! :)