11 Advanced Tricks With Channels

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:

  1. The above for loop continuously tries to retrieve element values from the intChan2 channel. Even if intChan2 has been closed, it will continue to execute until all remaining element values have been retrieved.
  2. Typically, when the intChan2 channel has no element values, this for loop will be blocked at the line with the for keyword, waiting for new element values to be available. However, because the getIntChan function closes intChan2 in advance, it will directly exit after retrieving all the element values from intChan2.
  3. If the value of intChan2 is nil, this for loop will be permanently blocked at the line with the for 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:

  1. 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.
  2. If there is no default branch, the select statement will be blocked once none of the case expressions satisfies the evaluation condition. It will remain blocked until at least one case expression satisfies the condition.
  3. 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.
  4. The select statement can only evaluate each case expression once. Therefore, if we want to continuously or periodically operate on the channels, we often need to embed the select statement in a for statement. But be aware that simply using the break statement in the branches of the select statement will only end the execution of the current select statement and have no effect on the outer for statement. This incorrect usage may cause the for 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:

  1. 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 the case 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 a case expression is evaluated, the multiple expressions it contains will be evaluated from left to right in order.

  2. The case expressions in the candidate branches included in the select 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 the select 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.

  3. For each case expression, if the sending expression or receiving expression is in a blocking state when being evaluated, the evaluation of the case expression will fail. In this case, we can say that the candidate branch where this case expression is located does not meet the selection conditions.

  4. Only when all the case expressions in the select 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, the select 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, the select statement (or the goroutine it is in) will be awakened, and the candidate branch will be executed.

  5. 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 the select statement discovers this situation when it is awakened, it will still do so.

  6. 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.

  7. Each execution of the select statement, including the evaluation of case expressions and branch selection, is independent. However, whether its execution is concurrency-safe depends on whether the case 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.

  1. If it is found in the select statement that a channel has been closed, how to shield the branch it is in?
  2. When the select statement is used together with the for statement, how to directly exit the outer for statement?

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