Primitivas de Canal en Golang

GolangBeginner
Practicar Ahora

Introducción

Una de las características más atractivas del lenguaje Go es su soporte nativo para la programación concurrente. Posee un fuerte soporte para el paralelismo, la programación concurrente y la comunicación en red, lo que permite un uso más eficiente de los procesadores multinúcleo.

A diferencia de otros lenguajes que logran la concurrencia a través de la memoria compartida, Go logra la concurrencia utilizando canales (channels) para la comunicación entre diferentes goroutines.

Las goroutines son las unidades de concurrencia en Go y pueden considerarse como hilos (threads) ligeros. Consumen menos recursos al cambiar entre ellas en comparación con los hilos tradicionales.

Los canales y las goroutines constituyen juntos los primitivos de concurrencia en Go. En esta sección, aprenderemos sobre los canales, esta nueva estructura de datos.

Puntos de Conocimiento:

  • Tipos de canales
  • Creación de canales
  • Operaciones en canales
  • Bloqueo de canales
  • Canales unidireccionales
Este es un Guided Lab, que proporciona instrucciones paso a paso para ayudarte a aprender y practicar. Sigue las instrucciones cuidadosamente para completar cada paso y obtener experiencia práctica. Los datos históricos muestran que este es un laboratorio de nivel principiante con una tasa de finalización del 100%. Ha recibido una tasa de reseñas positivas del 100% por parte de los estudiantes.

Visión General de los Canales

Los canales (channels) son una estructura de datos especial utilizada principalmente para la comunicación entre goroutines.

A diferencia de otros modelos de concurrencia que utilizan mutexes y funciones atómicas para el acceso seguro a los recursos, los canales permiten la sincronización mediante el envío y la recepción de datos entre múltiples goroutines.

En Go, un canal es como una cinta transportadora o una cola (queue) que sigue la regla Primero en Entrar, Primero en Salir (FIFO - First-In-First-Out).

Tipos y Declaración de Canales

Un canal es un tipo de referencia, y su formato de declaración es el siguiente:

var channelName chan elementType

Cada canal tiene un tipo específico y solo puede transportar datos del mismo tipo. Veamos algunos ejemplos:

Cree un archivo llamado channel.go en el directorio ~/project:

cd ~/project
touch channel.go

Por ejemplo, declararemos un canal de tipo int:

var ch chan int

El código anterior declara un canal de tipo int llamado ch.

Además de esto, existen muchos otros tipos de canales comúnmente utilizados.

En channel.go, escriba el siguiente código:

package main

import "fmt"

func main() {
    var ch1 chan int   // Declara un canal de enteros
    var ch2 chan bool  // Declara un canal booleano
    var ch3 chan []int // Declara un canal que transporta slices de enteros
    fmt.Println(ch1, ch2, ch3)
}

El código anterior simplemente declara tres tipos diferentes de canales.

Ejecute el programa usando el siguiente comando:

go run channel.go

La salida del programa es la siguiente:

<nil> <nil> <nil>

Inicialización de Canales

Al igual que los mapas (maps), los canales deben inicializarse después de ser declarados antes de que puedan ser utilizados.

La sintaxis para inicializar un canal es la siguiente:

make(chan elementType)

En channel.go, escriba el siguiente código:

package main

import "fmt"

func main() {
    // Canal que almacena datos enteros (integer)
    ch1 := make(chan int)

    // Canal que almacena datos booleanos (boolean)
    ch2 := make(chan bool)

    // Canal que almacena datos de tipo []int (slice de enteros)
    ch3 := make(chan []int)

    fmt.Println(ch1, ch2, ch3)
}

Ejecute el programa usando el siguiente comando:

go run channel.go

La salida del programa se muestra a continuación:

0xc0000b4000 0xc0000b4040 0xc0000b4080

En el código anterior, se definen e inicializan tres tipos diferentes de canales.

Al examinar la salida, podemos ver que, a diferencia de los mapas, imprimir directamente el canal en sí no revela su contenido.

Imprimir directamente un canal solo proporciona la dirección de memoria del canal. Esto se debe a que, al igual que un puntero (pointer), un canal es solo una referencia a una dirección de memoria.

Entonces, ¿cómo podemos acceder y operar sobre los valores en un canal?

