17 Go Statements and Their Execution Rules Part 2

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

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