10 Basic Operations of Channels

10 Basic Operations of Channels #

As the most distinctive data type in the Go language, channels can work together with goroutines (also known as “go routines”), representing the unique concurrency programming pattern and programming philosophy of Go.

Don’t communicate by sharing memory; share memory by communicating.

This is a famous saying by Rob Pike, one of the main creators of the Go language. It fully reflects the most important programming concept of Go. And the channel type is a perfect implementation of the second half of the quote. We can use channels to pass data between multiple goroutines.

Introduction: Basics of Channels #

The values of channel types themselves are concurrency-safe. This is the only type in Go that is built-in and guaranteed to be concurrency-safe. It is very simple to use and does not burden our minds.

When declaring and initializing a channel, we need to use the built-in function make of Go. Just like initializing a slice with make, the first argument we pass to this function should be a type literal representing the specific type of the channel.

When declaring a variable of channel type, we first need to determine the element type of the channel, which determines what type of data we can transmit through the channel.

For example, the type literal chan int, where chan represents the keyword for channel type, and int indicates the element type of the channel. Another example is chan string, which represents a channel type with an element type of string.

When initializing a channel, in addition to the required type literal as a parameter, the make function can also receive an optional parameter of type int.

The latter is used to represent the capacity of the channel. The channel capacity refers to the maximum number of element values that the channel can buffer. Therefore, although this parameter is of type int, it cannot be less than 0.

When the capacity is 0, we can call it an unbuffered channel. When the capacity is greater than 0, we can call it a buffered channel. Unbuffered channels and buffered channels have different ways of data transmission, which I will explain later.

A channel is equivalent to a First-In-First-Out (FIFO) queue. This means that the element values in the channel are strictly arranged in the order they are sent, and the element value sent first will always be received first. The sending and receiving of element values require the use of the <- operator. We can also call it the sending and receiving operator. The left arrow immediately followed by a minus sign vividly represents the direction of element value transfer.

package main

import "fmt"

func main() {
	ch1 := make(chan int, 3)
	ch1 <- 2
	ch1 <- 1
	ch1 <- 3
	elem1 := <-ch1
	fmt.Printf("The first element received from channel ch1: %v\n",
		elem1)
}

In the file demo20.go, I declare and initialize a channel ch1 with an element type of int and a capacity of 3. Using three statements, I sequentially send three element values 2, 1, and 3 to the channel.

The statements here should be written as follows: type the name of the channel variable (such as ch1), followed by the sending and receiving operator <-, and the element value to be sent (such as 2). It is recommended to separate these three elements with spaces.

This clearly expresses the semantics of “this element value will be sent to the channel”. Since the capacity of the channel is 3, I can continuously send three values to the channel when it does not contain any element values, and these three values will be buffered in the channel.

When we need to receive an element value from the channel, we also use the sending and receiving operator <-, but this time it needs to be written on the left side of the variable name, expressing the semantics of “receiving an element value from the channel”.

For example: <-ch1, which can also be called a receive expression. In general, the result of the receive expression will be an element value from the channel.

If we need to store the obtained element value, we need to add assignment symbols (= or :=) and the name of the variable used for storing the value to the left side of the receive expression. Therefore, the statement elem1 := <-ch1 will receive the first element 2 entered into ch1 and store it in the variable elem1.

Now let’s look at a related question. The question of the day is: What are the basic characteristics of sending and receiving operations on channels?

This question hides many knowledge points behind it. Let’s take a look at a typical answer.

Their basic characteristics are as follows:

  1. For the same channel, sending operations are mutually exclusive, and receiving operations are also mutually exclusive.
  2. The handling of element values in both sending and receiving operations is indivisible.
  3. Sending operations are blocked until they are fully completed. The same goes for receiving operations.

Problem Analysis #

Let’s start with the first basic feature. At any given time, the runtime system of the Go language (referred to as the runtime system) will only execute one of the arbitrary number of send operations on the same channel.

Other send operations on the same channel can only be executed after the element value has been fully copied into the channel.

Similarly, at any given time, the runtime system will only execute one of the arbitrary number of receive operations on the same channel.

Other receive operations on the same channel can only be executed after the element value has been fully removed from the channel. This applies even if these operations are executed concurrently.

Here, concurrent execution can be understood as multiple code blocks running in different goroutines and having the opportunity to be executed within the same time period.

Additionally, for the same element value in the channel, send and receive operations are also mutually exclusive. For example, although there may be an element value being copied into the channel but not yet fully copied, it will never be seen or taken by the receiving side.

One detail to note here is that the element value is copied when entering the channel. More specifically, it is not the element value on the right side of the receive operator that enters the channel, but its copy.

On the other hand, when an element value exits the channel to the outside, it is moved. This move operation actually consists of two steps: the first step is to generate a copy of the element value in the channel and prepare it for the receiver, and the second step is to remove the element value in the channel.

Now let’s look at the second basic feature based on this detail. Here, “indivisible” means that the handling of element values is always done in one go, without interruption.

For example, a send operation either has not finished copying the element value or has finished completely, and there will never be a situation where only part of the element value is copied.

