Primitivas de Canal em Golang

GolangBeginner
Pratique Agora

Introdução

Uma das características mais atraentes da linguagem Go é o seu suporte nativo para programação concorrente. Ela possui forte suporte para paralelismo, programação concorrente e comunicação em rede, permitindo um uso mais eficiente de processadores multi-core.

Diferentemente de outras linguagens que alcançam concorrência através de memória compartilhada, Go alcança concorrência utilizando canais (channels) para comunicação entre diferentes goroutines.

Goroutines são as unidades de concorrência em Go e podem ser pensadas como threads leves (lightweight threads). Elas consomem menos recursos ao alternar entre elas em comparação com threads tradicionais.

Canais e goroutines juntos constituem os primitivos de concorrência em Go. Nesta seção, aprenderemos sobre canais, esta nova estrutura de dados.

Pontos de Conhecimento:

  • Tipos de canais
  • Criação de canais
  • Operações em canais
  • Bloqueio de canal (Channel blocking)
  • Canais unidirecionais
Este é um Lab Guiado, que fornece instruções passo a passo para ajudá-lo a aprender e praticar. Siga as instruções cuidadosamente para completar cada etapa e ganhar experiência prática. Dados históricos mostram que este é um laboratório de nível iniciante com uma taxa de conclusão de 100%. Recebeu uma taxa de avaliações positivas de 100% dos estudantes.

Visão Geral dos Canais

Canais são uma estrutura de dados especial usada principalmente para comunicação entre goroutines.

Diferentemente de outros modelos de concorrência que utilizam mutexes e funções atômicas (atomic functions) para acesso seguro a recursos, os canais permitem a sincronização através do envio e recebimento de dados entre múltiplas goroutines.

Em Go, um canal é como uma esteira transportadora ou uma fila que segue a regra Primeiro a Entrar, Primeiro a Sair (First-In-First-Out - FIFO).

Tipos e Declaração de Canais

Um canal é um tipo de referência (reference type), e seu formato de declaração é o seguinte:

var channelName chan elementType

Cada canal possui um tipo específico e só pode transportar dados do mesmo tipo. Vejamos alguns exemplos:

Crie um arquivo chamado channel.go no diretório ~/project:

cd ~/project
touch channel.go

Por exemplo, vamos declarar um canal do tipo int:

var ch chan int

O código acima declara um canal de int chamado ch.

Além disso, existem muitos outros tipos de canais comumente usados.

Em channel.go, escreva o seguinte código:

package main

import "fmt"

func main() {
    var ch1 chan int   // Declara um canal de inteiros
    var ch2 chan bool  // Declara um canal de booleanos
    var ch3 chan []int // Declara um canal que transporta fatias (slices) de inteiros
    fmt.Println(ch1, ch2, ch3)
}

O código acima simplesmente declara três tipos diferentes de canais.

Execute o programa usando o seguinte comando:

go run channel.go

A saída do programa é a seguinte:

<nil> <nil> <nil>

Inicialização de Canais

Semelhante aos mapas (maps), os canais precisam ser inicializados após serem declarados antes que possam ser usados.

A sintaxe para inicializar um canal é a seguinte:

make(chan elementType)

Em channel.go, escreva o seguinte código:

package main

import "fmt"

func main() {
    // Canal que armazena dados inteiros
    ch1 := make(chan int)

    // Canal que armazena dados booleanos
    ch2 := make(chan bool)

    // Canal que armazena dados []int
    ch3 := make(chan []int)

    fmt.Println(ch1, ch2, ch3)
}

Execute o programa usando o seguinte comando:

go run channel.go

A saída do programa é mostrada abaixo:

0xc0000b4000 0xc0000b4040 0xc0000b4080

No código acima, três tipos diferentes de canais são definidos e inicializados.

Ao examinar a saída, podemos ver que, diferentemente dos mapas, a impressão direta do canal em si não revela seu conteúdo.

Imprimir diretamente um canal apenas fornece o endereço de memória do canal. Isso ocorre porque, como um ponteiro, um canal é apenas uma referência a um endereço de memória.

Então, como podemos acessar e operar sobre os valores em um canal?

Operações de Canal

As operações de canal são centradas no envio e recebimento de dados, o que é feito usando o operador <-. Para ver os canais em ação, precisamos usá-los com goroutines, os threads leves do Go para concorrência.

Um canal conecta uma goroutine de envio e uma goroutine de recebimento. Se uma não estiver pronta, a outra ficará bloqueada. Vamos ver um exemplo completo.

Em channel.go, escreva o seguinte código:

