Channel Primitives in Golang

GolangGolangBeginner
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

Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL go(("`Golang`")) -.-> go/BasicsGroup(["`Basics`"]) go(("`Golang`")) -.-> go/DataTypesandStructuresGroup(["`Data Types and Structures`"]) go(("`Golang`")) -.-> go/ConcurrencyGroup(["`Concurrency`"]) go/BasicsGroup -.-> go/values("`Values`") go/BasicsGroup -.-> go/variables("`Variables`") go/DataTypesandStructuresGroup -.-> go/structs("`Structs`") go/ConcurrencyGroup -.-> go/goroutines("`Goroutines`") go/ConcurrencyGroup -.-> go/channels("`Channels`") go/ConcurrencyGroup -.-> go/select("`Select`") subgraph Lab Skills go/values -.-> lab-149096{{"`Channel Primitives in Golang`"}} go/variables -.-> lab-149096{{"`Channel Primitives in Golang`"}} go/structs -.-> lab-149096{{"`Channel Primitives in Golang`"}} go/goroutines -.-> lab-149096{{"`Channel Primitives in Golang`"}} go/channels -.-> lab-149096{{"`Channel Primitives in Golang`"}} go/select -.-> lab-149096{{"`Channel Primitives in Golang`"}} end

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

Sending Data

Channel operations have their own unique ways. In general, when working with other data types, we use = and index to access and manipulate the data.

But for channels, we use <- for both sending and receiving operations.

In a channel, writing data is called sending, which means sending a piece of data into the channel.

So how do we send data?

In channel.go, write the following code:

package main

import "fmt"

func main() {
    ch := make(chan int, 3)
    ch <- 10
    fmt.Println(ch)
}

Run the program using the following command:

go run channel.go

The program output is as follows:

0xc0000b4000

Although we put data into the channel, we cannot see it directly from the output of the channel value.

This is because the data in the channel still needs to be received before it can be used.

Receiving Data

What does it mean to receive from a channel? When we learn about maps or slices, we can retrieve any data from these data types based on indices or keys.

In the case of channels, we can only retrieve the earliest value that has entered the channel.

After the value is received, it will no longer exist in the channel.

In other words, receiving from a channel is like querying and deleting data in other data types.

In channel.go, write the following code:

package main

import "fmt"

func main() {
    ch := make(chan int, 3)
    ch <- 10
    ch <- 20
    fmt.Println(<-ch)
    fmt.Println(<-ch)
}

Run the program using the following command:

go run channel.go

The program output is as follows:

10
20

We can see that the value 10, which entered the channel first, is output first, and after it is output, it no longer exists in the channel.

The value 20, which entered later, will be output later, highlighting the first in, first out principle of the channel.

Closing the Channel

After a channel is no longer needed, it should be closed. However, when closing a channel, we need to pay attention to the following points:

  • Sending a value to a closed channel will cause a runtime error.

    package main
    
    import "fmt"
    
    func main() {
        ch := make(chan int, 3)
        ch <- 10
    
        fmt.Println(<-ch)
    
        close(ch)
        ch <- 20
    }

    The program output is as follows:

    10
    panic: send on closed channel
    
    goroutine 1 [running]:
    main.main()
        /home/labex/project/channel.go:10 +0x1a0
    exit status 2
  • Receiving from a closed channel will continue to retrieve values until it is empty.

    package main
    
    import "fmt"
    
    func main() {
        ch := make(chan int, 3)
    
        ch <- 10
        ch <- 20
        ch <- 30
    
        close(ch)
    
        fmt.Println(<-ch)
        fmt.Println(<-ch)
        fmt.Println(<-ch)
    }

    Run the program using the following command:

    go run channel.go

    The program output is as follows:

    10
    20
    30
  • Receiving from a closed empty channel will receive the default initial value of the corresponding data type.

    package main
    
    import "fmt"
    
    func main() {
        ch := make(chan int, 3)
    
        ch <- 10
        ch <- 20
        ch <- 30
    
        close(ch)
    
        fmt.Println(<-ch)
        fmt.Println(<-ch)
        fmt.Println(<-ch)
        fmt.Println(<-ch)
        fmt.Println(<-ch)
    }

    Run the program using the following command:

    go run channel.go

    The program output is as follows:

    10
    20
    30
    0
    0
  • Closing a closed channel will throw an error.

    package main
    
    import "fmt"
    
    func main() {
        ch := make(chan int, 3)
        ch <- 10
        close(ch)
        close(ch)
        fmt.Println(<-ch)
    }

    The program output is as follows:

    panic: close of closed channel
    
    goroutine 1 [running]:
    main.main()
        /home/labex/project/channel.go:9 +0x75
    exit status 2

Channel Blocking

Observant students may have noticed that when we introduced channel declarations and initialization, we did not specify the channel's capacity:

package main

import "fmt"

func main() {
    // Stores integer data in the channel
    ch1 := make(chan int) // Same below

    // Stores boolean data in the channel
    ch2 := make(chan bool)

    // Stores []int data in the channel
    ch3 := make(chan []int)

    fmt.Println(ch1, ch2, ch3)
}

But when we demonstrated channel operations, we specified the channel's capacity:

package main

import "fmt"

func main() {
    // Specify the channel capacity as 3
    ch := make(chan int, 3)
    ch <- 10
    ch <- 20
    fmt.Println(<-ch)
    fmt.Println(<-ch)
}

For channels without a specified capacity, they are called unbuffered channels, which have no buffer space.

If a sender or receiver is not ready, the first operation will block until the other operation becomes ready.

chan1 := make(chan int) // Unbuffered channel of type int

For channels with a specified capacity and buffer space, they are called buffered channels.

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

They function like a queue, adhering to the First In First Out (FIFO) rule.

For buffered channels, we can use send operations to append elements to the end of the queue and use receive operations to remove elements from the head of the queue.

What happens if we put more data into a buffered channel than its capacity? Let's check it out. Write the following code into the channel1.go file:

cd ~/project
touch channel1.go
package main

import "fmt"

func main() {
    ch := make(chan int, 1)
    ch <- 10
    ch <- 20
    fmt.Println("succeed")
}

Run the program using the following command:

go run channel1.go

The program output is as follows:

fatal error: all goroutines are asleep - deadlock!

We find that the program deadlocks because the channel is already full.

To solve this problem, we can extract the data from the channel first. Write the following code in channel.go:

package main

import "fmt"

func main() {
    ch := make(chan int, 1)
    ch <- 10
    fmt.Println("Data extracted:", <-ch)

    ch <- 20
    fmt.Println("succeed")
}

Run the program using the following command:

go run channel.go

The program output is as follows:

Data extracted: 10
succeed

We can continuously use the channel with a limited capacity by adopting a strategy of taking, using, taking, and using again.

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

Other Golang Tutorials you may like