Golang – 10. Goroutines – Canais

Este artigo é a parte 10 de 11 na série Golang

Canais os meios através dos quais as goroutines se comunicam umas com as outras, trocando dados tipados. Neste artigo será apresentado como os canais funcionam e como empregá-los.

Canal

Um canal é o meio pelo qual se pode enviar e receber dados tipados para as goroutines. Sua declaração pode ser feita através da palavra chave var seguida do nome do canal, da palavra chave chan e o tipo do canal, mas neste caso uma posterior inicialização deve ser feita com a palavra chave make

O código a seguir ilustra a inicialização do canal e como este á percebido pelo Go:

Exemplo 01: iniciando um canal
package main

import "fmt"

func main() {
	var ch chan int
	fmt.Printf("ch é nil? %v\nTipo: (%T)\nValor: %v\n\n", ch == nil, ch, ch)

	ch = make(chan int)
	fmt.Printf("ch é nil? %v\nTipo: (%T)\nValor: %v", ch == nil, ch, ch)
}

A saída deste código é apresentada a seguir:

ch é nil? true
Tipo: (chan int)
Valor: <nil>

ch é nil? false
Tipo: (chan int)
Valor: 0xc00001c1e0

A linha 6 cria o canal ch para transportar inteiros, que neste instante está vazio, valor nil, como mostra a saída da linha 7. Na linha 9 o canal é instanciado passando a não conter mais um valor nil e sim um ponteiro para um inteiro, como mostra a saída da linha 10.

Em geral a declaração de um canal é feita junto com a sua inicialização com o operador “:=“, em uma única linha como segue:

ch := make(chan int)

Como visto na saída do código anterior os canais possuem como valor um ponteiro para o tipo associado, o que simplifica a passagem de um canal de escrita para uma função, já que como um ponteiro este pode ser escrito e lido diretamente no bloco da função.

Escrevendo e Lendo em um Canal

Para escrever e ler um canal se usa o operador “<-” de forma semelhante ao operador de associação, ou seja:

ch <- v

sendo ch um canal e v um valor, esta linha escreve o valor de v no canal ch. De forma semelhante

v <-ch

a variável v recebe o valor do canal ch. É possível declarar a variável v com o operador “:=” e receber o valor do canal na mesma linha como segue:

v := <-ch

Ao passar um canal como parâmetro para uma função, este pode ser passado como:

  • (ch <-chan int) – para indicar que ch será um canal de leitura na função;
  • (ch chan<- int) – para indicar que ch será um canal de escrita na função;
  • (ch chan int) – para indicar que ch será um canal de leitura ou de escrita (bidirecional) na função.

Em geral um canal é unidirecional, suportando apenas operações de escrita ou de leitura pela função. Embora seja possível implementar um canal bidirecional geralmente não é o mais adequado.

Canal sem Buffer

Os canais criados nos exemplos acima são canais unbuffered, ou seja sem buffer. Cada escrita em um canal unbuffer faz com que a goroutine pause até que outra goroutine leia este conteúdo do canal. Da mesma forma a uma leitura de um canal unbuffer aberto faz com que a goroutine pause até que outra goroutine escreva no canal. Isto significa que não se pode escrever ou ler em um canal sem que duas goroutine estejam em execução simultaneamente.

Observe que a função main é a goroutine principal de todas as goroutines em um código Go.

O código a seguir mostra um canal unbuffer operando entre a goroutine main() e a função print()

Exemplo 02: canal sem buffer
package main

import (
	"fmt"
)

func print(id int, c <-chan int) {
	fmt.Printf("(%d) channel: %v\n", id, <-c)
}

func main() {
	ch := make(chan int)

	go print(1, ch)
	go print(2, ch)
	go print(3, ch)

	ch <- 0
	ch <- 1
	ch <- 2
}

A função print(), linhas 7 à 9, será chamada como uma goroutine três vezes seguidas, linhas 14 à 16. Para deixar mais claro o que está acontecendo, em tempo de execução, a função print possui dois parâmetros: uma id para identificar a ordem de chamada da função; e o canal c, como apenas leitura, por onde ela recebe as entradas enviadas pela função main, nas linhas 18 à 20.

Executando este código a saída é algo como:

(3) channel: 0
(1) channel: 1
(2) channel: 2

Quando este código é executado são criadas três threads para as goroutines, linhas 14 à 16, as quais param a execução e ficam aguardando que algo seja escrito no canal c, linha 8. Como o canal ch, na função main, inicia a ser escrito na linha 18, a thread em execução na sequência é a que primeiro lê o conteúdo do canal ch, neste caso foi a terceira goroutine iniciada.

A ordem em que as goroutines são executadas não é, necessariamente, a mesma que elas foram invocadas na função main(). Isto é algo esperado em código executado em concorrência, sua execução ocorre de acordo com a disponibilidade do sistema, sendo adiada até o sistema disponibilize os recursos necessários e que exista algo a processar.

Após a segunda escrita no canal ch, linha 19, a primeira goroutine responde e por último a segunda responde à última escrita ao canal ch, linha 20.

Em tempo de execução três goroutines são inicializadas criando três threads no sistema que aguardam a escrita no canal ch. De acordo com a disponibilidade do sistema as goroutines vão sendo atendidas, o que permite que o programa adiante a escrita no canal ch da linha 18, para a 19 e por fim para a 20.

Canal com Buffer

Canal com buffer é declarado com o make, adicionando uma vírgula e o tamanho do buffer ao final da declaração, com abaixo:

ch := make(chan int, 20)

Esta linha cria um canal de capacidade de 20 elementos inteiros, em ch. Canais com buffer possuem um número limitado de escritas antes de bloquear a execução. Neste caso, se o buffer estiver cheio uma escrita subsequente pausa o canal até que alguma goroutine leia algum elementos do canal. De forma semelhante, se o buffer estiver vazio o canal pausa até que alguma goroutine escreva no canal.

As funções internas len e cap, quando aplicadas a um canal, retornam a sua capacidade e a quantidade de elementos no canal.

O código a seguir, como está escrito, emprega um canal unbuffered, a declaração com buffer está comentada na linha 19 neste primeiro momento.

Exemplo 03: canal com buffer
package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func print(ch <-chan int) {
	defer wg.Done()
	for i := 0; i < 6; i++ {
		fmt.Printf("Print %02d (len: %d, loop: %d)\n", <-ch, len(ch), i)
	}
}

func main() {
	ch := make(chan int)
	// ch := make(chan int, 3)

	wg.Add(1)
	go print(ch)

	for i := 0; i < 6; i++ {
		value := i * 10
		fmt.Printf("Write %02d\n", value)
		ch <- value
	}
	fmt.Println("Wait...")
	wg.Wait()
}

Execute o código sem buffer o que deve gerar a saída como:

a: Write 00
b: Print 00 (len: 0, loop: 0)
c: Write 10 
d: Write 20
e: Print 10 (len: 0, loop: 1)
f: Print 20 (len: 0, loop: 2)
g: Write 30
h: Write 40
i: Print 30 (len: 0, loop: 3)
j: Print 40 (len: 0, loop: 4)
k: Write 50
l: Wait...
m: Print 50 (len: 0, loop: 5)

As linhas de saída foram manualmente nomeadas com as letras de “a” à “m” para facilitar o apontamento das saídas impressas. Antes de iniciar a discussão do código é importante observar que um canal unbuffered sempre possui comprimento 0, mesmo que a função len fosse chamada antes do canal ser lido na linha 13 o resultado ainda seria zero.

Quando o código inicia sua execução a goroutine, iniciada na linha 22, pausa a sua execução aguardando que o canal ch seja escrito, o que ocorre apenas no laço for da linha 24 à 28.