package main

import (
    "fmt"
    "time"
)

func main() {
    // Cria um canal não-bufferizado (unbuffered channel)
    ch := make(chan int)

    // Inicia uma nova goroutine usando uma função anônima
    go func() {
        fmt.Println("Goroutine começa a enviar...")
        // Envia o valor 10 para o canal
        ch <- 10
        fmt.Println("Goroutine terminou de enviar.")
    }()

    fmt.Println("Main está esperando por dados...")
    // Recebe o valor do canal
    value := <-ch
    fmt.Printf("Main recebeu: %d\n", value)

    // Dá tempo para a goroutine imprimir sua mensagem final
    time.Sleep(time.Second)
}

Execute o programa usando o seguinte comando:

go run channel.go

A saída do programa será semelhante a esta (a ordem pode variar ligeiramente):

Main is waiting for data...
Goroutine starts sending...
Main received: 10
Goroutine finished sending.

Neste exemplo:

  1. ch := make(chan int) cria um canal não-bufferizado. Canais não-bufferizados exigem que tanto o remetente quanto o receptor estejam prontos ao mesmo tempo para que a comunicação ocorra. Isso é chamado de sincronização.
  2. go func() { ... }() inicia uma nova goroutine. A palavra-chave go executa a função concorrentemente com a função main.
  3. Dentro da goroutine, ch <- 10 envia o valor 10 para o canal. Esta operação ficará bloqueada até que a goroutine main esteja pronta para receber.
  4. Na função main, value := <-ch recebe o valor. Esta operação bloqueia até que a goroutine envie um valor.
  5. Assim que ambos estão prontos, o valor é transferido, e ambas as goroutines são desbloqueadas e continuam sua execução. Adicionamos time.Sleep para garantir que o programa não termine antes que a goroutine possa imprimir sua mensagem final.

Fechando o Canal

Quando não houver mais valores a serem enviados em um canal, o remetente pode fechá-lo usando a função close(). Uma maneira comum de ler todos os valores de um canal até que ele seja fechado é usar um loop for range.

Vamos modificar channel.go:

package main

import "fmt"

func main() {
    // Um canal bufferizado para armazenar múltiplos valores
    ch := make(chan int, 3)

    // Inicia uma goroutine para enviar dados
    go func() {
        fmt.Println("Goroutine enviando 10, 20, 30")
        ch <- 10
        ch <- 20
        ch <- 30
        // Fecha o canal após enviar todos os valores
        close(ch)
        fmt.Println("Goroutine fechou o canal.")
    }()

    fmt.Println("Main está recebendo dados...")
    // Usa um loop for-range para receber valores até que o canal seja fechado
    for value := range ch {
        fmt.Printf("Main recebeu: %d\n", value)
    }
    fmt.Println("Main terminou de receber.")
}

Execute o programa usando o seguinte comando:

go run channel.go

A saída do programa é a seguinte (a ordem pode variar ligeiramente):

Main is receiving data...
Goroutine sending 10, 20, 30
Main received: 10
Main received: 20
Main received: 30
Goroutine closed the channel.
Main finished receiving.

Isso demonstra um padrão comum: o loop for range lida automaticamente com a verificação se o canal está fechado e sai quando isso acontece.

Ao trabalhar com canais fechados, lembre-se destas regras principais:

  • Enviar para um canal fechado causará um erro em tempo de execução (um panic).
  • Receber de um canal fechado e vazio retornará imediatamente o valor zero do tipo do canal.
  • Fechar um canal que já está fechado causará um panic.
  • Em geral, apenas o remetente deve fechar um canal, nunca o receptor.

Bloqueio de Canal

Como vimos na etapa anterior, as operações de canal podem bloquear. Este é um recurso fundamental que permite que as goroutines se sincronizem. Vamos analisar isso mais de perto.

Canais Não-Bufferizados (Unbuffered Channels)

Canais criados com make(chan T) são não-bufferizados.

ch1 := make(chan int) // Canal não-bufferizado

Um canal não-bufferizado não tem capacidade. Uma operação de envio em um canal não-bufferizado bloqueia a goroutine de envio até que outra goroutine esteja pronta para receber. Inversamente, uma operação de recebimento bloqueia a goroutine de recebimento até que outra goroutine esteja pronta para enviar. Isso cria um ponto de sincronização forte, como vimos em nosso primeiro exemplo na etapa anterior.

Canais Bufferizados (Buffered Channels)

Canais também podem ser bufferizados, o que significa que eles têm capacidade para armazenar um certo número de valores antes de bloquear.

