Jida's Blog

Concurrency in Go (2): channel

24th December 2024
Golang
Concurrency
Goroutine
Last updated:25th January 2025
6 Minutes
1112 Words

Intro

Channels are a core component of Go’s concurrency model. It allows goroutines to communicate safely and synchronize their execution. Channel is super useful in any of your programs thanks to its superpower.

You may imagine a channel in Go as a canal, where cargo ships travel from one side of the canal to the other, delivering goods in a orderly manner. Bu for a Go channel, it is a stream of information that flows between goroutines. We send data along the channel like loading cargo onto ships, and read the data downstream like unloading cargo at the destination.

Get started

Channel creation

Creating Go Channels is easy. Here would create a channel dataStream which allows any data (interface{} means any in Golang) to be read or written. Any we also create a channel intStream which allows only int data to be read or written.

1
dataStream := make(chan interface{})
2
intStream := make(chan int)

Channel read & write

read:

syntax: val, ok := <- ch

  • val: value read from the channel.
  • ok: a boolean
    • ok is true when:
      1. The channel is open and a value is successfully received from the channel.
      2. The channel is closed but there are buffered values in the channel
    • ok is false when:
      1. The channel is closed and there are no more values left to receive.

write:

syntax: ch <- val

example:

1
func chanReadWriteExample() {
2
intCh := make(chan int)
3
go func() {
4
intCh <- 1 // write to intCh
5
close(intCh)
6
}()
7
for _ = range 2 {
8
val, ok := <-intCh // read from intCh
9
fmt.Printf("(%v): %d\n", ok, val)
10
}
11
}
12
13
// output:
14
// (true): 1
15
// (false): 0

Chanel closing

Closing a channel is also one of the ways you can signal multiple goroutines simultaneously.

A closed channel can still be received from, but it will always return zero values after the last value has been received.

  • Exmple:

    1
    func closeChannelAsSignal() {
    2
    startRace := make(chan interface{})
    3
    wg := sync.WaitGroup{}
    4
    5
    for i := 0; i < 5; i++ {
    6
    wg.Add(1)
    7
    go func(id int) {
    8
    defer wg.Done()
    9
    <-startRace // Wait for start signal
    10
    fmt.Printf("Runner %d started (%s)\n", id, time.Now().Format("15:04:05.000"))
    11
    }(i)
    12
    }
    13
    14
    fmt.Printf("Preparing the race... (%s)\n", time.Now().Format("15:04:05.000"))
    15
    time.Sleep(2 * time.Second)
    5 collapsed lines
    16
    fmt.Printf("...GO! (%s)\n", time.Now().Format("15:04:05.000"))
    17
    18
    close(startRace) // Signal all runners to start
    19
    wg.Wait() // Wait for all runners to finish
    20
    }
    Terminal window
    1
    # outputs:
    2
    Preparing the race... (16:47:49.317)
    3
    ...GO! (16:47:51.317)
    4
    Runner 3 started (16:47:51.318)
    5
    Runner 4 started (16:47:51.318)
    6
    Runner 2 started (16:47:51.318)
    7
    Runner 1 started (16:47:51.318)
    8
    Runner 0 started (16:47:51.318)

Types of channels

1. Bidirection & unidirection

1) bidirectional channels

The channels we defined above are all bidirectional channels where we can read and write to the channel.

Bidirectional channel:

1
bidirectionalCh := make(chan interface{})

2) unidirectional channels

The unidirectional channel means you can either write to or read from the channel.

  • Write-only channel:

    1
    writeOnlyCh := make(chan<- interface{})
  • Read-only channel:

    1
    readOnlyCh := make(<-chan interface{})

Initializing unidirectional channels is relatively uncommon in real-world scenarios. They are more often used as function parameters or return types. You may ask but how? Go will implicitly convert bidirectional channels to unidirectional channels.

For example, in the Consumer-Producer pattern, consumers typically operate on a read-only channel, while producers use a write-only channel. We would explore that a bit later.

2. Unbuffered & buffered