Similarly, after preparing a copy of the element value, a receive operation will always delete the original value in the channel, and there will never be any residual values left in the channel.

This is done to ensure the integrity of the element values in the channel and the uniqueness of the channel operations. For the same element value in the channel, it can only be put in by a send operation and can only be taken out by a receive operation.

Let’s move on to the third basic feature. In general, a send operation consists of two steps: “copying the element value” and “placing the copy inside the channel”.

The code that initiates this send operation will be blocked until these two steps are completed. In other words, the code after it will not have a chance to execute until the blockage of this code is released.

More specifically, after the channel completes the send operation, the runtime system will notify the goroutine where this code is located so that it can compete for the opportunity to continue running the code.

Additionally, a receive operation usually includes three steps: “copying the element value inside the channel”, “placing the copy to the receiving end”, and “deleting the original value”.

The code that initiates this operation will also be blocked until all these steps are completed, and it will be unblocked when the goroutine receives a notification from the runtime system and regains the opportunity to run.

At this point, you may already realize that such blocked code is actually used to achieve mutual exclusion of operations and integrity of element values.

Next, let’s discuss a question related to blocking of channel operations.

Knowledge Expansion #

Question 1: When can send and receive operations be blocked for a long time?

First, let’s talk about the case of buffered channels. If a channel is full, all send operations on it will be blocked until an element value is received from the channel.

In this case, the channel will notify the goroutine that is waiting for the earliest send operation, and the latter will perform the send operation again.

Since the goroutines containing the send operations in this situation will enter the sending wait queue inside the channel in sequence after being blocked, the notification order is always fair.

On the contrary, if the channel is empty, all receive operations on it will be blocked until a new element value appears in the channel. At this time, the channel will notify the goroutine that is waiting for the earliest receive operation and let it perform the receive operation again.

Therefore, all goroutines waiting for receive operations will be put into the receiving wait queue inside the channel in the order of their occurrence.

For unbuffered channels, the situation is simpler. Whether it is a send operation or a receive operation, they will be blocked when executed initially until the paired operation is also executed and then continue to transmit. From this, it can be seen that unbuffered channels are used to transmit data synchronously. That is, data will only be transmitted when both the sender and receiver are connected.

In addition, the data is directly copied from the sender to the receiver, and there is no intermediate transfer using unbuffered channels. In comparison, buffered channels use asynchronous data transmission.

In most cases, buffered channels act as middleware between senders and receivers. As mentioned earlier, the element value is first copied from the sender to the buffered channel, and then copied from the buffered channel to the receiver.

However, if a send operation finds that there is a waiting receive operation in an empty channel while it is being executed, it will directly copy the element value to the receiver.

The above describes what will happen when the channels are used correctly. Below, I specifically explain the blocking caused by the incorrect use of channels.

For a channel with a value of nil, regardless of its specific type, both send and receive operations on it will be permanently blocked. Any code in the goroutine to which they belong will no longer be executed.

Note that since channel types are reference types, their zero value is nil. In other words, when we only declare a variable of that type but do not initialize it with the make function, the value of that variable will be nil. We must not forget to initialize the channel!

You can take a look at demo21.go, where I listed several situations that can cause blocking using code.

Question 2: When will send and receive operations cause a panic?

For an initialized but unclosed channel, send and receive operations will not cause a panic. However, once a channel is closed, performing a send operation on it will cause a panic.

In addition, if we try to close a channel that has already been closed, it will also cause a panic. Note that receive operations can detect the closure of the channel and can safely exit.

Specifically, when we assign the results of a receive expression to two variables, the type of the second variable is always bool. If its value is false, it means that the channel has been closed and there are no more element values to be taken.

Note that if there are still element values in the channel when it is closed, the first result of the receive expression will still be one of the element values in the channel, and the second result value will always be true.

Therefore, there may be a delay in determining whether a channel is closed by using the second result value of the receive expression.

Because the send and receive operations of a channel have the above characteristics, unless there are special measures, we should never let the receiver close the channel, but let the sender do it. There is a simple pattern for this in demo22.go for reference.

Summary #

Today we talked about some common operations of channels, including initialization, sending, receiving, and closing. The channel type is unique to the Go language, so you might feel unfamiliar with it at first. You need to remember and carefully appreciate some of the rules and mysteries.

First, the significance of setting the capacity when initializing a channel, which can sometimes give the channel different behavioral patterns. It is also crucial for us to understand the basic characteristics of sending and receiving operations on channels.

This involves when they will be mutually exclusive, when they will cause blocking, when they will cause panic, and the order in which they send and receive element values, how they ensure the integrity of element values, how many times element values are typically copied, and so on.

Finally, don’t forget that channels are also important members in Go language’s concurrent programming patterns.

Thought Questions #

I hope you can obtain the answers to the following questions through experiments.

  1. What does the length of a channel represent? When will the length of a channel be the same as its capacity?
  2. When elements are passed through a channel, are they copied with shallow or deep copying?

Click here to view the detailed code accompanying the Go Language column article.