ch2 := make(chan int, 5) // Canal bufferizado com capacidade de 5

Com um canal bufferizado:

  • Uma operação de envio bloqueia apenas quando o buffer está cheio.
  • Uma operação de recebimento bloqueia apenas quando o buffer está vazio.

Se o buffer não estiver cheio, um remetente pode enviar um valor sem que um receptor esteja imediatamente pronto. O valor será armazenado na fila do canal.

Exemplo de Deadlock

O que acontece se excedermos a capacidade de um canal? O programa resultará em um deadlock (impasse). Um deadlock ocorre quando todas as goroutines em um programa estão bloqueadas, esperando por algo que nunca acontecerá.

Crie um arquivo chamado channel1.go:

cd ~/project
touch channel1.go

Escreva o seguinte código, que tenta enviar dois valores para um canal com capacidade de um, tudo dentro da goroutine principal (main):

package main

import "fmt"

func main() {
    ch := make(chan int, 1)
    ch <- 10 // Isso é bem-sucedido
    fmt.Println("Sent 10 to channel")
    ch <- 20 // Isso bloqueia para sempre porque o canal está cheio
    fmt.Println("succeed")
}

Execute o programa usando o seguinte comando:

go run channel1.go

A saída do programa é a seguinte:

Sent 10 to channel
fatal error: all goroutines are asleep - deadlock!

O programa entra em pânico com um erro de deadlock. A goroutine main envia 10, o que preenche o buffer. Em seguida, tenta enviar 20, mas bloqueia porque o buffer está cheio. Como nenhuma outra goroutine jamais receberá do canal, a goroutine main ficará bloqueada para sempre, fazendo com que o runtime do Go detecte um deadlock.

Para corrigir isso, a operação deve ser coordenada com outra goroutine que possa receber o valor e liberar espaço no buffer.

Canais Unidirecionais

Por padrão, um canal é bidirecional e pode ser lido ou escrito. Às vezes, precisamos restringir o uso de um canal, por exemplo, queremos garantir que um canal só possa ser escrito ou só possa ser lido dentro de uma função. Como podemos conseguir isso?

Podemos declarar explicitamente um canal com uso restrito antes de inicializá-lo.

Canal Somente Escrita (Write-only Channel)

Um canal somente escrita significa que, dentro de uma função específica, o canal só pode ser escrito e não pode ser lido. Vamos declarar e usar um canal somente escrita do tipo int:

package main

import "fmt"

func writeOnly(ch chan<- int) {
    ch <- 10
    fmt.Println("Data written to channel")
}

func main() {
    ch := make(chan int, 1)
    writeOnly(ch)
    fmt.Println("Data received:", <-ch)
}

Execute o programa usando o seguinte comando:

go run channel.go

A saída do programa é a seguinte:

Data written to channel
Data received: 10

Neste exemplo, a função writeOnly restringe o canal apenas à escrita.

Canal Somente Leitura (Read-only Channel)

Um canal somente leitura significa que, dentro de uma função específica, o canal só pode ser lido e não pode ser escrito. Vamos declarar e usar um canal somente leitura do tipo int:

package main

import "fmt"

func readOnly(ch <-chan int) {
    fmt.Println("Data received from channel:", <-ch)
}

func main() {
    ch := make(chan int, 1)
    ch <- 20
    readOnly(ch)
}

Execute o programa usando o seguinte comando:

go run channel.go

A saída do programa é a seguinte:

Data received from channel: 20

Neste exemplo, a função readOnly restringe o canal apenas à leitura.

Combinando Canais Somente Escrita e Somente Leitura

Você pode combinar canais somente escrita e somente leitura para demonstrar como os dados fluem através de canais restritos:

package main

import "fmt"

func writeOnly(ch chan<- int) {
    ch <- 30
    fmt.Println("Data written to channel")
}

func readOnly(ch <-chan int) {
    fmt.Println("Data received from channel:", <-ch)
}

func main() {
    ch := make(chan int, 1)
    writeOnly(ch)
    readOnly(ch)
}

Execute o programa usando o seguinte comando:

go run channel.go

A saída do programa é a seguinte:

Data written to channel
Data received from channel: 30

Este exemplo ilustra como um canal somente escrita pode passar dados para um canal somente leitura.

Resumo

Neste laboratório, aprendemos os fundamentos dos canais (channels), que incluem:

  • Tipos de canais e como declará-los
  • Métodos para inicializar canais
  • Operações em canais
  • O conceito de bloqueio de canal (channel blocking)
  • Declaração de canais unidirecionais