#Concurrency in Go: Goroutines Explained
Go is a highly performant language built for concurrency. It redefines concurrent programming through goroutines and channels — lightweight primitives managed by the Go runtime.
Using goroutines turns a sequential program into a concurrent one without managing threads or thread-pools.
But concurrency comes with dangers. Before adding the go keyword to every function call, it’s worth understanding how goroutines work and where they can go wrong.
In this tutorial, we’ll look at how to use goroutines in Go programs and improve the performance of your applications.
Video Tutorial
Should you prefer, this tutorial is also available in video format. If you wish to support my work, then please consider liking and subscribing to my YouTube channel.
Goals
By the end of this tutorial, you should:
- have a solid understanding as to what
goroutinesare, and how they can be used to improve the performance of your applications - know how to create and work with anonymous goroutines.
- have an understanding as to some of the dangers of making your applications concurrent.
What Are Goroutines?
Goroutines are lightweight “threads” managed by the Go runtime. They let you write asynchronous, parallel programs that execute tasks far faster than sequential equivalents.
Goroutines are far smaller than threads, they typically take around 2kB of stack space to initialise compared to a thread which takes 1MB.
Goroutines are multiplexed onto a small number of OS threads, so concurrent Go programs use far fewer resources than equivalent programs in languages like Java.
Creating 1,000 goroutines typically requires one or two OS threads. The same in Java would require 1,000 threads, each consuming at least 1MB of heap space.
Mapping thousands of goroutines onto a single thread eliminates the performance cost of creating and destroying threads.
Goroutines are cheap to create and destroy — the Go runtime handles scheduling efficiently so your application stays fast under load.
A Simple Sequential Program
As a demonstration, we’ll create a function that takes an int and prints numbers to the console n times, with a one-second sleep between each.
package main
import (
"fmt"
"time"
)
// a very simple function that we'll
// make asynchronous later on
func compute(value int) {
for i := 0; i < value; i++ {
time.Sleep(time.Second)
fmt.Println(i)
}
}
func main() {
fmt.Println("Goroutine Tutorial")
// sequential execution of our compute function
compute(10)
compute(10)
// we scan fmt for input and print that to our console
var input string
fmt.Scanln(&input)
}
Running this prints 0–9 twice in sequence. Total execution time is just over 20 seconds.
The fmt.Scanln() call prevents main from exiting before the compute calls finish — we’ll see why this matters in the next section.
Making our Program Asynchronous
If the order of output doesn’t matter, we can speed this up dramatically with goroutines.
package main
import (
"fmt"
"time"
)
// notice we've not changed anything in this function
// when compared to our previous sequential program
func compute(value int) {
for i := 0; i < value; i++ {
time.Sleep(time.Second)
fmt.Println(i)
}
}
func main() {
fmt.Println("Goroutine Tutorial")
// notice how we've added the 'go' keyword
// in front of both our compute function calls
go compute(10)
go compute(10)
}
Adding the go keyword before each compute call creates two goroutines that run in parallel.
But if you run this, you’ll notice it exits without printing anything.
This happens because main completes before the goroutines get a chance to execute. Any unfinished goroutines are terminated when main returns.
To fix this for now, we add fmt.Scanln() to block until keyboard input — keeping main alive long enough for the goroutines to finish.
Note:
fmt.Scanln()is a teaching simplification. In production, usesync.WaitGroupto synchronise goroutines properly.
package main
import (
"fmt"
"time"
)
// notice we've not changed anything in this function
// when compared to our previous sequential program
func compute(value int) {
for i := 0; i < value; i++ {
time.Sleep(time.Second)
fmt.Println(i)
}
}
func main() {
fmt.Println("Goroutine Tutorial")
// notice how we've added the 'go' keyword
// in front of both our compute function calls
go compute(10)
go compute(10)
var input string
fmt.Scanln(&input)
}
Running this prints 0,0,1,1,2,2… through 9,9 — and execution takes roughly 10 seconds instead of 20.
Anonymous Goroutine Functions
You can use the go keyword with anonymous functions too, not just named ones.
package main
import "fmt"
func main() {
// we make our anonymous function concurrent using `go`
go func() {
fmt.Println("Executing my Concurrent anonymous function")
}()
// we have to once again block until our anonymous goroutine
// has finished or our main() function will complete without
// printing anything
fmt.Scanln()
}
The fmt.Scanln() call blocks main from exiting before the anonymous goroutine completes.
Executing my Concurrent anonymous function
Frequently Asked Questions
What is a goroutine in Go?
A goroutine is a lightweight unit of execution managed by the Go runtime, similar in concept to a thread but far cheaper to create and destroy. Goroutines start with around 2kB of stack space and grow as needed. You launch one by prefixing any function call with the go keyword.
How are goroutines different from OS threads?
OS threads are managed by the operating system and typically consume 1MB of stack space each. Goroutines are managed by the Go runtime and are multiplexed onto a small pool of OS threads. This means you can run tens of thousands of goroutines with minimal overhead.
How do I wait for a goroutine to finish?
The idiomatic way is to use sync.WaitGroup. Call wg.Add(1) before launching each goroutine, defer wg.Done() inside it, and wg.Wait() in the caller to block until all goroutines complete. See the Go sync.WaitGroup Tutorial for a full walkthrough.
How many goroutines can I run at once?
There is no fixed limit imposed by Go itself — programs commonly run hundreds of thousands of goroutines. The practical limit is memory: each goroutine starts at ~2kB of stack space, so 100,000 goroutines use roughly 200MB at minimum. The Go scheduler handles multiplexing them efficiently.
What happens if a goroutine panics?
An unrecovered panic in a goroutine crashes the entire program, not just that goroutine. Each goroutine that can panic should recover from it using defer and recover(). This is especially important in long-running server goroutines.
Is it safe to access a map from multiple goroutines?
No — concurrent reads and writes to a Go map without synchronisation cause a runtime panic. Use sync.Mutex to guard map access, or use sync.Map for concurrent workloads. See the Go Mutex Tutorial for examples.
Conclusion
In this tutorial, we looked at what goroutines are, how they compare to OS threads, and how to use them to write concurrent Go programs.
We also covered a key pitfall — goroutines are terminated when main returns — and pointed to sync.WaitGroup as the right synchronisation tool for production code.
Further Reading
If you enjoyed this article and wish to learn more about working with Concurrency in Go, then I recommend you check out our other articles on concurrency:
- Go sync.WaitGroup Tutorial
- Go Mutex Tutorial
- Go Channels Tutorial
- Channels and Select in Go
- Using WaitGroups in Go
- Worker Pool Pattern in Go
Ready to Go Further?
This tutorial is part of the Go Learning Path — a curated route from beginner to advanced Go developer, covering goroutines, channels, REST APIs, testing, gRPC and more.
Continue Learning
Go Maps Tutorial
In this tutorial, we are going to look at how you can effectively use Maps within your Go applications.
Go Mutex Tutorial
In this tutorial, we are going to look at how you can use mutexes in your Go programs
Structured Logging in Go with log/slog - The Complete Guide
Learn structured logging in Go with the standard library log/slog package - handlers, levels, context, custom handlers, and why it replaces logrus, zap and zerolog.
An Introduction to Go Closures - Tutorial
Learn how closures work in Go with simple, practical examples. Understand lexical scoping and how closures capture and maintain their own state.