Este laço escreve o valor do contador i*10 por seis vezes no canal ch, abastecendo o canal para ser lido pela goroutine. Como o “Write 00“, linha a da saída, é impresso antes de escrever no canal ch, ele acaba sendo escrito antes na tela. Desta forma a ordem de execução das linhas seriam:

  1. linha 26, imprime o “Write 00“;
  2. linha 27, escreve um 0 no canal ch;
  3. linha 13, na goroutine, que estava aguardando a escrita no canal imprime a saída b, “Print 00 (len: 0, loop: 0)”;
  4. o laço for prossegue a execução parando a goroutine novamente na linha 13, aguardando uma nova escrita no canal;
  5. na sequência a linha c é impressa, “Write 10“, e um 10 é escrito no canal ch e enquanto este é lido na goroutine e preparado a impressão, o laço da função main executa uma segunda impressão, “Write 20” a linha d, e escreve um 20 no canal ch.
    Isto gera as duas impressões seguidas “Write 10” e “Write 20” que gera a impressão de que o 10 e o 20 foram escritos num canal unbuffered, o que não ocorre. Esta dupla impressão ocorre porque a escrita no canal ch corre na linha 27, antes da escrita na tela das mensagens “Write xx“.
  6. as linhas de saída e e f ocorre por um efeito semelhante ao descrito acima, mas agora na linha de impressão da goroutine, linha 13. Enquanto a função main aguarda que o canal seja liberado para escrever o 20 a mensagem “Print 10 (len: 0, loop: 1)” é impressa na goroutine. Isto libera o canal para a escrita do 20 e a goroutine executa o segundo laço e imprime “Print 20 (len: 0, loop: 2)“;
  7. na sequência os efeitos dos itens 5 e 6 se repetem;
  8. por fim um 50 é escrito no canal, antes de ser escrito a saída k, e um “Wait...” é impresso, indicando que o programa está aguardando um wg.Done(), para encerrar, o que ocorre na execução do último laço da goroutine;
  9. isto gerar a impressão da linha m e o programa termina.

Agora veja como trabalha o código com a buffer habilitado. Para isto comente e linha 18 e remova o comentário da linha 19. Com isto o canal ch terá um buffer para três inteiros. Execute novamente o código e veja a saída seguinte:

Write 00
Write 10
Write 20
Write 30
Print 00 (len: 3, loop: 0)
Print 10 (len: 2, loop: 1)
Print 20 (len: 1, loop: 2)
Print 30 (len: 0, loop: 3)
Write 40
Write 50
Wait...
Print 40 (len: 1, loop: 4)
Print 50 (len: 0, loop: 5)

Observe que correm quatro escritas “Write” (00, 10, 20 e 30) antes que a goroutine consiga imprimir o primeiro “Print ...“. Da mesma forma que descrita anteriormente, o “Write 30” é escrito e o programa pausa na linha 27 aguardando que algum dado seja lido do canal ch, para que o “30” possa ser escrito. Isto ocorre quando o “00” é lido do canal, na função print, o que encadeia a impressão das quatro linhas “Print ...” pela goroutine, “Print 00” a “Print 30“.

As saídas len() mostram o canal sendo esvaziado, de 3 elementos até 0, vazio. A função len() ocorre após a leitura do canal, observe a linha 13:

fmt.Printf("Print %02d (len: %d, loop: %d)\n", <-ch, len(ch), i)

Enquanto o canal é lido pelo “<-ch“, na linha 13, o elemento 30 é escrito na linha 27. Por isto que o len(ch) retorna um comprimento de 3 e não 2.

Para deixar mais claro experimento trocar a linha 13 pela linha abaixo:

fmt.Printf("Print %02[2]d (len: %[1]d/%[3]d, loop: %[4]d)\n", len(ch), <-ch, len(ch), i)

Os índices [1] a [4] se referem aos elementos len(ch), <-ch, len(ch) e i, passados ao fmt.Printf para alterar a ordem da escrita dos elementos. Com esta mudança o comprimento do canal é lido antes e depois do canal ser lido. Execute novamente e observe a saída:

Write 00
Write 10
Write 20
Write 30
Print 00 (len: 3/3, loop: 0)
Print 10 (len: 3/2, loop: 1)
Print 20 (len: 2/1, loop: 2)
Print 30 (len: 1/0, loop: 3)
Write 40
Write 50
Wait...
Print 40 (len: 0/1, loop: 4)
Print 50 (len: 1/0, loop: 5)

No primeiro “Print” o comprimento do canal é 3 antes e depois da leitura do canal, “len: 3/3“. Isto aconteceu porque enquanto no canal é lido o “00“, linha 13, um “30“, linha 27, é escrito no canal dando a impressão que o número de elementos no canal permaneceu constante em 3.

