11 Advanced Tricks with Channels #
We have already discussed the basic operations of channels and the rules behind them. Today, I will talk about the advanced usage of channels.
First, let’s talk about one-way channels. When we talk about “channels,” we are referring to bi-directional channels, which means they can both send and receive.
A one-way channel, on the other hand, can only send or only receive. Whether a channel is bi-directional or one-way is indicated by its type literal.
Do you remember the receive operator <-
that we mentioned in the previous article? If we use it in the type literal of a channel, it no longer represents the action of “sending” or “receiving,” but indicates the direction of the channel.
For example:
var uselessChan = make(chan<- int, 1)
I declare and initialize a variable named uselessChan
. The type of this variable is chan<- int
, with a capacity of 1
.
Please pay attention to the <-
right after the keyword chan
, which indicates that this channel is one-way and can only send, but not receive.
Similarly, if this operator is right before chan
, it means that the channel can only receive, but not send. So, the former can be referred to as a send-only channel, and the latter can be referred to as a receive-only channel.
Note that, in correspondence with send and receive operations, “send” and “receive” here are spoken from the perspective of the code that operates the channel.
From the name of the variable above, you can also guess that such channels are useless. Channels exist to transmit data, so declaring a channel that can only be used by one end (send or receive) serves no purpose. So, what is the purpose of one-way channels?
Question: What is the value of one-way channels?
You can think about it yourself before reading further.
Typical Answer
In general, the main purpose of one-way channels is to constrain the behavior of other code.
Question Analysis
This needs to be explained from two aspects, both related to function declarations. Let’s start with the following code:
func SendInt(ch chan<- int) {
ch <- rand.Intn(1000)
}
I used the func
keyword to declare a function named SendInt
. This function accepts only one parameter of type chan<- int
. The code inside this function can only send element values to the parameter ch
, but cannot receive element values from it. This constrains the behavior of the function.
You may ask, if I write a function myself, I can definitely determine how to operate the channel. Why do I need further constraints? Well, this example may be too simple. In actual scenarios, such constraints are typically found in the definition of a method in the declaration of an interface type. Take a look at this interface type declaration called Notifier
:
type Notifier interface {
SendInt(ch chan<- int)
}
In the curly braces of the interface type declaration, each line represents a method definition. Method definitions in an interface are similar to function declarations, but only include the method name, parameter list, and result list.
If a type wants to be an implementation type of an interface type, it must implement all the methods defined in that interface. Therefore, if we use a one-way channel type in the definition of a method, it is like imposing constraints on all its implementations.
Here, the SendInt
method in the Notifier
interface only accepts a send channel as a parameter, so the SendInt
method in all implementation types of this interface will be restricted. This kind of constraint is very useful, especially when we are writing template code or extensible libraries.
By the way, when calling the SendInt
function, we only need to pass a bidirectional channel with matching element types to it. There is no need to use a send channel because in this case, Go automatically converts the bidirectional channel to the unidirectional channel required by the function.
intChan1 := make(chan int, 3)
SendInt(intChan1)
On the other hand, we can also use unidirectional channels in the result list of a function declaration, as shown below:
func getIntChan() <-chan int {
num := 5
ch := make(chan int, num)
for i := 0; i < num; i++ {
ch <- i
}
close(ch)
return ch
}
The getIntChan
function returns a channel of type <-chan int
, which means that the program obtaining this channel can only receive element values from the channel. This actually imposes a constraint on the caller of the function.
In addition, we can also declare function types in Go, and if a unidirectional channel is used in the function type, it is equivalent to constraining all functions that implement this function type.
Let’s take a look at the code that calls getIntChan
:
intChan2 := getIntChan()
for elem := range intChan2 {
fmt.Printf("The element in intChan2: %v\n", elem)
}
I assign the result value obtained from calling getIntChan
to the variable intChan2
, and then I use a for
loop to retrieve all the elements from the channel and print them.
This for
loop can also be called a for
loop with a range
clause. I will explain its usage specifically when talking about the for
loop later. For now, you only need to know three things about it:
- The above
for
loop continuously tries to retrieve element values from theintChan2
channel. Even ifintChan2
has been closed, it will continue to execute until all remaining element values have been retrieved. - Typically, when the
intChan2
channel has no element values, thisfor
loop will be blocked at the line with thefor
keyword, waiting for new element values to be available. However, because thegetIntChan
function closesintChan2
in advance, it will directly exit after retrieving all the element values fromintChan2
. - If the value of
intChan2
isnil
, thisfor
loop will be permanently blocked at the line with thefor
keyword.
This is the way of using a for
loop with a range
clause in conjunction with a channel. However, it is a widely used statement that can also be used to retrieve elements from some other types of values. In addition to this, Go also has a statement select
specifically for working with channels.
Knowledge Extension
Question 1: How are the select
statement and channels used together, and what should be noted?
The select
statement can only be used with channels, and it generally consists of several branches. Each time the statement is executed, only the code in one branch will be run.
The branches of a select
statement can be divided into two types: candidate branches and default branch. Candidate branches always start with the keyword case
, followed by a case
expression and a colon. We can then write the code to be executed when the branch is selected from the next line.
The default branch is actually the default case, because it is only executed when no candidate branch is selected. It starts with the keyword default
followed directly by a colon. Similarly, we can write the statements to be executed on the line below default:
.
Since the select
statement is designed for channels, each case
expression can only contain expressions that operate on channels, such as receive expressions.
Of course, if we need to assign the result of the receive expression to a variable, we can also write it as an assignment statement or short variable declaration. Here is a simple example.
// Prepare several channels.
intChannels := [3]chan int{
make(chan int, 1),
make(chan int, 1),
make(chan int, 1),
}
// Select a channel randomly and send an element value to it.
index := rand.Intn(3)
fmt.Printf("The index: %d\n", index)
intChannels[index] <- index
// The branch corresponding to the channel with a value to receive will be executed.
select {
case <-intChannels[0]:
fmt.Println("The first candidate case is selected.")
case <-intChannels[1]:
fmt.Println("The second candidate case is selected.")
case elem := <-intChannels[2]:
fmt.Printf("The third candidate case is selected, the element is %d.\n", elem)
default:
fmt.Println("No candidate case is selected!")
}
I first prepared three channels of type chan int
with a capacity of 1, and stored them in an array called intChannels
.
Then, I randomly selected an integer in the range [0, 2] as the index to select a channel from the above array and send an element value to it.
Finally, I used a select
statement with three candidate branches, each attempting to receive an element value from the three channels mentioned above. The branch corresponding to the channel with a value will be executed. There is also a default branch at the end, but it cannot be selected in this case.
When using the select
statement, there are a few things we need to pay attention to:
- If a default branch is added as in the example above, the
select
statement will not be blocked regardless of whether the expressions involving channel operations are blocked or not. If all those expressions are blocked, or in other words, none of them satisfies the evaluation condition, the default branch will be selected and executed. - If there is no default branch, the
select
statement will be blocked once none of thecase
expressions satisfies the evaluation condition. It will remain blocked until at least onecase
expression satisfies the condition. - Remember? We may directly receive a zero value of the element type from a channel because the channel has been closed. Therefore, in many cases, we need to judge whether the channel has been closed by using the second result value of the receive expression. Once we find a channel has been closed, we should timely shield the corresponding branch or take other measures. This is beneficial for program logic and performance.
- The
select
statement can only evaluate eachcase
expression once. Therefore, if we want to continuously or periodically operate on the channels, we often need to embed theselect
statement in afor
statement. But be aware that simply using thebreak
statement in the branches of theselect
statement will only end the execution of the currentselect
statement and have no effect on the outerfor
statement. This incorrect usage may cause thefor
statement to run indefinitely.
Here’s a simple example.
intChan := make(chan int, 1)
// Close the channel after one second.
time.AfterFunc(time.Second, func() {
close(intChan)
})
select {
case _, ok := <-intChan:
if !ok {
fmt.Println("The candidate case is closed.")
break
}
fmt.Println("The candidate case is selected.")
}
I first declare and initialize a channel called intChan
, and then use the AfterFunc
function in the time
package to close the channel after one second.
The select
statement has only one candidate branch, where I use the second result value of the receive expression to check if the intChan
channel is closed. If the result is positive, the break
statement is used to immediately end the execution of the current select
statement.
This example, as well as the previous one, can be found in the demo24.go file. You should run it to see the result.
Some of the above points involve the branch selection rules of the select
statement. I think it is necessary to summarize and summarize these rules separately.
Question 2: What are the branch selection rules of the select
statement?
The rules are as follows:
-
For each
case
expression, there is at least one sending expression representing the send operation or one receiving expression representing the receive operation, and may also include other expressions. For example, if thecase
expression is a short variable declaration containing a receive expression, the expression on the left side of the assignment operator can be one or two expressions, but the result of the expression must be assignable. When such acase
expression is evaluated, the multiple expressions it contains will be evaluated from left to right in order. -
The
case
expressions in the candidate branches included in theselect
statement are evaluated at the beginning of the execution of the statement, and the evaluation order follows the order in which the code is written from top to bottom. Combining the previous rule, when theselect
statement starts to execute, the leftmost expression in the uppermost candidate branch is evaluated first, followed by the expression on its right. Only when all the expressions in the uppermost candidate branch have been evaluated, will the expressions in the second candidate branch from the top be evaluated from left to right, followed by the third candidate branch, the fourth candidate branch, and so on. -
For each
case
expression, if the sending expression or receiving expression is in a blocking state when being evaluated, the evaluation of thecase
expression will fail. In this case, we can say that the candidate branch where thiscase
expression is located does not meet the selection conditions. -
Only when all the
case
expressions in theselect
statement have been evaluated, it will start to select the candidate branches. At this time, it will only select and execute the candidate branch that meets the selection conditions. If none of the candidate branches meet the selection conditions, the default branch will be executed. If there is no default branch at this time, theselect
statement will enter a blocking state immediately until at least one candidate branch meets the selection conditions. Once a candidate branch meets the selection conditions, theselect
statement (or the goroutine it is in) will be awakened, and the candidate branch will be executed. -
If the
select
statement finds that multiple candidate branches meet the selection conditions at the same time, it will use a pseudo-random algorithm to select and execute one of them. Note that even if theselect
statement discovers this situation when it is awakened, it will still do so. -
There can only be one default branch in a
select
statement. And the default branch will only be executed when no candidate branch can be selected, regardless of its position in the code. -
Each execution of the
select
statement, including the evaluation ofcase
expressions and branch selection, is independent. However, whether its execution is concurrency-safe depends on whether thecase
expressions and branches contain concurrency-unsafe code.
I have put the examples related to the above rules in the demo25.go file. You must try to run it and try to explain its output based on the rules above.
Summary
Today, we first talked about the representation of unidirectional channels, and the operator “<-
” is still the key. If I have to summarize the meaning of unidirectional channels in just one word, it is “constraint”, that is, constraints on code.
We can use the for
statement with the range
clause to get data from a channel, and we can manipulate channels with the select
statement.
The select
statement is specifically designed for channels, and it can contain multiple candidate branches, each of which contains a send or receive operation for a certain channel in its case
expression.
When the select
statement is executed, it selects a branch and executes its code based on a set of branch selection rules. If none of the candidate branches are selected, the default branch (if any) will be executed. Note that the blocking of send and receive operations is an important basis for the branch selection rules.
Exercises
Today’s exercises are extended from the clues in the above content.
- If it is found in the
select
statement that a channel has been closed, how to shield the branch it is in? - When the
select
statement is used together with thefor
statement, how to directly exit the outerfor
statement?
Click here to view the detailed code associated with the Go Language column article.