GoSuda

How Goroutines Work in Go: 2KB Growable Stacks and the M:N GMP Scheduler

By hamori views ...

Goroutine

When Gophers are asked to talk about the advantages of Golang, a frequently appearing topic is Concurrency. The foundation of that topic is the goroutine, which can be handled lightly and simply. I have written a brief overview of it.

Concurrency vs Parallelism

Before we understand goroutines, let us first review two concepts that are often confused.

  • Concurrency: Concurrency is about handling many tasks at once. It does not necessarily mean that they are actually executed at the same time. It is a structural and logical concept in which multiple tasks are divided into small units and executed in alternation, so that to the user it appears as though multiple tasks are being processed simultaneously. Concurrency is possible even on a single core.
  • Parallelism: Parallelism means "processing multiple tasks simultaneously on multiple cores." Literally, work proceeds in parallel, and other tasks are executed at the same time.

Goroutines enable concurrency to be implemented easily through the Go runtime scheduler, and they naturally leverage parallelism as well through the GOMAXPROCS setting.

Java's multithreading, which is widely used, is a representative concept of parallelism.

Why Are Goroutines Good?

Lightweight

Their creation cost is very low compared to other languages. This raises the question of why Golang uses so few resources. The reason is that goroutines are created and managed inside the Go runtime. Because they are lightweight logical threads, they are smaller than OS thread units. Their initial stack requires about 2KB, and the stack can be dynamically extended as needed according to the implementation.

Since they are managed in units of stacks, creation and removal are very fast and inexpensive, so even running millions of goroutines is manageable without burden. Thanks to the runtime scheduler, goroutines can minimize intervention from the OS kernel.

Performance

First, as explained above, goroutines involve little OS kernel intervention. Therefore, context switching at the user level is cheaper than at the OS thread level, allowing tasks to be switched quickly.

In addition, the M:N model is used to assign goroutines to OS threads for management. By creating an OS thread pool, many threads are not required, and processing is possible with a small number of threads. For example, when a waiting state such as a system call occurs, the Go runtime executes another goroutine on the OS thread, so the OS thread does not sit idle and the CPU is used efficiently, enabling fast processing.

As a result, Golang can achieve higher performance than other languages, especially in I/O operations.

Concise

The fact that concurrent processing of a function can be easily accomplished with a single go keyword is also a major advantage.

When using complex locks such as Mutex and Semaphore, one inevitably must consider the DeadLock state that accompanies locking, which requires complex steps from the design stage before development.

Following the philosophy "Do not communicate by sharing memory; instead, share memory by communicating," goroutines encourage data transfer through Channel, and SELECT combined with channels supports the ability to process whichever channel has data ready first. Furthermore, using sync.WaitGroup allows one to wait simply until all goroutines have finished, making workflows easy to manage. Thanks to these tools, data race problems between threads can be prevented and concurrent processing can be performed more safely.

Additionally, by using context, one can control lifecycles, cancellation, timeouts, deadlines, and request scope at the user level, which can guarantee a certain degree of stability.

Parallel Work of Goroutines (GOMAXPROCS)

Having discussed the advantages of goroutine concurrency, you may wonder whether parallelism is supported as well. Recently, the number of CPU cores has exceeded double digits, and even home PCs contain a considerable number of cores.

However, goroutines do perform parallel work too, and this is done through GOMAXPROCS.

If GOMAXPROCS is not set, its default value differs by version.

  1. Before 1.5: The default value is 1. To use more than 1, it is essential to set it using a method such as runtime.GOMAXPOCS(runtime.NumCPU()).

  2. 1.5 to 1.24: The default was changed to the number of all available logical cores. From this point on, developers do not need to set it unless there is a significant constraint.

  3. 1.25: True to the reputation of a language popular in container environments, it checks the CPU limit configured for the container by inspecting cgroups on Linux.

    For example, if there are 10 logical cores and the CPU limit value is 5, GOMAXPROCS is set to 5, the lower number.

The change in 1.25 is a very significant modification, because the usability of the language in container environments has improved. As a result, unnecessary thread creation and context switching can be reduced, preventing CPU throttling.

 1package main
 2
 3import (
 4	"fmt"
 5	"math/rand"
 6	"runtime"
 7	"time"
 8)
 9
10func exe(name int, wg *sync.WaitGroup) {
11	defer wg.Done()
12
13	fmt.Printf("Goroutine %d: ์‹œ์ž‘\n", name)
14	time.Sleep(10 * time.Millisecond) // ์ž‘์—… ์‹œ๋ฎฌ๋ ˆ์ด์…˜์„ ์œ„ํ•œ ์ง€์—ฐ
15	fmt.Printf("Goroutine %d: ์‹œ์ž‘\n", name)
16}
17
18func main() {
19	runtime.GOMAXPROCS(2) // CPU ์ฝ”์–ด 2๊ฐœ๋งŒ ์‚ฌ์šฉ
20	wg := sync.WaitGroup();
21  goroutineCount := 10
22	wg.Add(goroutineCount)
23
24	for i := 0; i < goroutineCount; i++ {
25		go exe(i, &wg)
26	}
27
28	fmt.Println("๋ชจ๋“  goroutine์ด ๋๋‚  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐํ•ฉ๋‹ˆ๋‹ค...")
29	wg.Wait()
30	fmt.Println("๋ชจ๋“  ์ž‘์—…์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.")
31
32}
33

Goroutine Scheduler (M:N Model)

Returning to the earlier point that the M:N model assigns goroutines to OS threads for management, if we go into more detail, there is the GMP model of goroutines.

  • G (Goroutine): The smallest unit of work executed in Go
  • M (Machine): An OS thread (the actual place where work is performed)
  • P (Processor): A logical processor managed by the Go runtime

P additionally has a Local Run Queue, and it serves as a scheduler that assigns the G it holds to an M. Simply put, the operation of goroutines in GMP proceeds as follows.

  1. When a G (Goroutine) is created, it is assigned to the Local Run Queue of a P (Processor).
  2. The P (Processor) assigns a G (Goroutine) in its Local Run Queue to an M (Machine).
  3. The M (Machine) returns the state of the G (Goroutine), which may be blocked, complete, or preempted.
  4. Work-Stealing: If the Local Run Queue of a P becomes empty, another P checks the Global Queue. If there is no G there either, it steals work from another P's Local Run Queue so that all Ms keep operating without rest.
  5. System Call Handling (Blocking): When a G (Goroutine) blocks during execution, its M (Machine) enters a waiting state. At this point, the P (Processor) is separated from the blocked M (Machine), combined with another M (Machine), and executes the next G (Goroutine). In this way, there is no CPU waste even while waiting during I/O operations.
  6. If one G (Goroutine) holds the processor for a long time (preempted), another G (Goroutine) is given an opportunity to run.

Golang's GC (Garbage Collector) also runs on top of goroutines, so it can clean up memory in parallel while minimizing interruptions to application execution (STW), which allows system resources to be used efficiently.

Finally, Golang is one of the language's strong advantages, and there are many more besides, so I hope that many developers will enjoy Go.

Thank you.