1) unbuffered channels

  • Define: a channel created with a capacity of 0 to store data.

  • Create:

    1
    // The two channels have equivalent functionalities
    2
    a := make(chan int)
    3
    b := make(chan int, 0)
  • Analog: there is a narrow canal without docking area. Each ship (data) arriving at the canal must be immediately unloaded by a waiting docker (goroutine). If no docker is ready, the ship must wait at the entrance. Similarly, sending to an unbuffered channel blocks until another goroutine is ready to receive.

  • How it works:

    • Sender Attempts to Send: when the sending goroutine sends a value into an unbuffered channel, it waits until another goroutine is ready to receive that value.
    • Receiver Attempts to Receive: when the receing goroutine tries to receive from an unbuffered channel, it waits until another goroutine sends a value.
  • Case study: deadlock

    The following code leads to the deadlock. Since it is an unbuffered channel, when executing intCh <- 1, the main goroutine would be blocked until another goroutine is ready to receive the data. However, the receiver of the channel is the main goroutine again. The main goroutine would be blocked forever at intCh <- 1, causing the deadlock.

    1
    func main() {
    2
    intCh := make(chan int)
    3
    defer close(intCh)
    4
    5
    intCh <- 1
    6
    7
    val, ok := <-intCh
    8
    fmt.Printf("(%v): %d\n", ok, val)
    9
    }

2) buffered channels

  • Define: channels that are given a capacity to store data **when they’re instantiated.

  • Analog: there is a canal with a docking bay that can hold several ships. Ships can arrive and wait in the docks (buffer) even if no docke is immediately available. Ships are blocked at the entrance only when the docks are all occupied. Similarly, sending to a buffered channel only blocks when the buffer is full, and receiving blocks only when the buffer is empty.

  • How it works: sending to a buffered channel blocks only if the buffer is full, and receiving blocks only if the buffer is empty.

  • Create:

    1
    a := make(chan int, 5)
  • Example: Producer-Consumer

    In this example, the for loop over the channel breaks when:

    1. The channel is closed, and
    2. All values from the channel have been received.
    1
    func bufferedChannelExample() {
    2
    // Create a buffer to store output for synchronized printing
    3
    var stdoutBuff bytes.Buffer
    4
    defer stdoutBuff.WriteTo(os.Stdout)
    5
    6
    // Create buffered channel with capacity 4
    7
    intStream := make(chan int, 4)
    8
    9
    // Producer goroutine
    10
    go func() {
    11
    defer close(intStream)
    12
    defer fmt.Fprintln(&stdoutBuff, "Producer Done.")
    13
    for i := 0; i < 6; i++ {
    14
    fmt.Fprintf(&stdoutBuff, "+ Sending: %d (%s)\n", i, time.Now().Format("15:04:05.000"))
    15
    intStream <- i // Will block when buffer is full (after 4 items)
    10 collapsed lines
    16
    }
    17
    }()
    18
    19
    time.Sleep(100 * time.Millisecond)
    20
    21
    // Consumer: read until channel is closed and all buffered values are received
    22
    for integer := range intStream {
    23
    fmt.Fprintf(&stdoutBuff, "- Received %v (%s).\n", integer, time.Now().Format("15:04:05.000"))
    24
    }
    25
    }
    Terminal window
    1
    # outputs:
    2
    + Sending: 0 (16:32:32.793)
    3
    + Sending: 1 (16:32:32.793)
    4
    + Sending: 2 (16:32:32.793)
    5
    + Sending: 3 (16:32:32.793)
    6
    + Sending: 4 (16:32:32.793)
    7
    - Received 0 (16:32:32.894).
    8
    - Received 1 (16:32:32.894).
    9
    - Received 2 (16:32:32.894).
    10
    - Received 3 (16:32:32.894).
    11
    - Received 4 (16:32:32.894).
    12
    + Sending: 5 (16:32:32.894)
    13
    Producer Done.
    14
    - Received 5 (16:32:32.894).

Results of channel op

default

At first glance of this table, it looks as channes could be dangerous to use. However, after examining the motivation of these results and framing the use of channels, it becomes less scary and makes a lot of sense. Here we would introduce how to organize different types of channels.

The first thing we should do is to assgin channel ownership. The ownership is defined as being a goroutine that instantiates, writes and closes a channel. Then, channel owners have a write-access view into the channel (chan or chan<-), while the channel utilizers only have a read-only view (<-chan).

The second thing is that as a consumer, you should handle the fact that reads can and will block.

To see the full example of code, you can visit: goChannel.

References

Article title:Concurrency in Go (2): channel
Article author:Jida-Li
Release time:24th December 2024