Paul Sitoh

I'm a software engineer who enjoys coding principally in Go and where necessary other languages.

Go channels: Don't communicate by sharing memory, share memory by communicating

15 Jun 2024 » go

About this post

Go provides a mechanism known as channels to facilitate data transfer between functions, typically concurrent ones. In this post, we’ll examine the features of Go channels, focusing on the differences between buffered and unbuffered channels, and the potential pitfalls when using them.

What is a Go Channel?

One approach for two or more concurrent operations to communicate with each other is to use shared memory. However, shared memory has it challenge; namely, race condition. Mechanisms like mutex locks are used to prevent race condition. When a process accesses the memory, a lock is placed on the shared memory to prevent other processes from accessing it. Once the lock is removed, access to the shared memory by all processes is permitted. This ensures that the processes do not override shared memory out of sequence. Unfortunately this approach negate the benefits of concurrency.

Go channels offer an alternative way of enabling concurrent processes to communicate in synchronized way without losing the benefit of concurrency. A Go channel works like a pipe connecting a sender of messages to a receiver. There are two types of channels: unbuffered and buffered. Let’s examine these in detail.

Unbuffered Channel

Example 1 illustrates a use case using unbuffered channel. However, this example results in a deadlock. In this example, the channel operates in the main routine. A channel c is created. A message is sent to the channel c <- before a receiver <-c is instantiated. This is analogous to water sent to a pipe with nothing to collect the water causing leakages. In our example the main routine to panic report a deadlock.

package main

func main() {

	// Create an unbuffered channel
	c := make(chan string)

	// Send message to channel before receiver is available
	c <- "Hello World" // Causing Go routine dead lock.
	<-c
}

Example 1 - Unbuffered Channel

We fix this problem by having the sender run in a separate goroutine, as shown in Example 2. Whilst the goroutine is scheduled for execution, the receiver <-c is instantiated and is ready to recieve message. Hence, no deadlock.

package main

func main() {

  	// Create an unbufferred channel
  	c := make(chan string)

  	// Schedule a goroutine and hands off to the main routine immediately
	go func(c chan string) {
		defer close(c)
		for {
			c <- "Hello"
			break
		}
	}(c)

  	// The main routine is blocked until the channel receives a message
	fmt.Println(<-c) 
}

Example 2: Schedule a sender in a goroutine.

In practice, we often find ourselves in situations where the receiver is unaware of the number of messages sent by the sender, as illustrated in Example 3. This example uses the close operation to signal that the channel cannot receive any more messages. At the receiver end verify that the channel is close.

However, we should not close a channel before sending a message to it, as in Example 4. Doing so results in a deadlock panic. It is a good practice that channels should only be close by the sender routine.

An alternative approach to detecting close channel is ranging the receiver channel as shown in Example 5.

func main() {
  	// Create an unbuffered channel
	c := make(chan string)

	// Goroutine execute and handover to main routine
	go func(ch chan<- string) {
		// Send message to channel
		for range 2 {
			c <- "Hello World"
		}
		close(c) // Channel is closed no more message allowed and signal to receiver that no more is coming.
	}(c)

	// The sender runs in an endless loop but no idea of the number of messages sent
	// or when the channel is closed.
	for {
		// Receive message
		v, ok := <-c
		// If channel is closed, ok is false otherwise channel is opened and expect more messages
		if !ok {
			log.Fatal("Channel closed")
		}
		fmt.Println(v)
	}
}

Example 3: Check for closed channel.

func main(){
	c := make(chan string)
	go func(ch chan<- string) {
		time.Sleep(1 * time.Second)
		ch <- "Hello"
	}(c)
	close(c)
	<-c
}  

Example 4: Closing a channel prematurely.

  c := make(chan string)
	go func(ch chan<- string) {
		defer close(ch)
		for i := 0; i < 3; i++ {
			ch <- "Hello"
		}
	}(c)

	for v := range c { // This ensure the receving channel range until all messages from sender are drained
		fmt.Println(v)
	}

Example 5: Range for message until the channel is closed.

A proper use case for an unbuffered channel is to facilitate synchronized operations across goroutines, as shown in Example 6. The channel is used to signal two goroutines to end their loops.

  	sig := make(chan struct{})
	go func(ch chan<- string) {
    	for{
      		// Do something until end
    	}
		// We send an arbitrary signal
		ch <- struct{}{}
	}(sig)
loop:
  	for {
		// 
		select {
		case <-q:
			break loop
		}
	}

Example 6: Using unbuffered channel to synchronised Go routines.

Buffered Channel

A buffered channel includes a buffer at the sender end. You have to fill out the buffer before any message is sent to the channel. In Example 7, we have a channel in the main routine. A message is sent to the channel but it is held in the buffer, long enough for the receiver to instantiate and drain the message. Hence, unlike Example 1, this does not result in a deadlock.

func main() {

	// Create a buffered channel with a capacity of one
	c := make(chan string, 1)

	// Push a message to a buffered channel
	c <- "Hello"

  	// Consume data
	fmt.Println(<-c)
}

Example 7 - Buffered channel

If you attempted to send messages to a buffered channel that is already full and there is no receiver, you will get a deadlock as illustrated in Example 8. If you have a receiver, as shown in Example 9, and the buffer is full, additional message send to the sender will be blocked, until receiver has drained the channel.

You can determine if senders has no more messages to send via close signal and range similar to Example 3 and Example 5.

func main() {
	c := make(chan string, 2) // Create a channel with two slices of strings
	fmt.Printf("Channel capacity: %v Length: %v\n", cap(c), len(c))

	c <- "Hello"
	fmt.Printf("Channel capacity: %v Length: %v\n", cap(c), len(c))

	c <- "World"
	fmt.Printf("Channel capacity: %v Length: %v\n", cap(c), len(c))

	c <- "Hola" // Send a message to channel when its buffer is full this will cause a deadlock as there is no receiving channel to drain the buffer.
	// fatal error: all goroutines are asleep - deadlock!
	fmt.Printf("Channel capacity: %v Length: %v\n", cap(c), len(c))

}

Example 8: Panic when you try to a full buffered channel.

func main() {
	c := make(chan int, 2)
	go func(ch chan int) {
		ch <- 1
		ch <- 2
		ch <- 3 // This is blocked until the buffer is drained
		fmt.Println("End of goroutine")
	}(c)
	time.Sleep(10 * time.Second) // Main routine goes to sleep allowing the receiver to fill.
	// Drain the first two messages.
	fmt.Println("First message: ", <-c)
	fmt.Println("Second message: ", <-c)
	time.Sleep(10 * time.Second)
	fmt.Println("Third message: ", <-c) // Pull more message after sleep.

}

Example 9: Blocked receiver.

Buffered channels are typically used to decouple operations between goroutines. One use case is for aysnchronous processing as shown in Example 10.

func asyncProcessor() chan int {
	c := make(chan int, 1)
	go func(ch chan int) {
		for i := range 10 {
			ch <- i
		}
		close(ch)
	}(c)
	return c
}

func main() {
	data := asyncProcessor()
	for d := range data {
		fmt.Println(d)
	}
}

Example 10: Asynchronous function.

Conclusion

Let’s conclude with this quote from the Go proverb:

Don’t communicate by sharing memory, share memory by communicating.

In other words, use Go channels for synchronization.

Finally, be aware of the differences between unbuffered and buffered channels. These are as follows:

  • Unbuffered channels:
    • The channel has no capacity to store data.
    • When you send a message to the channel, there must be a corresponding receiver ready to receive it.
    • The sender goroutine will block until a receiver is available to receive the message.
    • Typical use cases include facilitating synchronizations between goroutines.
  • Buffered channels:
    • It has a specified capacity to store data, determined during the make operation.
    • The receiver will block when the buffer is empty.
    • Attempting to send data to a full buffer will cause the channel to panic.
    • Typical use cases include decoupling operations when sending messages.