Go channels are more than just queues; they’re a fundamental mechanism for inter-goroutine communication, enabling safe and concurrent data sharing.

Let’s see a channel in action:

package main

import (
	"fmt"
	"time"
)

func producer(ch chan int) {
	for i := 0; i < 5; i++ {
		fmt.Printf("Producing %d\n", i)
		ch <- i // Send value to channel
		time.Sleep(100 * time.Millisecond)
	}
	close(ch) // Signal no more values will be sent
}

func consumer(ch chan int) {
	for val := range ch { // Receive values until channel is closed
		fmt.Printf("Consuming %d\n", val)
	}
}

func main() {
	dataChannel := make(chan int) // Unbuffered channel
	go producer(dataChannel)
	go consumer(dataChannel)

	// Keep main goroutine alive long enough for others to finish
	time.Sleep(2 * time.Second)
}

When you run this, you’ll observe the interleaved "Producing" and "Consuming" messages. The producer goroutine sends values, and the consumer goroutine receives them. The <- operator is the key: ch <- i sends i to ch, and val := <-ch receives from ch into val. The range ch loop elegantly handles receiving until the channel is closed.

The core problem channels solve is the "shared memory problem" in concurrent programming. Instead of goroutines directly accessing and modifying shared data (which can lead to race conditions), they communicate by sending values over channels. This is often summarized as "Do not communicate by sharing memory; instead, share memory by communicating."

Internally, a channel is a complex data structure managed by the Go runtime. At its heart, it’s a pointer to a hchan struct. This struct contains:

  • qcount: The number of elements currently in the channel buffer.
  • data: A circular buffer (if buffered) holding the channel’s elements.
  • elemsize: The size of each element in bytes.
  • closed: A flag indicating if the channel has been closed.
  • buf: Pointer to the buffer itself.
  • lock: A mutex to protect access to the channel’s internal state.
  • recvq: A doubly linked list of sudog structures waiting to receive from the channel.
  • sendq: A doubly linked list of sudog structures waiting to send to the channel.

When a goroutine sends to a channel (ch <- value):

  1. It acquires the channel’s lock.
  2. If the channel is closed, it panics.
  3. If the channel is buffered and has space, the value is placed in the buffer, and the lock is released.
  4. If the channel is unbuffered or buffered but full, the sending goroutine is blocked. A sudog representing the send operation is added to the sendq. The goroutine yields control to the scheduler.
  5. If there’s a goroutine waiting to receive (recvq is not empty), the sender can directly hand off the value to the waiting receiver without using the buffer. The sender and receiver synchronize, and both are unblocked.

When a goroutine receives from a channel (value := <-ch):

  1. It acquires the channel’s lock.
  2. If the channel is closed and empty, it returns the zero value for the channel’s type and false for the second return value (if using the val, ok := <-ch idiom).
  3. If the channel is empty, the receiving goroutine is blocked. A sudog representing the receive operation is added to the recvq. The goroutine yields control.
  4. If there’s a value in the buffer, it’s taken from the buffer, and the lock is released.
  5. If there’s a goroutine waiting to send (sendq is not empty), the receiver can take the value directly from the sender. The receiver and sender synchronize, and both are unblocked.

The choice between buffered and unbuffered channels is crucial.

Unbuffered Channels (make(chan T)):

  • Behavior: Send and receive operations block until both sides are ready. This provides strong synchronization.
  • Use Cases:
    • Signaling: A goroutine signals completion to another.
    • Mutex-like behavior: Ensure only one goroutine can access a resource at a time.
    • Strict ordering: When the exact sequence of operations matters and you need tight coupling.

Buffered Channels (make(chan T, capacity)):

  • Behavior: Send operations block only when the buffer is full. Receive operations block only when the buffer is empty.
  • Use Cases:
    • Decoupling producer and consumer: Allows producers to run ahead of consumers to a certain extent, smoothing out temporary rate differences.
    • Throttling: Limiting the number of concurrent operations.
    • Batching: Accumulating a few items before processing.

Consider the "fan-in" pattern. You might have multiple goroutines producing data, and you want to consolidate it into a single channel.

package main

import (
	"fmt"
	"sync"
	"time"
)

func producerWorker(id int, ch chan<- int) {
	for i := 0; i < 3; i++ {
		val := id*10 + i
		fmt.Printf("Worker %d producing %d\n", id, val)
		ch <- val
		time.Sleep(time.Duration(id+1) * 50 * time.Millisecond)
	}
}

func main() {
	const numWorkers = 3
	inputCh := make(chan int)
	var wg sync.WaitGroup

	// Start worker goroutines
	for i := 1; i <= numWorkers; i++ {
		wg.Add(1)
		go func(workerID int) {
			defer wg.Done()
			producerWorker(workerID, inputCh)
		}(i)
	}

	// Goroutine to close the channel once all workers are done
	go func() {
		wg.Wait()
		close(inputCh)
		fmt.Println("All workers finished, closing channel.")
	}()

	// Fan-in: Consume from the single input channel
	fmt.Println("Starting fan-in consumer...")
	for val := range inputCh {
		fmt.Printf("Fan-in received: %d\n", val)
	}
	fmt.Println("Fan-in consumer finished.")
}

This example demonstrates how multiple goroutines (producerWorker) send to a single channel (inputCh). A sync.WaitGroup is used to track when all producers are finished, at which point the channel is closed. The range loop on inputCh then acts as the "fan-in" point, consuming all data.

The specific mechanism by which a Go channel ensures that a send and receive operation are synchronized is through the sendq and recvq. When a sender arrives and finds no receiver ready, it places its value (or a reference to it) into its sudog and queues it in sendq, then blocks. If a receiver arrives and finds the sendq non-empty, it doesn’t use the buffer; instead, it takes the value directly from the waiting sender’s sudog, unblocks the sender, and proceeds. The opposite happens when a receiver waits and a sender arrives. This direct handoff is the core of the synchronization, making channels behave like rendezvous points.

The next concept to explore is how to handle multiple channels effectively, often using the select statement.

Want structured learning?

Take the full Golang course →