canales-go-comunicacion-goroutines

Channels in Go: Concurrent Communication and Synchronization

  • 6 min

A channel in Go is a typed conduit for sending values between goroutines. It allows coordinating work by sharing messages instead of sharing memory recklessly.

We already know how to launch goroutines and wait for them to finish. Now we are going to communicate values between them.

In languages like Java or C++, when two threads need to share data, they usually access a shared variable protected by a lock (Mutex). This is error-prone, leading to Race Conditions and complex Deadlocks.

Go encourages this second option with a simple idea:

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

For this we have channels. We can think of them as typed conduits through which goroutines send values and synchronize.

Declaration and Syntax

A channel is a data type like any other. It is defined with the keyword chan followed by the type of data that will travel through the pipe.

// Declaration (zero value is nil, be careful!)
var nilChan chan int

// Initialization of a channel ready to use
ch := make(chan int)
Copied!

Sending or receiving on a nil channel blocks forever, and closing it causes a panic. This behavior allows enabling or disabling cases in a select, but usually indicates an error outside that pattern.

The Arrow Operator <-

To send and receive data we use the <- operator. The direction of the arrow indicates the data flow.

  • Send data to the channel: ch <- value (Put the value into the channel).
  • Receive data from the channel: variable := <-ch (the arrow leaves the channel).

Unbuffered Channels

By default, channels in Go do not have a buffer. This means they have no “space” to store data.

How do they work then? They work through Strict Synchronization.

For the communication to complete, a send and a receive must meet. One of the goroutines may have been blocked before the other arrives.

  • If you send (ch <- 1) and no one is listening, you block.
  • If you try to receive (<-ch) and no one is sending, you block.

It’s like passing a baton in a relay race: both runners must coincide in time and space.

package main

import (
    "fmt"
    "time"
)

func main() {
    // Create a channel WITHOUT buffer
    messages := make(chan string)

    go func() {
        fmt.Println("Goroutine: Sending message...")
        // This line BLOCKS until someone reads from the channel
        messages <- "Ping!"
        fmt.Println("Goroutine: Message delivered")
    }()

    fmt.Println("Main: Waiting a bit...")
    time.Sleep(2 * time.Second)

    // When this line executes, the goroutine is unblocked
    received := <-messages
    fmt.Println("Main: Received:", received)
}
Copied!

Unbuffered channels are perfect for synchronizing two goroutines. They guarantee that the exchange has occurred.

Buffered Channels

Sometimes we don’t want the sender to block if the receiver is busy. We want a “queue” or a mailbox where we can leave the message and continue working.

These are Buffered Channels. They are created by passing the capacity to make.

ch := make(chan string, 3) // Capacity for 3 messages

In a buffered channel:

  1. Sending only blocks if the buffer is full.
  2. Receiving only blocks if the buffer is empty.
func main() {
    // Channel with capacity for 2 integers
    queue := make(chan int, 2)

    // We can send without anyone listening (because there is space)
    queue <- 1
    queue <- 2

    fmt.Println("We have inserted 2 elements without blocking")

    // queue <- 3 // CAREFUL! This would block because the buffer is full (Deadlock in main)

    fmt.Println(<-queue) // 1
    fmt.Println(<-queue) // 2
}
Copied!

When to use Buffer?

Use them to decouple producers and consumers when the production/consumption speeds vary. But be careful: a buffer is not infinite memory. If the producer is systematically faster than the consumer, the buffer will fill up and you will block again.

Closing Channels and Using range

The producing side can close a channel to indicate that no more data will be sent. It is not mandatory to close all channels: it is only necessary when the receiver needs to detect that end or release a range loop.

close(ch)

This is very useful so the receiver knows when to stop waiting. In fact, we can iterate over a channel with range. The range loop continuously receives values and breaks automatically when the channel is closed.

func producer(c chan int) {
    for i := 0; i < 5; i++ {
        c <- i
    }
    close(c) // Important! Close when finished
}

func main() {
    numbers := make(chan int)
    go producer(numbers)

    // Range iterates until the channel is closed
    for n := range numbers {
        fmt.Printf("Received: %d\n", n)
    }
    fmt.Println("Channel closed, loop finished.")
}
Copied!

Sending to a closed channel or closing it a second time causes a panic. The responsibility to close it should be on the producer side, and if there are multiple senders, it must be coordinated in a single point.

We can also check if a channel is still open using the form value, ok := <-ch. When it is closed and there are no pending values left, we receive the zero value and ok is false.

Directional Channels

When passing a channel to a function, we can specify whether that function will use the channel only for reading or only for writing. This improves type safety (the compiler warns us if we make a mistake).

  • chan<- Type: Send-only channel.
  • <-chan Type: Receive-only channel.
// This function can ONLY send
func send(c chan<- string) {
    c <- "Hello"
    // msg := <-c // Compilation ERROR
}

// This function can ONLY receive
func receive(c <-chan string) {
    fmt.Println(<-c)
    // c <- "Hello" // Compilation ERROR
}
Copied!

Differences Between Buffered and Unbuffered Channels

FeatureUnbuffered Channel (make(chan T))Buffered Channel (make(chan T, N))
BehaviorEach send synchronizes with a receiveSends proceed while capacity remains
SendingBlocks until someone readsBlocks only if full
ReceivingBlocks until someone writesBlocks only if empty
Primary UseStrict synchronizationPerformance decoupling