quinta-feira, 4 de agosto de 2011

Análise da linguagem Go para programação paralela

1 comentários
 
Esse texto foi um trabalho acadêmico que escrevi no início de 2010, como uma referência básica sobre o uso da linguagem de programação Go (http://golang.org/) para programação paralela. Ele explica basicamente como se preparar um ambiente para desenvolvimento utilizando-se a linguagem Go e analisa superficialmente os mecanismos existentes para programação paralela.

1. Introdução
No ano passado o Google anunciou sua própria linguagem de programação, a Go. Segundo seu lançamento a linguagem foi lançada partindo da frustração das linguagens de programação atuais, que acabavam onerando segurança ou desempenho. Buscou-se então uma linguagem que tivesse a facilidade de uma linguagem interpretada com a eficiência de uma linguagem compilada, nisso nasceu a Go. Uma das características bastante faladas é quanto à programação concorrente, mencionando que a linguagem torna muito mais fácil e seguro a construção de programas concorrentes, nesse trabalho irei verificar se isso é realmente verdade.
Cabe ressaltar que nesse trabalho não será abordado o processo de programação em Go como um todo, ou seja, será assumido que o leitor tem conhecimentos sobre as funções básicas da linguagem, além disso, em alguns momentos é feito um paralelo com as POSIX threads, portanto é importante também que o leitor tenha conhecimento no assunto.

2. Instruções de instalação e uso
Primeiramente, seguem informações básicas do ambiente inicial, o sistema operacional utilizado foi o GNU/Linux Debian 5.0.4 64 bits (Kernel 2.6.26-2-amd64). Alguns programas são necessários durante a compilação Go, para verificar a existência deles, foram executados os seguintes comandos:

$ which gcc
$ which make
$ which bison
$ which python
$ which hg #Mercurial

Para todos os pacotes deve ter retornado o path do binário solicitado, caso contrário, deve ser instalado utilizando-se a ferramenta de gerenciamento de pacotes da distribuição, no caso do Debian, o apt-get. Dos programas requeridos, o mais incomum é o Mercurial que é um programa de controle de versão, assim como o cvs ou o Subversion.
Após assegurar a presença dos requisitos, é necessário definir algumas variáveis de ambiente, indicando o sistema operacional e a arquitetura utilizada, bem como o diretório em que será baixado o compilador, no meu caso, foram utilizados os seguintes comandos:

$ export GOOS=Linux
$ export GOARCH=amd64
$ export GOROOT=$HOME/go

Em seguida se deve efetuar o download do código fonte do Go utilizando-se o Mercurial:

$ hg clone -r release https://go.googlecode.com/hg/ $GOROOT

Deve-se criar o diretório para abrigar os binários:

$ mkdir ~/bin

Nesse ponto estamos aptos a invocar o compilador para gerar os binários da Go, no entanto, ao analisar os scripts de compilação, identifica-se que eles esperam encontrar o programa gmake, ao invés do make, para evitar ter de alterar os scripts, gera-se um link simbólico fazendo com que ao se invocar o gmake, na verdade seja executado o make:

# ln -s /usr/bin/make /usr/bin/gmake

Agora sim, o ambiente está pronto, pode-se chamar o script de compilação:

$ cd $GOROOT/src 
$ ./all.bash

Após o processo de compilação, se tudo correr bem, deve ser exibida a seguinte mensagem:

N known bugs; 0 unexpected bugs

Sendo N igual a zero ou um número natural que varia de release para release. Após isso, basta ajustar as variáveis de ambiente ou definir links a seu gosto, pois o compilador já está pronto no diretório bin criado na Tabela 4.
Como a arquitetura utilizada é 64 bits, foram gerados os binários compatíveis:
  • 6g : compilador
  • 6l : linker
  • Outros, menos importantes agora.
O 6 no inicio dos binários indicam que eles foram compilados para 64-bits (amd64, x86_64). Se fossem compilados para 32-bits (x86), os binários seriam 8g e 8l.

3. Goroutines
Goroutines são, a grosso modo, threads. Basicamente são funções que são executadas em paralelo com outras goroutines no mesmo espaço de endereçamento. O processo de implementação é relativamente simples: implementa-se a função, normalmente, e no momento de chamá-la, se utiliza a palavra reservada go, isso faz com que a função seja executada em paralelo com a main ou com outras goroutines que estão sendo executadas ao mesmo tempo.
Um dos mecanismos não encontrados diretamente é a função pthread_join, que é utilizada na sincronização entre threads, fazendo com que uma thread espere por outra, na ausência desse sistema, sugere-se a utilização de um canal binário (será abordado em seguida) para bloquear uma goroutine até que outra sinalize. Isso permite com que o programador defina que o programa só termine quando todas as threads forem concluídas.

4. Mecanismos de programação concorrente
A linguagem de programação Go teve desde sua criação a idéia de que a programação concorrente é complicada devido a complexidade no design de softwares concorrentes, como, por exemplo, as POSIX Threads, além de que o desenvolvimento utilizando programação concorrente enfatiza exageradamente o baixo-nível, com mutexes, variáveis de condição e barreiras de memória. Por isso, a Go utiliza mecanismos de mais alto-nível, baseados, principalmente, nos Communicating Sequential Processes de C.A.R. Hoare. Considerando isso, o mecanismo de programação concorrente mais utilizado pela Go considera a utilização de canais para a comunicação entre Goroutines.
No entanto, isso não impede que existam mecanismos tradicionais (“de baixo-nível”) para sincronização, por exemplo, a Go implementa mutexes, por meio da package sync. Mais detalhes serão passados em cada mecanismo, individualmente.

4.1. Canais
O mecanismo de alto nível utilizado pela Go são os canais, pode se dizer que canais são buffers compartilhados que garantem a comunicação entre processos com sincronização, garantindo que duas goroutines estão em um estado conhecido.
Existem dois tipos de canais, cada um podendo receber dados de um determinado tipo (int, char, bool, etc), unbuffered e buffered. O unbuffered só permite a inserção de novos termos assim que o anterior for removido, ou seja, não possui buffer, já o buffered possui um espaço de buffer determinado, no exemplo abaixo o buffer possui nove posições.

var cj = make(chan int,9) // com buffer de 9 posições de inteiros
var ce = make(chan bool) // unbuffered, de variáveis booleanas

Por exemplo, no caso citado em aula do produtor-consumidor, a utilização de um canal compartilhado entre os processos garante que algumas situações sejam devidamente cobertas, são elas:
  • Consumidor com buffer vazio: a goroutine fica bloqueada até que seja inserido algum elemento no canal;
  • Produtor com buffer cheio: a goroutine fica bloqueada até que seja consumido algum elemento do canal.
Os demais casos não são excepcionais e as rotinas permanecem sincronizadas naturalmente.
Além disso, os canais possuem comportamento FIFO, ou seja, os dados são lidos na mesma ordem que foram inseridos.
Os canais possuem dois operadores básicos, para inserção e remoção de dados no canal. Ambos são representados pelo operador <- com a diferença de que o operador de inserção é binário e o de remoção é unário. Abaixo exemplos de utilização:

cj <- x // insere o elemento x no buffer cj
<-cj // remove o primeiro elemento do buffer ch

Para ilustrar o comportamento dos canais, foi codificado um exemplo, transcrito abaixo, com alguns comentários, os trechos em vermelho são operações relativas ao controle da seção crítica:

package main
import (
     "fmt"
     "time"
     "rand"
)
var end = make(chan bool) /* faz as vezes da pthread_join */
var buffer = make(chan int,9) /* buffer compartilhado */
var cont int
func producer() {
     for {
          time.Sleep(rand.Int63()%5e9) /* espera de até 5 segundos */
          x := rand.Int()%100 /* valor a ser inserido no buffer */
          fmt.Println(cont, "prod....",x)
          cont++
          buffer <- x
     }
     end <- true
}
func consumer() {
     for {
          time.Sleep(rand.Int63()%5e9)
          x := <-buffer
          fmt.Println(cont, "cons....", x);
          cont--
     }
     end <- true
}
func main() {
     go producer();
     go consumer();
     <-end
}

Logo, podemos concluir que os canais são os mecanismos mais usuais para garantir a seção crítica em Go, além disso, eles implementam uma série de mecanismos de comunicação entre funções.

4.2. Mutexes
Um mecanismo clássico implementado na Go são os mutexes, apesar de seu uso não ser recomendado, sendo substituídos por canais, podemos verificar a sua existência. Os mutexes são definidas na package sync e são divididos em dois tipos: sync.Mutex e sync.RWMutex, ambos possuem primitivas de lock e unlock, com a diferença de que o último prevê primitivas separadas para lock e unlock de leitura e gravação.
Conforme citado anteriormente, o uso de mutexes não é recomendado, tendo em vista que normalmente o uso de canais prevê os bloqueios/desbloqueios necessários em uma determinada operação.

4.3. Semáforos
Existem algumas primitivas de semáforos implementadas no package runtime, Semacquire e Semrelease, no entanto não consegui encontrar documentação explícita da declaração de semáforos, ao que tudo indica, esses métodos são utilizados na implementação dos canais, mas creio ser possível, de alguma maneira declarar um semáforo.

4.4. Monitores
Não foram encontradas informações sobre a presença de monitores na Go.

5. Conclusões
 Foi possível verificar que a Go prevê um mecanismo diferenciado para comunicação entre rotinas (“threads”), esse mecanismo facilita bastante a programação tendo em vista que ele remove do programador uma série de responsabilidades e validações. No exemplo do produtor-consumidor pode-se verificar que as operações de controle foram minimizadas pois toda a validação é definida na implementação dos canais.


One Response so far.

  1. Anônimo says:

    Gostei muito da sua matéria, esta de parabéns!

Leave a Reply