Em geral canais unbuffered atendem a maioria das demandas, no entanto canais com buffer tem o seu lugar e podem ser bem convenientes quando se sabe quantos elementos se podem acumular em um buffer antes de pausar a execução do código.

Laço for-range e fechando o Canal

Um laço for-range pode ser empregado para ler todos os elementos passados por um canal aberto. O laço se repete até que um close(channel) seja executado fechando o canal.

No código abaixo um for-range é empregado para alterar o código anterior e ler os elementos do canal. Um close(ch) foi adicionado na linha 29 para fechar o canal e terminar o for-range.

Exemplo 04: for-range
package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func print(ch <-chan int) {
	defer wg.Done()
	for c := range ch {
		fmt.Printf("Print %02d (len: %d)\n", c, len(ch))
	}
}

func main() {
	// ch := make(chan int)
	ch := make(chan int, 3)

	wg.Add(1)
	go print(ch)

	for i := 0; i < 6; i++ {
		value := i * 10
		fmt.Printf("Write %02d\n", value)
		ch <- value
	}
	close(ch)
	fmt.Println("Wait...")
	wg.Wait()
}

Agora a leitura do canal é feita no início do laço for-range e por isto não faz mais sentido a alteração da linha 13 feita anteriormente. Por isto esta linha foi restaurada ao original para uma impressão mais limpa.

Executando o código a saída será:

Write 00
Write 10
Write 20
Write 30
Print 00 (len: 3)
Print 10 (len: 2)
Print 20 (len: 1)
Print 30 (len: 0)
Write 40
Write 50
Wait...
Print 40 (len: 1)
Print 50 (len: 0)

O efeito na saída foi exatamente o mesmo do código anterior.

Um canal fechado não pode mais ser escrito ou mesmo fechado novamente ou gera um erro de panic. No entanto um canal fechado pode ser lido e sempre retorna um zero. Isto levanta a questão:

Como saber se o retorno “0” é um retorno real ou o retorno de um canal fechado?

Quando se lê um canal ele também retorna um booleano que indica se o canal está aberto (true) ou fechado (false). Este booleano de status pode ser lido adicionando uma variável separada por vírgula na leitura do canal:

value, ok := <- ch

A variável ok vai retornar o status do canal, aberto ou fechado, e quando omitido apenas o valor do canal é lido.

Não é a forma mais elegante, mas a função print, do código anterior, pode ser substituída pelo código abaixo, para empregar a leitura do status do canal em um laço for infinito:

...
func print(ch <-chan int) {
	defer wg.Done()
	for {
		c, ok := <-ch
		if !ok {
			break
		}
		fmt.Printf("Print %02d (len: %d)\n", c, len(ch))
	}
}
...

Este código vai gerar a mesma saída do código anterior, no entanto agora o laço termina quando a variável ok retornar um false, acionando o break da linha 7, que termina o laço.

Select – case

A sentença select do Go é parecida com a sentença switch com respeito a sua sintaxe:

select {
    case <cond 1>:
        // cond 1 ...
    case <cond 2>:
        // cond 2 ...
    ...
    default:
        // default:
}

No entanto as semelhanças não vão muito além. No bloco select o case deve ser sempre sobre um canal recebendo ou escrevendo algo ou gera um erro de InvalidSelectCase.

Outra diferença fundamental é sobre o comportamento, num switch a execução passa uma única vez pelos diferentes cases saindo na primeira condição satisfeita ou alcançando o final do bloco, ou seja, um bloco switch não passa de uma forma elegante de fazer uma sequencia de if-else. Já num bloco select a execução do código é bloqueada até que um dos seus cases esteja pronto, recebendo ou escrevendo algo em um canal, para então sair do bloco.

No caso do uso da condição default o select acaba por funcionar de forma muito semelhante ao switch, e geralmente seu emprego não é algo desejado.

Para ilustrar uma aplicação, suponha que se deseja acessar a um banco de dados distribuído em cinco servidores e somente o primeiro a atender será aberto. O código a seguir implementa a ideia:

Exemplo 05: select-case
package main

import (
	"fmt"
	"math/rand"
	"time"
)

