17 Go Statements and Their Execution Rules - Part 2 #
Hello, I’m Haolin. Today, we will continue sharing the content about the execution rules of Go statements.
In the previous article, we discussed the role and significance of goroutines in the concurrent programming system of operating systems, as well as in the concurrent programming model of the Go language. Today, let’s continue this topic.
Knowledge Expansion #
Question 1: How can we make the main goroutine wait for other goroutines?
As I mentioned earlier, once the code in the main goroutine is executed, the current Go program will end, regardless of whether other goroutines are already running. So how can we make the main goroutine finish running after other goroutines have completed?
There are actually many ways to achieve this. Among them, the simplest and crudest way is to let the main goroutine “sleep” for a while.
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i)
}()
}
time.Sleep(time.Millisecond * 500)
After the for
statement, I call the Sleep
function from the time
package and pass the result of time.Millisecond * 500
as its argument. The Sleep
function’s role is to pause the current goroutine (in this case, the main goroutine) for a certain period of time until the specified resume time is reached.
We can pass a relative time to this function, just like the “500 milliseconds” I passed here. When Sleep
function is called, it calculates the absolute time based on the current time and the relative time, and waits until the future resume time. Once the resume time is reached, the current goroutine will wake up from the “sleep” state and continue executing the remaining code.
This method is feasible as long as the “sleep” time is not too short. However, the problem lies exactly here: how long should we make the main goroutine sleep? If the sleep time is too short, it may not be enough for other goroutines to complete. On the other hand, if the sleep time is too long, it is simply a waste of time and it is difficult to determine the appropriate time.
You may think that since it’s difficult to estimate the time, why don’t we let other goroutines tell us when they have finished running? This idea is good, but how do we do it?
Have you thought about using channels? First, let’s create a channel whose length should be the same as the number of goroutines we manually start. When each manually started goroutine is about to complete running, we need to send a value to this channel.
Note that these send expressions should be placed at the end of their corresponding go
function bodies. Correspondingly, we also need to receive elements from the channel at the end of the main
function, and the number of receptions should be the same as the number of manually started goroutines. You can find the specific code in the file demo39.go
.
One detail to note is that when declaring the channel sign
, I used chan struct{}
as its type. The type literal struct{}
is somewhat similar to the empty interface type interface{}
, which represents an empty struct type that has no fields and methods.
Note that there is only one representation of the struct{}
type value, which is: struct{}{}
. And it occupies 0
bytes of memory. More precisely, this value only exists once in the entire Go program. Although we can use this value literal multiple times, they are all the same value.
When we only use the channel as a medium to pass a simple signal, using struct{}
as its element type is the best choice. By the way, when I talked about “the door to using structures and their methods”, I left a related thinking question. You can go back and take a look.
Returning to the current question, is there a better method than using channels? If you are familiar with the sync
package in the standard library, you may think of the sync.WaitGroup
type. Yes, that is a better answer. However, I will discuss the specific usage when talking about the sync
package later.
Question 2: How can we make multiple goroutines we created run in a predetermined order?
Many times, when I ask this second expansion question following the main question and the first expansion question, candidates are often stumped.
So based on the code in the previous main question, how can we print the integers from 0
to 9
in natural order? You may say that we can do it without using goroutines. Yes, that works, but what if we don’t consider doing it that way? How would you solve this problem?
Of course, there are all kinds of answers from many candidates, some feasible, some not feasible, and some completely changes the original code. Below, I will share my thoughts and the answer in my mind. This answer is not necessarily the best one, and perhaps after reading it, you may come up with a better answer.
First, we need to slightly modify the go
function in the for
statement to make it accept an int
type parameter, and pass the value of the i
variable into it when calling it. To avoid modifying other parts of this go
function, we can name this parameter i
as well.
for i := 0; i < 10; i++ {
go func(i int) {
fmt.Println(i)
}(i)
}
Only in this way can Go guarantee that each goroutine can get a unique integer. The reason lies in the execution timing of the go
function.
I have mentioned it earlier. When the go
statement is executed, the parameter i
passed to the go
function will be evaluated first, thus obtaining the index of the current iteration. After that, no matter when the go
function is executed, this parameter value will not change. In other words, the fmt.Println
function called in the go
function will always print the index of the current iteration.
Next, let’s proceed with modifying the go
function in the for
statement.
fn := func() {
fmt.Println(i)
}
trigger(i, fn)
}(i)
}
I declared an anonymous function in the go
function and assigned it to the variable fn
. This anonymous function simply calls the fmt.Println
function to print the value of the i
parameter of the go
function.
After that, I called a function named trigger
and passed the i
parameter of the go
function and the variable fn
that was just declared to it as arguments. Note that the type of the local variable i
declared in the for
statement and the type of the i
parameter of the go
function have both changed from int
to uint32
. I will explain why in a moment.
Now let’s talk about the trigger
function. This function takes two parameters: a uint32
parameter i
and a func()
parameter fn
. You should remember that func()
represents a function type with no parameter declaration and no result declaration.
The trigger
function continuously retrieves the value of a variable named count
and checks if it is equal to the value of the i
parameter. If they are equal, it immediately calls the function represented by fn
, increments the value of the count
variable by 1
, and breaks out of the loop. Otherwise, it sleeps for a nanosecond before entering the next iteration.
Note that I use atomic operations to manipulate the count
variable because the trigger
function can be called concurrently by multiple goroutines, causing the variable count
and other non-local variables used by it to be shared among multiple user-level threads. Therefore, the operations on it would cause a race condition and compromise the concurrency safety of the program.
That’s why we should always protect such operations, and the sync/atomic
package declares many functions for atomic operations.
Also, because the atomic operation functions I chose have constraints on the type of the manipulated value, I unified the types of the count
variable and related variables and parameters from int
to uint32
.
Looking at the count
variable, the trigger
function, and the modified for
statement and go
function, what I want to achieve is to make the count
variable a signal whose value is always the next sequential number for the go
function that can be invoked.
This sequential number is actually the number for the current iteration when a goroutine is launched. It is precisely because of this that the actual execution order of the go
functions is completely consistent with the execution order of the go
statements. Additionally, the trigger
function implements a spin. It keeps checking unless the condition is satisfied.
Lastly, because I still want the main goroutine to run last, I need to add one more line of code. But since I have the trigger
function, I didn’t use channels again.
Calling the trigger
function can achieve the same effect. Since the value of the count
variable will always be 10
when all the manually launched goroutines have finished running, I use 10
as the first parameter value. And since I don’t want to print this 10
, I use a function that does nothing as the second parameter value.
In summary, through the above modifications, I made the asynchronously initiated go
functions executed synchronously (or in the specified order). You can also try it yourself and feel it.
Summary
In this article, I continue the main question from the previous article and discuss how to exert more control over the result of our programs.
If the main goroutine ends prematurely, the functionality of our concurrent program may not be fully completed. Therefore, we often need to intervene using some means, such as calling the time.Sleep
function or using channels. We will discuss more advanced techniques in later articles.
In addition, the actual execution order of the go
functions is often different from the order in which the go
statements (or the launch order of goroutines) are executed, and the default execution order is unpredictable. How can we make these two orders consistent? In fact, there are many complex ways to do this, but they may completely change the original code. Here, I provided a relatively simple and clear modification scheme for your reference.
In short, I hope that through the basic knowledge mentioned above and three coherent questions, I can help you connect the main line. This should allow you to more quickly understand goroutines and the concurrency programming model behind them, and use the go
statement more skillfully.
Exercises
- What functions related to the model’s three elements, G, P, and M, are provided in the
runtime
package? (The content of the model’s three elements is discussed in the previous article)
Click here to view the detailed code accompanying the Go language column.