Back

Understanding Channels in Go

Nikita Khabya

Nikita Khabya

7 min read

|

4 months ago

As we saw in the Exploring Goroutines post that channels are a way to communicate between goroutines, in this post we will explore channels in more detail.

What is a Channel?

Working of channels

When Should You Use Channels? 🤔

Before diving into channels, it's important to understand when you actually need them. Not every program needs concurrency, and adding channels and goroutines can make your code more complex.

Use channels when

Most programs start simple. Concurrency can be introduced when you have a clear need for it, like improving performance or handling multiple tasks simultaneously.

Operations on Channels ⚒️

Creating a channel:

A channel is created using the make() function. While creating a channel, we need to specify the type of data and the capacity of the channel. If we don't specify the capacity, it will be 0 capacity channel, which is called an unbuffered channel.

go
unbuffered := make(chan string) // Unbuffered channel
go
buffered := make(chan int, 5) // Buffered channel with capacity 5

Sending data to a channel:

The <- operator is used to send data to a channel. It means "send this data to the channel".

go
ch <- "Data"

Receiving data from a channel:

The <- operator is also used to receive data from a channel, but in the opposite direction. It means "receive data from the channel".

go
data := <-ch

Both sending and receiving are blocking operations by default.

Checking if a channel is closed:

While receiving data from a channel, it also returns a second boolean value 'ok' to indicate if the channel is closed or not.

go
data, ok := <-ch
if !ok {
	fmt.Println("Channel is closed")
}

Closing a channel:

This is done using the close() function. Closing a channel indicates that no more data will be sent on it. It's very important to close channels to avoid memory leaks and also to signal the receiver that no more data will be sent.

go
close(ch)

Example

Add an array of integers using a goroutine and send the result back through a channel.

go
package main

import "fmt"

func main() {
	// Create an unbuffered channel for integers
	c := make(chan int)

	nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

	// Start a goroutine to calculate the sum
	go func() {
		defer close(c) // Close channel after sending the result
		sum := 0
		for i := 0; i < len(nums); i++ {
			fmt.Println("Adding:", nums[i])
			sum += nums[i]
		}
		c <- sum
	}()

	output := <-c
	fmt.Println("Sum:", output)
}

Explanation of the code: 📝

  1. Create a channel using the make() function and specify the type of data to be sent through the channel
  2. Use defer to ensure the channel is closed when done
  3. Define an array of integers to sum
  4. Start a goroutine to calculate the sum of the integers
  5. Use a for loop to iterate through the array and add each integer to the sum
  6. Send the result back to the main function through the channel
  7. In the main function, receive the result from the channel (this is a blocking operation, so the main function will wait until the channel is closed or data is received from the channel)
  8. Print the sum of the integers

Types of Channels: 🎢

Unbuffered Channels:

Buffered Channels:

Example of an Unbuffered channel:

Example I:

This is an example where we hit a deadlock because the main goroutine is blocked on the receive operation, and the goroutine that sends data is blocked on the send operation. They are waiting for each other, which leads to a deadlock.

go
package main
import "fmt"

func main() {
	ch := make(chan string) 

	ch <- "Data" 
	fmt.Println(<-ch) 
}

Output:

fatal error: all goroutines are asleep - deadlock!

Example II:

Here we don't hit a deadlock because the main goroutine is not blocked on the receive operation, and the goroutine that sends data is not blocked on the send operation. Simply, they are not waiting for each other but working concurrently.

go
package main

import (
	"fmt"
)

func main() {
	ch := make(chan string)

	// Start a goroutine to send data
	go func() {
		ch <- "Data"
	}()

	message := <-ch
	fmt.Println(message)
}

Example of a Buffered channel:

go
package main
import "fmt"

func main() {
	ch := make(chan string, 3) 

    // Send data to the channel
	ch <- "Data" 
	ch <- "Data" 
	ch <- "Data" 

	// Receive data from the channel
	fmt.Println(<-ch)
	fmt.Println(<-ch) 
	fmt.Println(<-ch)
}

Output:

Data
Data
Data

Channel directions: 🔄

Channels can be directional, meaning they can be used for sending only or receiving only. All this time we have been using channels that are bidirectional, i.e. both sending and receiving data.

Using send only or receive only channels makes our code more type-safe and clear about the intent of the channel.

Example

go
package main

import (
    "fmt"
    "time" 
)

func sender(ch chan<- string) {
    ch <- "Data from sender"
	defer close(ch)
}

func receiver(ch <-chan string) {
    msg := <-ch
    fmt.Println("Received:", msg)
}

func main() {
    ch := make(chan string) 

    // Start goroutines for sending and receiving
    go sender(ch)   
    go receiver(ch) 

    time.Sleep(100 * time.Millisecond) 
    fmt.Println("Main function completed")
}

Explanation of the code: 📝

  1. Create a channel using the make() function and specify the type of data to be sent through the channel

  2. Start two goroutines to send and receive data

  3. The sender function can only send data to the channel, and the receiver function can only receive data from the channel. This enforces type safety and makes the intent of the channel clear

  4. Since we are not using WaitGroup here, we need to give some time for goroutines to complete before the main function exits

  5. Main function completed

Using select with channels ⚖️

Syntax of select statement:

select {
    case value := <-channel1:
        // Executes if channel1 has data to receive
    case channel2 <- value:
        // Executes if channel2 is ready to receive data
    case <-time.After(1 * time.Second):
        // Executes if no other case is ready within 1 second (timeout)
    default:
        // Executes immediately if no other case is ready
}

Example of using select with channels:

In this example we have two channels ch1 and ch2. ch1 sends data after 2 seconds, while ch2 sends data after 1 second.

The select statement waits for either channel to be ready and prints the message accordingly. Since ch2 is ready first, it prints that message first, followed by ch1.

go
package main

import (
	"fmt"
	"time"
)

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	// Goroutine that sends after 2 seconds
	go func() {
		time.Sleep(2 * time.Second)
		ch1 <- "Takes 2 seconds to send this message"
	}()

	// Goroutine that sends after 1 second
	go func() {
		time.Sleep(1 * time.Second)
		ch2 <- "Takes 1 second to send this message"
	}()

	// Use select to handle multiple channels
	for i := 0; i < 2; i++ {
		select {
		case msg1 := <-ch1:
			fmt.Println("Received from ch1:", msg1)
		case msg2 := <-ch2:
			fmt.Println("Received from ch2:", msg2)
		case <-time.After(3 * time.Second):
			fmt.Println("Timeout: No message received within 3 seconds")
		}
	}
}

Output:

Received from ch2: Takes 1 second to send this message
Received from ch1: Takes 2 seconds to send this message

Common Pitfalls: 💥

Deadlocks

Goroutine Leaks

TL;DR: