16 Go Statements and Their Execution Rules Part 1

16 Go Statements and Their Execution Rules - Part 1 #

You’re awesome! You have completed all the content about Go language data types. I believe that you not only know how to efficiently use the built-in data types in Go, but also understand how to create your own data types correctly.

You have indeed gained a lot of knowledge about Go programming. However, if you really want to master Go, you need to know some of its special processes and syntax.

Especially the go statement, which is the biggest feature of Go. It represents the most important programming philosophy and concurrent programming pattern in Go.

Let’s review the following sentence:

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

From the perspective of Go programming, this means: don’t communicate by sharing data, on the contrary, share data by communicating.

We already know that values of channel types can be used to share data by communicating. More specifically, they are often used to pass data between different goroutines. But what exactly does goroutine represent?

In simple terms, a goroutine represents a user-level thread in the concurrency programming model. You may already know that the operating system itself provides processes and threads, which are two tools for concurrent execution of programs.

Introduction: Processes and Threads #

A process describes the execution process of a program and represents a running program. In other words, a process is actually a product of a program running. If the code lying there quietly is the program, then the code running and performing existing functions can be called a process.

Why can our computers run so many applications at the same time? Why can our phones have so many apps refreshing in the background simultaneously? This is because there are multiple processes representing different applications or apps running simultaneously on their operating systems.

Let’s talk about threads. First of all, threads are always within a process, and they can be viewed as the control flow (or the flow of code execution) running in a process.

A process contains at least one thread. If a process contains only one thread, then all the code inside it will be executed serially. The first thread of each process is created with the start of the process and can be called the main thread of the process.

On the other hand, if a process contains multiple threads, the code inside it can be executed concurrently. Except for the first thread of the process, other threads are created by existing threads within the process.

In other words, threads other than the main thread can only be created and destroyed explicitly in code. This requires manual control when writing programs. The operating system and the process itself will not give us such instructions; they will only faithfully execute our instructions.

However, in Go programs, the Go language’s runtime system helps us automatically create and destroy system-level threads. Here, system-level threads refer to the threads provided by the operating system that we mentioned earlier.

Correspondingly, user-level threads refer to the code execution flow completely controlled by users (or the programs we write) and are built on top of system-level threads. The creation, destruction, scheduling, state changes, as well as the code and data within user-level threads, all need to be implemented and handled by our own programs.

This brings many advantages. For example, because their creation and destruction do not need to be done through the operating system, they are much faster. Also, because they do not need to wait for the operating system to schedule their execution, they are often easy to control and very flexible.

However, there are also disadvantages, the most obvious and important one being complexity. If we only use system-level threads, we just need to specify the code segment that needs to be executed by the new thread and give instructions to create or destroy threads, and all other specific implementations will be taken care of by the operating system.

But if we use user-level threads, we have to be both the issuer and the executor of instructions. We must be fully responsible for all specific implementations related to user-level threads.

The operating system not only will not help, but will also require our specific implementation to correctly interface with it; otherwise, user-level threads cannot be executed concurrently, or even run correctly. After all, all the code we write needs to be executed on a computer through the operating system. It sounds troublesome, doesn’t it?

But don’t worry, Go language not only has a unique concurrent programming model and user-level threads called goroutines but also has a powerful scheduler for scheduling goroutines and interfacing with system-level threads.

This scheduler is an important component of the Go language’s runtime system. It is mainly responsible for coordinating and allocating the three main elements in the Go concurrent programming model: G (short for goroutine), P (short for processor), and M (short for machine).

M refers to system-level threads. P denotes a kind of intermediary that can carry several G and can timely interface these G with M to achieve actual execution.

From a macro perspective, thanks to the existence of P, there can be a many-to-many relationship between G and M. When a running G that is interfacing and executing with an M needs to pause due to an event (such as I/O waiting or lock release), the scheduler will promptly find out and detach this G from that M to release computing resources for those waiting G to use.

And when a G needs to resume execution, the scheduler will quickly find available computing resources (including M) and arrange for its execution. In addition, when M is not enough, the scheduler will help us apply for new system-level threads from the operating system, and when an M is no longer needed, the scheduler will be responsible for promptly destroying it.

Because the scheduler helps us with a lot of things, our Go programs can always efficiently utilize the resources of the operating system and the computer. All goroutines in the program will be fully scheduled, and the code within them will be executed concurrently. Even if there are tens of thousands of such goroutines, they can still be executed like this.

Relationship Between M, P, and G (Simplified Version)

Due to space limitations, I will not delve further into the details of the scheduler and runtime system of the Go language. You need to know that Go implements a very complete runtime system, which ensures that our programs can run stably and efficiently even in highly concurrent situations.

If you are interested in these specific details and want to explore further, I recommend you take a look at my book “Go Concurrency in Practice.” In this book, I elaborate at length on the principles and mechanisms of Go’s concurrency programming model, as well as all the closely related knowledge.

Next, I will introduce the execution rules, best practices, and taboos of the go statement from the perspective of programming practice.

Let’s take a look at today’s question: What is the main goroutine, and how is it different from the other goroutines we enable?

Let’s examine a programming question that I often ask in interviews.

package main

import "fmt"

func main() {
    for i := 0; i < 10; i++ {
        go func() {
            fmt.Println(i)
        }()
    }
}

In the file demo38.go, I only wrote a for loop in the main function. This for loop will iterate 10 times and has a local variable i representing the current iteration number, starting from 0.

In this for loop, there is only one go statement, and within this go statement, there is only one statement. The innermost statement calls the fmt.Println function and wants to print the value of the variable i.

This program is very simple, with three nested statements. My specific question is: What content will be printed when this source code file is executed?

The typical answer to this question is: Nothing will be printed.

Problem Analysis #

Similar to how a process always has a main thread, every independent Go program has a main goroutine. This main goroutine is automatically enabled after the preparation work of the Go program is completed, and we do not need to do any manual operations.

As you may know, each go statement generally carries a function call, which is often referred to as the go function. The go function of the main goroutine is the main function, which serves as the program entry point.

It is important to note that the time when the go function is actually executed is always different from the time when the go statement it belongs to is executed. When the program reaches a go statement, the Go runtime system will first try to obtain a G (goroutine) from a queue of idle G. It will only create a new G if no idle G is available.

This is why I always say “enable” a goroutine instead of “create” a goroutine. Existing goroutines are always preferred for reuse.

However, the cost of creating a G is also very low. Creating a G does not involve system calls, like creating a new process or a system-level thread. It can be completely done within the Go runtime system, especially considering that a G is only a context environment for the code snippet that needs to be executed concurrently.

Once an idle G is obtained, the Go runtime system uses this G to wrap the current go function (or the code within that function), and then appends this G to a queue of runnable G.

G in this queue is always scheduled to run in a first-in-first-out order by the scheduler inside the runtime system. Although this happens quickly, there is still some time consumed due to the unavoidable preparation work mentioned above.

Therefore, the execution of the go function is always significantly delayed compared to the execution of the go statement it belongs to. However, this “significant delay” is only in terms of the CPU clock and the Go program. Most of the time, we don’t feel a significant difference.

After explaining the principle, let’s look at the manifestation of this principle. Keep in mind that as long as the go statement itself is executed, the Go program will not wait for the execution of the go function. It will immediately proceed to execute the following statements. This is called asynchronous concurrent execution.

Here, “following statements” generally refers to the next iteration in a for statement. However, when the last iteration is executed, there is no “following statement”.

The for statement in demo38.go will be executed quickly. When it completes, the 10 goroutines that wrap the go functions often have not had a chance to execute.

Please note that the call to the fmt.Println function inside the go function takes the variable i in the for statement as a parameter. You can imagine that if the go functions have not been executed when the for statement completes, what will be the value of the variable i that they reference?

Will they all be 10? In that case, the answer to this question would be “print 10 10s”, right?

Before determining the final answer, you need to know an important feature related to the main goroutine, which is: once the code in the main goroutine (i.e., the code in the main function) completes execution, the current Go program will end.

Therefore, if at the moment when the Go program is ending, there are still goroutines that have not had a chance to run, then they will truly not have a chance to run, and the code within them will not be executed.

We just mentioned that when the last iteration of the for statement is executed, the go statement is the last statement. Therefore, after executing this go statement, the code in the main goroutine also completes execution, and the Go program will immediately end. In that case, will any content be printed?

Strictly speaking, Go does not guarantee the order of execution of these goroutines. Since the main goroutine is scheduled together with the goroutines we manually enable, and because the scheduler is likely to pause when a goroutine has only executed part of its code, in order to give all goroutines a more fair chance to run.

Therefore, which goroutine completes execution first, and which goroutine completes execution later, is often unpredictable unless we use some means provided by the Go language to intervene. However, in this code snippet, we did not manually intervene.

So what is the answer? For such a simple code snippet in demo38.go, in most cases, the answer would be “no content will be printed”.

But for the sake of rigor, whether the candidate’s answer is “print 10 10s” or “no content will be printed”, or “print the unordered sequence of 0 to 9”, I will immediately follow up with the question “Why?” Because only when you know the underlying principle can your answer be considered correct.

This principle is so important that if you don’t know it, it is almost impossible to write a correct concurrent program. Even if the concurrent program you write seems to run correctly, it is probably just luck.

Summary #

Today, I described the role and significance of goroutines in the concurrent programming model of the operating system and in Go language. These knowledge points will lay a solid foundation for you.

I also mentioned the runtime system and scheduler in the Go language, as well as the coordination and maintenance work they do around goroutines. Each sentence in these contents should play a substantial role in your correct understanding of goroutines. You can use this knowledge to explain why the program in the main problem produces the results it does after running.

In the next article, we will continue to discuss some extended knowledge about the go statement and execution rules. The thought-provoking question I leave you with today is: How can we limit the number of goroutines that can be used?

Thank you for listening and see you next time.

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