Concurrency Primitives in Go

GoGoBeginner
Practice Now

Introduction

One of the most attractive features of the Go language is its native support for concurrent programming. It has high 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 that can be thought of as threads. However, they are lighter weight than threads and consume fewer resources when switching between them.

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(("`Go`")) -.-> go/BasicsGroup(["`Basics`"]) go(("`Go`")) -.-> go/DataTypesandStructuresGroup(["`Data Types and Structures`"]) go(("`Go`")) -.-> go/FunctionsandControlFlowGroup(["`Functions and Control Flow`"]) go(("`Go`")) -.-> go/ObjectOrientedProgrammingGroup(["`Object-Oriented Programming`"]) go(("`Go`")) -.-> go/ConcurrencyGroup(["`Concurrency`"]) go/BasicsGroup -.-> go/variables("`Variables`") go/DataTypesandStructuresGroup -.-> go/slices("`Slices`") go/FunctionsandControlFlowGroup -.-> go/functions("`Functions`") go/ObjectOrientedProgrammingGroup -.-> go/struct_embedding("`Struct Embedding`") go/ConcurrencyGroup -.-> go/channels("`Channels`") subgraph Lab Skills go/variables -.-> lab-149096{{"`Concurrency Primitives in Go`"}} go/slices -.-> lab-149096{{"`Concurrency Primitives in Go`"}} go/functions -.-> lab-149096{{"`Concurrency Primitives in Go`"}} go/struct_embedding -.-> lab-149096{{"`Concurrency Primitives in Go`"}} go/channels -.-> lab-149096{{"`Concurrency Primitives in Go`"}} end

Overview of Channels

Channels are a special type of 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.

Channel Initialization

Similar to dictionaries, 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)
}

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 dictionaries, directly outputting the channel itself does not reveal its contents.

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

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

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/project/hello.go:10 +0x1a0
    exit status 2

    As we can see, before closing the channel, sending and receiving the data 10 works normally. But after the channel is closed, writing to it will throw an error.

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

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

    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/project/hello.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:

package main

import "fmt"

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

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

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 a write-only channel of type int:

var ch chan<- int

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 a read-only channel of type int:

var ch <-chan int

Summary

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

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

In the next section, we will learn about interfaces in Go.

Other Go Tutorials you may like