Channel Primitives in Golang

GolangBeginner
Practice Now

Introduction

One of the most attractive features of the Go language is its native support for concurrent programming. It has strong support for parallelism, concurrent programming, and network communication, allowing for more efficient use of multi-core processors.

Unlike other languages that achieve concurrency through shared memory, Go achieves concurrency by using channels for communication between different goroutines.

Goroutines are the units of concurrency in Go and can be thought of as lightweight threads. They consume fewer resources when switching between them compared to traditional threads.

Channels and goroutines together constitute the concurrency primitives in Go. In this section, we will learn about channels, this new data structure.

Knowledge Points:

  • Types of channels
  • Creating channels
  • Operating on channels
  • Channel blocking
  • Unidirectional channels

Overview of Channels

Channels are a special data structure primarily used for communication between goroutines.

Unlike other concurrency models that use mutexes and atomic functions for safe access to resources, channels allow for synchronization by sending and receiving data between multiple goroutines.

In Go, a channel is like a conveyor belt or a queue that follows the First-In-First-Out (FIFO) rule.

Channel Types and Declaration

A channel is a reference type, and its declaration format is as follows:

var channelName chan elementType

Each channel has a specific type and can only transport data of the same type. Let's see some examples:

Create a file named channel.go in the ~/project directory:

cd ~/project
touch channel.go

For example, let's declare a channel of type int:

var ch chan int

The above code declares an int channel named ch.

In addition to this, there are many other commonly used channel types.

In channel.go, write the following code:

package main

import "fmt"

func main() {
    var ch1 chan int   // Declare an integer channel
    var ch2 chan bool  // Declare a boolean channel
    var ch3 chan []int // Declare a channel that transports slices of integers
    fmt.Println(ch1, ch2, ch3)
}

The above code simply declares three different types of channels.

Run the program using the following command:

go run channel.go

The program output is as follows:

<nil> <nil> <nil>

Channel Initialization

Similar to maps, channels need to be initialized after they are declared before they can be used.

The syntax for initializing a channel is as follows:

make(chan elementType)

In channel.go, write the following code:

package main

import "fmt"

func main() {
    // Channel that stores integer data
    ch1 := make(chan int)

    // Channel that stores boolean data
    ch2 := make(chan bool)

    // Channel that stores []int data
    ch3 := make(chan []int)

    fmt.Println(ch1, ch2, ch3)
}

Run the program using the following command:

go run channel.go

The program output is shown below:

0xc0000b4000 0xc0000b4040 0xc0000b4080

In the above code, three different types of channels are defined and initialized.

By examining the output, we can see that, unlike maps, directly printing the channel itself does not reveal its contents.

Directly printing a channel only gives the memory address of the channel itself. This is because, like a pointer, a channel is only a reference to a memory address.

So, how can we access and operate on the values in a channel?

Channel Operations

Channel operations are centered around sending and receiving data, which is done using the <- operator. To see channels in action, we need to use them with goroutines, Go's lightweight threads for concurrency.

A channel connects a sending goroutine and a receiving goroutine. If one is not ready, the other will block. Let's see a complete example.

In channel.go, write the following code:

package main

import (
    "fmt"
    "time"
)

func main() {
    // Create an unbuffered channel
    ch := make(chan int)

    // Start a new goroutine using an anonymous function
    go func() {
        fmt.Println("Goroutine starts sending...")
        // Send the value 10 to the channel
        ch <- 10
        fmt.Println("Goroutine finished sending.")
    }()

    fmt.Println("Main is waiting for data...")
    // Receive the value from the channel
    value := <-ch
    fmt.Printf("Main received: %d\n", value)

    // Give the goroutine time to print its final message
    time.Sleep(time.Second)
}

Run the program using the following command:

go run channel.go

The program output will be similar to this (order might vary slightly):

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

In this example:

  1. ch := make(chan int) creates an unbuffered channel. Unbuffered channels require both sender and receiver to be ready at the same time for communication to happen. This is called synchronization.
  2. go func() { ... }() starts a new goroutine. The go keyword executes the function concurrently with the main function.
  3. Inside the goroutine, ch <- 10 sends the value 10 to the channel. This operation will block until the main goroutine is ready to receive.
  4. In the main function, value := <-ch receives the value. This operation blocks until the goroutine sends a value.
  5. Once both are ready, the value is transferred, and both goroutines unblock and continue their execution. We added time.Sleep to ensure the program doesn't exit before the goroutine can print its final message.

Closing the Channel

When no more values will be sent on a channel, the sender can close it using the close() function. A common way to read all values from a channel until it's closed is to use a for range loop.

