Goroutines 101: A basic walkthrough

Lobsters Hottest Tools

Summary

This article provides a basic walkthrough of goroutines in Go, explaining how they simplify concurrency and how to use them effectively.

<p><a href="https://lobste.rs/s/ixxtwz/goroutines_101_basic_walkthrough">Comments</a></p>
Original Article
View Cached Full Text

Cached at: 08/20/26, 06:40 AM

# Goroutines 101: A basic walkthrough Source: [https://func25.dev/posts/go-goroutines-basics/](https://func25.dev/posts/go-goroutines-basics/) Go makes concurrency much easier than most other languages\. - In Java, you decide between platform threads, virtual threads, and the thread pools of the`ExecutorService`framework\. - In Python, you have`threading`,`asyncio`, and`multiprocessing`, but which one you choose depends on whether the work waits for I/O or uses the CPU\. In Go, you just write 1 keyword \(`go`\) in front of a function call: This single line starts a goroutine, which is a function that runs at the same time as the rest of your application\. You do not create a thread object, you do not size a pool, you do not install a library, etc\. That simple way of doing concurrency really really matters, and it is a big part of why we love Go\. This article introduces the basics behind it: goroutines, how they run on OS threads, and what`GOMAXPROCS`does\. Let’s start with what happens when we run that one line\. ## 1\. Starting a goroutine The snippet below starts a goroutine that prints one line, and then`main`prints another line: go ``` func main() { go doSomething() fmt.Println("done") } func doSomething() { fmt.Println("doSomething called") } ``` We might expect 2 lines of output\. If we run this a few times, we usually get only one: The message from`doSomething`is missing\. To understand why, we need to know what the`go`keyword really does\. A`go`statement does not call the function\. It creates a new goroutine, tells the Go scheduler that this goroutine is ready to run, and then moves to the next line right away\. That is the first rule:`main`never waits for a goroutine that it starts\. The second rule is the one that removes our output\. When`main`returns, the whole process exits\. The runtime does not wait for the other goroutines to finish, and it does not run their deferred calls either\. In the program above,`main`reaches the end of its body before the scheduler gives`doSomething`any CPU time, so the process is already gone when that goroutine would have printed its line\. maingo doSomething\(\)doSomethingnot started yetprocess exitsmain returnsmain returns before the new goroutine gets a turn, so the process exits first\.A common first fix is to make`main`sleep before it returns: go ``` go doSomething() time.Sleep(time.Second) fmt.Println("done") ``` Both lines show up now, because 1 second is far more time than`doSomething`needs\. But this is a guess about timing, not real synchronization\. If the work takes longer than the sleep, the output disappears again\. If the work is fast, the application waits for no reason\. The right tool for “wait until this work is done” is`sync\.WaitGroup`: go ``` func main() { var wg sync.WaitGroup wg.Go(doSomething) wg.Wait() fmt.Println("done") } ``` A`WaitGroup`holds a counter of pending work\.[`WaitGroup\.Go`](https://pkg.go.dev/sync#WaitGroup.Go)adds 1 to that counter and starts the goroutine, the counter drops back by 1 when`doSomething`returns, and`Wait`blocks`main`until the counter reaches zero\. The output is now the same on every run: ## 2\. Goroutines and OS threads A goroutine does the same job as an OS thread, which is to run code at the same time as other code\. So why did Go build its own mechanism instead of using threads directly? The reason is that a thread is something your program asks the OS for, but a goroutine is something the Go runtime builds, understands, and owns from start to finish\. The kernel has to keep its threads general enough for every language on the machine, and the Go runtime only has to handle Go\. OS threadGoJavaPythonkernelthreadgeneral for everyonegoroutineGoGo runtimegoroutinebuilt for GoThe kernel provides threads; the Go runtime builds goroutines on top\.So that ownership is what lets Go specialize: - **Stack size**: A new goroutine starts with a small stack, 2 KB at minimum, and the runtime grows it when the goroutine needs more room\. An OS thread reserves its whole stack at creation, and 8 MB is a common default on Linux\. Most of that space is never touched, but it is still reserved\. - **Creation cost**: A new goroutine needs a small stack, a bookkeeping struct, and a slot in a run queue\. All of that work stays inside your process\. A new thread needs a system call, so the kernel gets involved every time\. - **Scheduler**: The Go runtime runs many goroutines on a small set of OS threads\. This is called an m:n model, because m goroutines share n threads\. The kernel schedules the threads and knows nothing about the goroutines on top of them\. - **Context switch**: When the runtime pauses one goroutine and starts another, everything stays inside your process\. A thread switch goes through the kernel, which is a large part of why it costs more\. Go was not the first language with this design\. Erlang has used lightweight processes this way for decades, and Java added virtual threads in JDK 21\. Java still has both kinds of thread and lets you pick between them, but Go gives you one unit of concurrency behind one keyword\. Behind the scenes, the scheduler itself is complicated in both structure and behavior, because it has to fan your goroutines out across a limited number of threads and pull them back in\. We will get into that in another post\. goroutinesGGGGGGGGGmanyprocessorsPPPlimitedOS threadsMMMkernelCPU coresMany goroutines pass through fixed processors onto a few OS threads\.We will never write code that creates a processor`P`or a thread`M`, and we will never move a goroutine between them by hand\. The one thing in this diagram we do control is how many threads the runtime is allowed to run Go code on at the same time, and Go calls that limit`GOMAXPROCS`\. ## 3\. GOMAXPROCS There are two numbers that describe how much real parallelism your program can get: go ``` func main() { fmt.Println(runtime.NumCPU()) fmt.Println(runtime.GOMAXPROCS(0)) } ``` On my machine, both print`14`, because it has`14`logical CPUs: The 2 numbers answer different questions, but they are related: - `NumCPU`is the number of logical CPUs that the process can use, and Go reads that count once at startup, so the number never moves while the program runs\. - `GOMAXPROCS`is the runtime’s own limit on how many OS threads may run Go code at the same moment, and this one is a dynamic value\. You can set it yourself, though most programs never do\. `runtime\.GOMAXPROCS\(n\)`is a little bit special, because it can both get and set the limit depending on the argument\. A value below 1 means get, so`GOMAXPROCS\(0\)`is the usual way to read the current limit\. A value of 1 or more sets a new limit and returns the old one\. Note The 2 numbers \(`NumCPU`&`GOMAXPROCS`\) match here because this run is on a bare machine\. Since Go 1\.25, inside a container with a CPU limit, say 2 CPUs on a 64\-core host, the runtime follows the container limit instead of the host’s CPU count, so`GOMAXPROCS`gives you 2 while`NumCPU`still gives you 64\. The runtime also keeps that value updated if the limit changes\. How the runtime reads that container limit, and what happens when the limit changes while your program is running, is the subject of the next post\. What does this limit change in practice? The program below starts three goroutines, and each one prints the digits`0`to`9`\. The limit is set to one: go ``` var wg sync.WaitGroup func main() { runtime.GOMAXPROCS(1) for range 3 { wg.Go(printDigits) } wg.Wait() } func printDigits() { for i := range 10 { fmt.Print(i) } } ``` With one slot, only one goroutine runs at a time\. Each loop is short enough to finish its turn before the Go runtime switches to another goroutine, so the digits come out in three clean groups: ``` 012345678901234567890123456789 ``` My local machine has 14 logical CPUs, so deleting the`runtime\.GOMAXPROCS\(1\)`line raises the limit from 1 back to 14\. The 3 goroutines can then run at the same moment, and one run printed this: ``` 012345678901012345678923456789 ``` The digits are mixed together now, because the 3 goroutines wrote to the output at the same time\. GOMAXPROCS = 1012345678901234567890123456789no limit012345678901012345678923456789goroutine 1goroutine 2goroutine 3Each color marks one goroutine; the two runs show three blocks and five\.Some runs may still come out in order, since the sample is small here\. Each goroutine only prints 10 characters and can finish before another one gets a turn\. A longer loop makes the mixing show up more often, but the idea stays the same\. There are 2 details in the first result that are worth stating clearly: - It is normal here for a goroutine to finish its whole for loop in one turn, but that is not guaranteed\. Since Go 1\.14, the runtime can interrupt a goroutine that has held its CPU for about 10 ms, even in the middle of a loop\. - The order of the 3 goroutines is not defined, and this example hides that fact, because all three of them print the same digits, 0 to 9\. So`GOMAXPROCS\(1\)`takes away parallelism, but not concurrency\. All three goroutines can exist and be runnable during the same period, while only one executes Go code at any instant\. The scheduler may let one finish its short loop before running another, as in this output, or it may pause one and resume it later\. Concurrency allows the work to be interleaved; parallelism requires at least two goroutines to execute at the same moment\. Parallel execution is only one part of concurrency\. The next step is to let goroutines exchange values and synchronize with each other, which we do in[Go channels from first principles](https://func25.dev/posts/go-channels/)\. ## Source references - [`go`statements in the Go spec](https://go.dev/ref/spec#Go_statements) - [`src/runtime/stack\.go`](https://go.dev/src/runtime/stack.go)\(`stackMin`\) - [`sync\.WaitGroup\.Go`documentation](https://pkg.go.dev/sync#WaitGroup.Go) - [`runtime\.GOMAXPROCS`documentation](https://pkg.go.dev/runtime#GOMAXPROCS) - [Go 1\.25 release notes: container\-aware GOMAXPROCS](https://go.dev/doc/go1.25#container-aware-gomaxprocs)

Similar Articles

Concurrent Servers: Part 8 - Go

Eli Bendersky

This article is part 8 of a series on writing concurrent network servers, focusing on implementing concurrency in Go using goroutines and the Go runtime's scheduling.

Go-Flavored Concurrency in C

Hacker News Top

A detailed technical post exploring how to replicate Go's concurrency model in C using POSIX threads, mutexes, condition variables, and a worker pool, as part of the Solod transpiler project.

Understanding the Go Runtime: Profiling

Hacker News Top

A deep dive into Go's profiling mechanism, explaining how the runtime collects CPU, heap, block, mutex, and goroutine profiles, and how they are represented in the pprof format.

Data races and the memory model in Go

Lobsters Hottest

This article explains data races and the Go memory model, illustrating how unsynchronized access to shared variables in goroutines can cause issues and discussing proper synchronization methods.

Go 1.27 interactive tour

Lobsters Hottest

A hands-on interactive tour of Go 1.27's new features, highlighting generic methods, struct literal field selectors, and more, with runnable examples based on the official release notes.