Operaciones con Canales

Las operaciones de canal se centran en enviar y recibir datos, lo cual se realiza utilizando el operador <-. Para ver los canales en acción, necesitamos usarlos con goroutines, los hilos ligeros de Go para la concurrencia.

Un canal conecta una goroutine emisora y una goroutine receptora. Si una no está lista, la otra se bloqueará. Veamos un ejemplo completo.

En channel.go, escriba el siguiente código:

package main

import (
    "fmt"
    "time"
)

func main() {
    // Crea un canal sin buffer (unbuffered channel)
    ch := make(chan int)

    // Inicia una nueva goroutine usando una función anónima
    go func() {
        fmt.Println("Goroutine comienza a enviar...")
        // Envía el valor 10 al canal
        ch <- 10
        fmt.Println("Goroutine terminó de enviar.")
    }()

    fmt.Println("Main está esperando datos...")
    // Recibe el valor del canal
    value := <-ch
    fmt.Printf("Main recibió: %d\n", value)

    // Da tiempo a la goroutine para imprimir su mensaje final
    time.Sleep(time.Second)
}

Ejecute el programa usando el siguiente comando:

go run channel.go

La salida del programa será similar a esta (el orden puede variar ligeramente):

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

En este ejemplo:

  1. ch := make(chan int) crea un canal sin buffer. Los canales sin buffer requieren que tanto el emisor como el receptor estén listos al mismo tiempo para que ocurra la comunicación. Esto se denomina sincronización.
  2. go func() { ... }() inicia una nueva goroutine. La palabra clave go ejecuta la función concurrentemente con la función main.
  3. Dentro de la goroutine, ch <- 10 envía el valor 10 al canal. Esta operación se bloqueará hasta que la goroutine main esté lista para recibir.
  4. En la función main, value := <-ch recibe el valor. Esta operación se bloquea hasta que la goroutine envía un valor.
  5. Una vez que ambos están listos, el valor se transfiere y ambas goroutines se desbloquean y continúan su ejecución. Agregamos time.Sleep para asegurar que el programa no termine antes de que la goroutine pueda imprimir su mensaje final.

Cerrar el Canal

Cuando no se enviarán más valores a través de un canal, el emisor puede cerrarlo usando la función close(). Una forma común de leer todos los valores de un canal hasta que se cierra es usar un bucle for range.

Modifiquemos channel.go:

package main

import "fmt"

func main() {
    // Un canal con buffer para contener múltiples valores
    ch := make(chan int, 3)

    // Inicia una goroutine para enviar datos
    go func() {
        fmt.Println("Goroutine enviando 10, 20, 30")
        ch <- 10
        ch <- 20
        ch <- 30
        // Cierra el canal después de enviar todos los valores
        close(ch)
        fmt.Println("Goroutine cerró el canal.")
    }()

    fmt.Println("Main está recibiendo datos...")
    // Usa un bucle for-range para recibir valores hasta que el canal se cierre
    for value := range ch {
        fmt.Printf("Main recibió: %d\n", value)
    }
    fmt.Println("Main terminó de recibir.")
}

Ejecute el programa usando el siguiente comando:

go run channel.go

La salida del programa es la siguiente (el orden puede variar ligeramente):

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.

Esto demuestra un patrón común: el bucle for range maneja automáticamente la verificación de si el canal está cerrado y sale cuando lo está.

Al trabajar con canales cerrados, recuerde estas reglas clave:

  • Enviar a un canal cerrado provocará un error en tiempo de ejecución (un panic).
  • Recibir de un canal cerrado y vacío devolverá inmediatamente el valor cero (zero value) del tipo del canal.
  • Cerrar un canal que ya está cerrado provocará un panic.
  • En general, solo el emisor debe cerrar un canal, nunca el receptor.

Bloqueo de Canales

Como vimos en el paso anterior, las operaciones de canal pueden bloquearse (block). Esta es una característica clave que permite a las goroutines sincronizarse. Veamos esto más de cerca.

Canales Sin Buffer (Unbuffered Channels)

Los canales creados con make(chan T) son sin buffer (unbuffered).

ch1 := make(chan int) // Canal sin buffer