Let's modify channel.go:

package main

import "fmt"

func main() {
    // A buffered channel to hold multiple values
    ch := make(chan int, 3)

    // Start a goroutine to send data
    go func() {
        fmt.Println("Goroutine sending 10, 20, 30")
        ch <- 10
        ch <- 20
        ch <- 30
        // Close the channel after sending all values
        close(ch)
        fmt.Println("Goroutine closed the channel.")
    }()

    fmt.Println("Main is receiving data...")
    // Use a for-range loop to receive values until the channel is closed
    for value := range ch {
        fmt.Printf("Main received: %d\n", value)
    }
    fmt.Println("Main finished receiving.")
}

Run the program using the following command:

go run channel.go

The program output is as follows (order may vary slightly):

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.

This demonstrates a common pattern: the for range loop automatically handles checking if the channel is closed and exits when it is.

When working with closed channels, remember these key rules:

  • Sending to a closed channel will cause a runtime error (a panic).
  • Receiving from a closed, empty channel will immediately return the zero value of the channel's type.
  • Closing a channel that is already closed will cause a panic.
  • In general, only the sender should close a channel, never the receiver.

Channel Blocking

As we saw in the previous step, channel operations can block. This is a key feature that allows goroutines to synchronize. Let's look at this more closely.

Unbuffered Channels

Channels created with make(chan T) are unbuffered.

ch1 := make(chan int) // Unbuffered channel

An unbuffered channel has no capacity. A send operation on an unbuffered channel blocks the sending goroutine until another goroutine is ready to receive. Conversely, a receive operation blocks the receiving goroutine until another goroutine is ready to send. This creates a strong synchronization point, as we saw in our first example in the previous step.

Buffered Channels

Channels can also be buffered, meaning they have a capacity to hold a certain number of values before blocking.

ch2 := make(chan int, 5) // Buffered channel with a capacity of 5

With a buffered channel:

  • A send operation blocks only when the buffer is full.
  • A receive operation blocks only when the buffer is empty.

If the buffer is not full, a sender can send a value without a receiver being immediately ready. The value will be stored in the channel's queue.

Deadlock Example

What happens if we exceed a channel's capacity? The program will result in a deadlock. A deadlock occurs when all goroutines in a program are blocked, waiting for something that will never happen.

Create a file named channel1.go:

cd ~/project
touch channel1.go

Write the following code, which attempts to send two values to a channel with a capacity of one, all within the main goroutine:

package main

import "fmt"

func main() {
    ch := make(chan int, 1)
    ch <- 10 // This succeeds
    fmt.Println("Sent 10 to channel")
    ch <- 20 // This blocks forever because the channel is full
    fmt.Println("succeed")
}

Run the program using the following command:

go run channel1.go

The program output is as follows:

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

The program panics with a deadlock error. The main goroutine sends 10, which fills the buffer. It then tries to send 20 but blocks because the buffer is full. Since no other goroutine will ever receive from the channel, the main goroutine will be blocked forever, causing the Go runtime to detect a deadlock.

To fix this, the operation must be coordinated with another goroutine that can receive the value and make space in the buffer.

Unidirectional Channels

By default, a channel is bidirectional and can be read from or written to. Sometimes, we need to restrict the use of a channel, e.g., we want to ensure that a channel can only be written to or only be read from within a function. How can we achieve this?

We can explicitly declare a channel with restricted usage before initializing it.

Write-only Channel

A write-only channel means that within a particular function, the channel can only be written to and cannot be read from. Let's declare and use a write-only channel of type 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)
}

Run the program using the following command:

go run channel.go

The program output is as follows:

Data written to channel
Data received: 10

In this example, the writeOnly function restricts the channel to only writing.

Read-only Channel

A read-only channel means that within a particular function, the channel can only be read from and cannot be written to. Let's declare and use a read-only channel of type 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)
}

Run the program using the following command:

go run channel.go

The program output is as follows:

Data received from channel: 20

In this example, the readOnly function restricts the channel to only reading.

Combining Write-only and Read-only Channels

You can combine write-only and read-only channels to demonstrate how data flows through restricted channels:

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

Run the program using the following command:

go run channel.go

The program output is as follows:

Data written to channel
Data received from channel: 30

This example illustrates how a write-only channel can pass data to a read-only channel.

Summary

In this lab, we learned the fundamentals of channels, which include:

  • Types of channels and how to declare them
  • Methods to initialize channels
  • Operations on channels
  • The concept of channel blocking
  • Declaration of unidirectional channels