func connectDBase(server string, ch chan<- string) {
	fmt.Printf("Try connection in %s...\n", server)
	time.Sleep(time.Microsecond * (20 + time.Duration(rand.Int63n(10))))
	ch <- fmt.Sprintf("%s connected!", server)
}

func main() {
	serverList := []string{"Newton", "Einstein", "Plank", "Fermi"}
	var c []chan string

	rand.Seed(time.Now().UnixNano())

	for i, server := range serverList {
		c = append(c, make(chan string))
		go connectDBase(server, c[i])
	}

	select {
	case s := <-c[0]:
		fmt.Println(s)
	case s := <-c[1]:
		fmt.Println(s)
	case s := <-c[2]:
		fmt.Println(s)
	case s := <-c[3]:
		fmt.Println(s)
	}
}

A função connectDBase, linhas 9 à 13, simula a conexão ao banco de dados retornando no canal ch uma mensagem da conexão realizada, linha 12. O time.Sleep, linha 11, apenas é para simular o tempo de resposta do servidor.

Na linha 16 é declarada uma lista de nomes dos servidores e na linha 17 uma lista de canais para responder aos servidores. O laço das linhas 21 à 24 adiciona um novo canal na lista c, linha 22, e em seguida chama a goroutine para fazer a conexão, linha 23.

Por fim um select, linhas 26 à 35, aguarda até que algum servidor responda e termine o código. Uma possível saída deste código é apresentada a seguir:

Try connection in Fermi...
Try connection in Newton...
Try connection in Einstein...
Try connection in Plank...
Einstein connected!

Mesmo que diversos servidores respondam simultaneamente apenas um dos case do select será acionado. No caso de acionamento simultâneo apenas um dos cases será aleatoriamente acionado.

Goroutines e Boas Práticas

Embora o código anterior sirva para ilustrar o funcionamento do select ele apresenta um problema grave o qual se deve ter atenção ao empregar concorrência e paralelismos em seu código: as goroutines que não responderam a tempo continuam rodando mesmo após um dos canais ter respondido. Como se trata de um exemplo todas as goroutines são finalizadas ao atingir o final da função main. No entanto em um programa funcional após conectar ao banco de dados alguns processos devem ser realizados, o que não terminaria o programa de imediato.

Da forma que o código está desenhado as goroutines vão continuar rodando consumindo recursos de memória e processamento da máquina desnecessariamente, o que pode deixar o aplicativo mais lento e ineficiente.

Por isto uma boa prática é sempre gerenciar bem o emprego das goroutines, garantindo que estas sejam terminadas sempre que não forem mais necessárias, e que os canais envolvidos tenham sidos exauridos (com todos os elementos devidamente descarregados) e fechados.

Canal Done

É de praxe criar um canal done para comunicar às goroutines que o seu processamento não é mais necessário e desabilitá-la. Em geral este canal é do tipo struct vazia, mas poderia ser de qualquer outro tipo.

Antes de corrigir o código connectdb.go um problema mais simples será implementado para ilustrar o uso do done. O código a seguir emprega uma goroutine como um gerador da série de Fibonacci e um done é usado para parar a execução deste gerador.

Exemplo 06: canal done
package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

var wg sync.WaitGroup

func fibo(ch chan<- int, done <-chan struct{}) {
	defer wg.Done()

	a, b := 0, 1
	for {
		c := a + b
		a, b = b, c
		select {
		case ch <- c:
		case <-done:
			fmt.Println("\nFibo stoped...")
			close(ch)
			return
		}
	}
}

func main() {
	ch := make(chan int)
	done := make(chan struct{})

	wg.Add(1)
	go fibo(ch, done)

	rand.Seed(time.Now().UnixMicro())
	max := 5 + rand.Intn(15)

	fmt.Printf("\nFirst %d Fibonacci numbers:\n0 1", max+2)
	for i := 0; i < max; i++ {
		fb := <-ch
		fmt.Print(" ", fb)
	}

	done <- struct{}{}
	wg.Wait()
}

A função para gerar a série de Fibonacci é definida na função fibo, linhas 12 à 27. Cada novo número da série gerado é passado para o canal ch na linha 20, do select-case. Juntamente o canal done é verificado para saber se o sinal de cancelamento foi passado, case da linha 21. Portanto o select aguarda até que algum dos cases tenham sucesso: alguém lê o canal ch ou um struct{} vazia é passada no done. Quando o done é passado o canal ché fechado e a função fibo é encerrada.