Un canal sin buffer no tiene capacidad. Una operación de envío en un canal sin buffer bloquea la goroutine emisora hasta que otra goroutine está lista para recibir. A la inversa, una operación de recepción bloquea la goroutine receptora hasta que otra goroutine está lista para enviar. Esto crea un punto de sincronización fuerte, como vimos en nuestro primer ejemplo en el paso anterior.

Canales Con Buffer (Buffered Channels)

Los canales también pueden tener buffer, lo que significa que tienen una capacidad para almacenar un cierto número de valores antes de bloquearse.

ch2 := make(chan int, 5) // Canal con buffer con una capacidad de 5

Con un canal con buffer:

  • Una operación de envío se bloquea solo cuando el buffer está lleno.
  • Una operación de recepción se bloquea solo cuando el buffer está vacío.

Si el buffer no está lleno, un emisor puede enviar un valor sin que un receptor esté listo inmediatamente. El valor se almacenará en la cola (queue) del canal.

Ejemplo de Interbloqueo (Deadlock)

¿Qué sucede si excedemos la capacidad de un canal? El programa resultará en un interbloqueo (deadlock). Un deadlock ocurre cuando todas las goroutines en un programa están bloqueadas, esperando algo que nunca sucederá.

Cree un archivo llamado channel1.go:

cd ~/project
touch channel1.go

Escriba el siguiente código, que intenta enviar dos valores a un canal con capacidad de uno, todo dentro de la goroutine principal (main):

package main

import "fmt"

func main() {
    ch := make(chan int, 1)
    ch <- 10 // Esto tiene éxito
    fmt.Println("Sent 10 to channel")
    ch <- 20 // Esto se bloquea para siempre porque el canal está lleno
    fmt.Println("succeed")
}

Ejecute el programa usando el siguiente comando:

go run channel1.go

La salida del programa es la siguiente:

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

El programa entra en pánico (panics) con un error de deadlock. La goroutine main envía 10, lo que llena el buffer. Luego intenta enviar 20 pero se bloquea porque el buffer está lleno. Dado que ninguna otra goroutine recibirá del canal, la goroutine main quedará bloqueada para siempre, lo que hace que el runtime de Go detecte un deadlock.

Para solucionar esto, la operación debe coordinarse con otra goroutine que pueda recibir el valor y liberar espacio en el buffer.

Canales Unidireccionales

Por defecto, un canal es bidireccional y se puede leer o escribir en él. A veces, necesitamos restringir el uso de un canal; por ejemplo, queremos asegurar que un canal solo pueda ser escrito o solo pueda ser leído dentro de una función. ¿Cómo podemos lograr esto?

Podemos declarar explícitamente un canal con uso restringido antes de inicializarlo.

Canal de Solo Escritura (Write-only Channel)

Un canal de solo escritura significa que, dentro de una función particular, solo se puede escribir en el canal y no se puede leer desde él. Declararemos y usaremos un canal de solo escritura de 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)
}

Ejecute el programa usando el siguiente comando:

go run channel.go

La salida del programa es la siguiente:

Data written to channel
Data received: 10

En este ejemplo, la función writeOnly restringe el canal solo a la escritura.

Canal de Solo Lectura (Read-only Channel)

Un canal de solo lectura significa que, dentro de una función particular, solo se puede leer desde el canal y no se puede escribir en él. Declararemos y usaremos un canal de solo lectura de 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)
}

Ejecute el programa usando el siguiente comando:

go run channel.go

La salida del programa es la siguiente:

Data received from channel: 20

En este ejemplo, la función readOnly restringe el canal solo a la lectura.

Combinación de Canales de Solo Escritura y Solo Lectura

Puede combinar canales de solo escritura y solo lectura para demostrar cómo fluyen los datos a través de canales restringidos:

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)
}

Ejecute el programa usando el siguiente comando:

go run channel.go

La salida del programa es la siguiente:

Data written to channel
Data received from channel: 30

Este ejemplo ilustra cómo un canal de solo escritura puede pasar datos a un canal de solo lectura.

Resumen

En este laboratorio, aprendimos los fundamentos de los canales (channels), que incluyen:

  • Tipos de canales y cómo declararlos
  • Métodos para inicializar canales
  • Operaciones en canales
  • El concepto de bloqueo de canal (channel blocking)
  • Declaración de canales unidireccionales