A função main cria dois canais sendo o ch para receber os elementos da série de Fibonacci e o done, para finalizar a função fibo. Na linha 34 a função fibo é executada como uma goroutine, acionando o gerador de Fibonacci.

As linhas 36 e 37 iniciam o gerador de números aleatórios e o número máximo de elementos a serem gerados como sendo algo entre 5 e 15 elementos. Os dois primeiros elementos da série (0 e 1) são impressos na linha 39.

No laço for os elementos da série de Fibonacci, gerados pela goroutine fibo, são lidos do canal ch, linha 41, e impressos na sequência.

Ao atingir o número de elementos o laço for termina, o done recebe um struct{}, linha 45, e é acionado o final da execução da função fibo, com a impressão da mensagem “Fibo stoped...“, linha 22.

O WaitGroup, linhas 10, 13, 33 e 46, foram inseridos aqui apenas para dar tempo para que a mensagem final “Fibo stoped...” possa ser impressa antes que a função main termine.

A saída do código acima é apresentado a seguir:

First 21 Fibonacci numbers:
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181 6765
Fibo stoped...

No próximo código um canal done é empregado para aprimorar o código do connectdb.go, do exemplo 05.

Exemplo 07: canal done para aprimorar código connectdb.go
package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

var wg sync.WaitGroup

func connectDBase(server string, ch chan<- string, done <-chan struct{}) {
	defer wg.Done()
	fmt.Printf("Try connection in %s...\n", server)
	time.Sleep(time.Microsecond * (20 + time.Duration(rand.Int63n(10))))
	select {
	case ch <- server:
		return
	case <-done:
		fmt.Printf("Server %s close!\n", server)
		return
	}
}

func main() {
	serverList := []string{"Newton", "Einstein", "Plank", "Fermi"}
	ch := make(chan string)
	done := make(chan struct{}, len(serverList))

	rand.Seed(time.Now().UnixNano())

	for _, s := range serverList {
		wg.Add(1)
		go connectDBase(s, ch, done)
	}

	doneAll := func() {
		for i := 0; i < len(serverList); i++ {
			done <- struct{}{}
		}
	}

	server := <-ch
	doneAll()

	fmt.Printf("Server %s CONNECTED.\n", server)
	wg.Wait()
	close(ch)
}

A primeira alteração foi no select-case que foi migrado para a função connectDBase, linhas 16 à 22, onde fica aguardando que o canal ch seja lido por outra goroutine, neste caso a função main, ou que o done seja escrita para acionar o final da função.

Como no código anterior um WaitGroup (linhas 10, 13, 33 e 47) foi adicionado para garantir que as chamadas às goroutines tenham retornado.

Na função main o slice de canais foi reduzido para apenas um canal ch, linha 27, visto que apenas um servidor deve responder a tempo os demais não necessitam mais escrever no canal. Apenas se deve tomar cuidado de não fechar o canal ch até que as goroutines tenham retornado, o que é feito posicionando o close(ch) após o wg.Wait(), linha 48. De outra forma o código pode gerar um erro de “panic: close of closed channel“, por tentar escrever em um canal fechado.

O canal done foi criado com um buffer do comprimento igual ao número de servidores, elementos na lista serverList, para que cada chamada das goroutines, o que ocorre no for das linhas 32 à 35, possam ser cancelados.

A função doneAll, declarada nas linhas 37 à 41, apenas carrega o buffer do canal done com uma struct vazia quando acionado.

Quando o código é executado ele irá para na linha 43 e aguardar até que algum servidor responda, passando seu nome para a variável server. Depois disto a função doneAll é chamada e desencadeia o fechamento das goroutines. Na linha 46 uma mensagem é impressa com o servidor conectado. Na linha 47 é aguardado que todas as goroutines terminem para finalmente fechar o canal ch, linha 48.

Executando este código deve ser gerado uma saída semelhente a:

Bash

alves@arabel:../connectdb2$ go run connectdb.go
Try connection in Fermi…
Try connection in Einstein…
Try connection in Plank…
Try connection in Newton…
Server Newton close!
Server Fermi close!
Server Einstein CONNECTED.
Server Plank close!

Como os Canais se Comportam

Os canais possuem comportamentos diferentes sobre tentativas de escrita, leitura ou fechamento dependente de seu status, aberto ou fechado. Mesmo um canal nil responde de forma diferenciada. Estes comportamentos são fundamentais para se compreender a resposta de um código nas diferentes situações, bem como suas mensagens de erro.

A tabela a seguir mostra as possíveis respostas de um canal buffered, unbuffered e nil com respeito a solicitações de leitura, escrita e fechamento.

CanalLeituraEscritaFechamento
Unbuffered abertopausa até algo ser escritopausa até algo ser lidofecha normalmente
Unbuffered fechadoretorna valor zero (use ", ok" para checar se está fechado).PANICPANIC
Buffered abertopausa se o buffer estiver vaziopausa se o buffer estiver cheiofecha normalmente, mas mantém os valores em buffer
Buffered fechadoretorna os valores restantes no buffer. Se o buffer estiver vazio retorna zero (use ", ok" para checar se está fechado).PANICPANIC
Nilpara eternamentepara eternamentePANIC

Em especial observe o comportamento de um canal nil quanto a escrita e leitura. Este comportamento pode ser empregado para desabilitar canais em um select-case, visto que estes não respondem a uma tentativa de escrita ou leitura.

O código a seguir mostra uma forma simples de desabilitar cases em um select-case.

Exemplo 08: Desabilitando cases em um select-case
package main

import (
	"fmt"
	"time"
)

func selectCase(ch1, ch2, ch3 chan<- int, done <-chan struct{}) {
	for {
		select {
		case ch1 <- 1:
			ch1 = nil
			continue
		case ch2 <- 2:
			ch2 = nil
			continue
		case ch3 <- 3:
			ch3 = nil
			continue
		case <-done:
			fmt.Println("Goroutine finised...")
			return
		}
		// more code here...
	}
}

func main() {
	ch1 := make(chan int)
	ch2 := make(chan int)
	ch3 := make(chan int)
	done := make(chan struct{})

	go selectCase(ch1, ch2, ch3, done)

	fmt.Printf("ch%d is nil\n", <-ch1)
	fmt.Printf("ch%d is nil\n", <-ch3)
	fmt.Printf("ch%d is nil\n", <-ch2)

	done <- struct{}{}
	time.Sleep(time.Microsecond * 100)
}

Em linhas gerais a função selectCase opera sobre os canais ch1, ch2 e ch3, desabilitando-os quando estes recebem uma escrita, já o canal done é empregado para encerrar a goroutine. Os continues, colocados aqui após fazer os canais igual a nil, são desnecessários a menos que algum código seja inserido dentro do laço for, antes ou depois do select-case.

Uma vez executado o código, no primeiro laço do for da linha 9, o select-case, linhas 10 à 23, para a execução do programa na goroutine selectCase até que algo seja lido dos canais ch1, ch2 ou ch3, ou que algo seja escrito no canal done.

A linha 36 lê o canal ch1 e Isto aciona o case da linha 11 o qual envia 1 para o canal e o desabilita fazendo-o igual a nil. Deste ponto em diante o ch1 não consegue mais ser escrito ou lido. Ainda na linha 36, uma mensagem de que o canal é nil é impressa.

Na linha 36 o mesmo se repete para o canal ch3 e depois, na linha 37, para o canal ch2. Por fim done é acionado para desabilitar a goroutine. O time.Sleep tem o único objetivo de aguarda para que a goroutine consiga desligar, embora seja desnecessário uma vez que o programa termina logo em seguida.

Considerações Finais

O uso de canais é algo fundamental códigos concorrentes e embora possam muitas vezes ser substituídos por WaitGroup, Once, Mutex, … em geral seu emprego deixa o código simples e direto. Não estendi a discussão mas tudo que o pacote sync implementa pode ser feito com canais.

Em canais está a alma do código concorrente e do emprego eficiente de máquinas com muito núcleos, portanto é muito importante a sua compreensão e domínio.

Deixe um comentário

Esse site utiliza o Akismet para reduzir spam. Aprenda como seus dados de comentários são processados.