Goroutines are Go's fundamental unit of concurrency. They're lightweight threads managed by the Go runtime that enable you to write concurrent programs easily and efficiently.
Basic syntax
Creating a goroutine is simple: just prefix a function call with the go keyword:
go functionName(arguments)
The go keyword is part of Go's language syntax, not a library call. This design decision makes concurrency a first-class citizen in Go. You don't need to import threading libraries, create thread objects, manage thread pools, or configure thread parameters.
When you write go functionName(), the Go runtime creates a new goroutine, allocates a small stack (starting at 2 KB), schedules the goroutine to run, and returns immediately without waiting for the function to complete. This immediate return allows the calling code to continue executing while the goroutine runs concurrently.
Basic example
package main
import (
"fmt"
"time"
)
func sayHello(name string) {
for i := 0; i < 3; i++ {
fmt.Printf("Hello from %s: %d\n", name, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
// Launch goroutine - execution continues immediately
go sayHello("Goroutine 1")
go sayHello("Goroutine 2")
// Main goroutine continues executing
fmt.Println("Main function continues...")
// Give goroutines time to execute
// (In real code, use channels or sync.WaitGroup instead)
time.Sleep(1 * time.Second)
fmt.Println("Main function ends")
}
Output (order may vary)
Main function continues...
Hello from Goroutine 1: 0
Hello from Goroutine 2: 0
Hello from Goroutine 1: 1
Hello from Goroutine 2: 1
Hello from Goroutine 1: 2
Hello from Goroutine 2: 2
Main function ends
Anonymous function goroutines
You can also launch goroutines with anonymous functions:
package main
import (
"fmt"
"time"
)
func main() {
// Launch anonymous function as goroutine
go func(name string) {
for i := 0; i < 3; i++ {
fmt.Printf("Hello from %s: %d\n", name, i)
time.Sleep(100 * time.Millisecond)
}
}("Anonymous Goroutine")
time.Sleep(500 * time.Millisecond)
fmt.Println("Main function")
}
Anonymous functions are convenient when the function is only used once, when you need to capture variables from the surrounding scope, or when you want to keep related code together.
Capturing variables in closures
package main
import (
"fmt"
"time"
)
func main() {
for i := 0; i < 3; i++ {
// Each goroutine captures its own copy of i
go func(id int) {
fmt.Printf("Goroutine %d started\n", id)
time.Sleep(100 * time.Millisecond)
fmt.Printf("Goroutine %d finished\n", id)
}(i) // Pass i as argument to capture current value
}
time.Sleep(500 * time.Millisecond)
}
Note (Go 1.23) In Go 1.23, loop variables in
forloops have improved scoping behavior. However, when launching goroutines in loops, it's still best practice to pass loop variables as function arguments to ensure each goroutine gets its own copy.
Understanding the goroutine lifecycle helps you write correct concurrent programs.
Lifecycle stages
go keywordThe lifecycle follows this pattern:
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // Signal completion when function returns
fmt.Printf("Worker %d: Starting\n", id)
// Simulate work
time.Sleep(time.Duration(id) * 100 * time.Millisecond)
fmt.Printf("Worker %d: Finished\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // Increment counter before starting goroutine
go worker(i, &wg)
}
fmt.Println("Main: Waiting for workers to finish...")
wg.Wait() // Block until counter reaches zero
fmt.Println("Main: All workers finished")
}
The main goroutine doesn't wait for other goroutines by default. If the main goroutine exits, the program terminates, even if other goroutines are still running. sync.WaitGroup provides a way to wait for goroutines to complete.
WaitGroup works through three methods: Add(n) increments the internal counter by n, Done() decrements the counter by 1 (typically called with defer), and Wait() blocks until the counter reaches zero.
Goroutine termination
A goroutine terminates when:
Example showing termination
package main
import (
"fmt"
"time"
)
func shortTask() {
fmt.Println("Short task: Starting")
time.Sleep(100 * time.Millisecond)
fmt.Println("Short task: Done")
}
func longTask() {
fmt.Println("Long task: Starting")
time.Sleep(2 * time.Second)
fmt.Println("Long task: Done") // May not execute if main exits
}
func main() {
go shortTask()
go longTask()
// Main exits after 500ms
time.Sleep(500 * time.Millisecond)
fmt.Println("Main: Exiting (program will terminate)")
// longTask may not complete
}
Go's runtime includes a sophisticated scheduler that manages goroutine execution efficiently.
Operating systems schedule threads, but goroutines are lighter than threads. Go's scheduler manages thousands or millions of goroutines efficiently, minimizes context switching overhead, and automatically utilizes multiple CPU cores.
Go uses an M:N scheduler (also called a "work-stealing scheduler") where M goroutines are multiplexed onto N OS threads. The scheduler uses three key components: G (Goroutine) represents a goroutine, M (Machine) represents an OS thread, and P (Processor) represents a logical processor (typically one per CPU core).
The scheduling model
CPU Core 1 CPU Core 2 CPU Core 3
| | |
M1 M2 M3 (OS Threads)
| | |
P1 P2 P3 (Processors)
| | |
G1 G2 G3 G4 G5 G6 G7 G8 G9 (Goroutines)
Goroutines are scheduled as follows:
When scheduling happens
The scheduler switches goroutines when:
runtime.Gosched())Example demonstrating scheduling
package main
import (
"fmt"
"runtime"
"time"
)
func cpuIntensiveTask(id int) {
sum := 0
for i := 0; i < 1000000; i++ {
sum += i
}
fmt.Printf("Task %d: Completed (sum: %d)\n", id, sum)
}
func ioTask(id int) {
fmt.Printf("IO Task %d: Starting\n", id)
time.Sleep(100 * time.Millisecond) // Simulates I/O
fmt.Printf("IO Task %d: Completed\n", id)
}
func main() {
// Show number of CPU cores
fmt.Printf("CPU cores: %d\n", runtime.NumCPU())
// CPU-intensive tasks (may be preempted)
for i := 0; i < 3; i++ {
go cpuIntensiveTask(i)
}
// I/O tasks (will yield to scheduler)
for i := 0; i < 3; i++ {
go ioTask(i)
}
time.Sleep(1 * time.Second)
}
Explicit scheduling control
You can influence scheduling with runtime.Gosched():
package main
import (
"fmt"
"runtime"
)
func greedyTask(id int) {
for i := 0; i < 5; i++ {
fmt.Printf("Task %d: %d\n", id, i)
runtime.Gosched() // Yield to other goroutines
}
}
func main() {
go greedyTask(1)
go greedyTask(2)
runtime.Gosched() // Give other goroutines a chance
fmt.Println("Main: Done")
}
Go's scheduler is preemptive in modern versions, so you rarely need runtime.Gosched(). However, it can be useful for cooperative multitasking in tight loops, testing concurrent behavior, or ensuring fairness in specific scenarios.
Following best practices helps you write correct, efficient concurrent programs.
1. Always wait for goroutines to complete
The main goroutine doesn't wait for others. Use sync.WaitGroup or channels:
// ❌ BAD: Main may exit before goroutines complete
func main() {
go doWork()
// Program may exit here
}
// ✅ GOOD: Wait for completion
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
doWork()
}()
wg.Wait()
}
2. Avoid goroutine leaks
Goroutines consume memory. If they never terminate, memory usage grows:
// ❌ BAD: Goroutine may never terminate
func leakyFunction() {
ch := make(chan int)
go func() {
for {
// This loop never exits
select {
case <-ch:
// Handle message
}
}
}()
// If ch is never closed, goroutine leaks
}
// ✅ GOOD: Provide a way to exit
func goodFunction() {
ch := make(chan int)
done := make(chan bool)
go func() {
for {
select {
case <-ch:
// Handle message
case <-done:
return // Exit goroutine
}
}
}()
// Later: close(done) to signal exit
}
3. Don't launch goroutines in loops without care
Loop variables can cause issues (though improved in Go 1.23):
// ⚠️ CAUTION: May have issues in older Go versions
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i) // All may print 10
}()
}
// ✅ GOOD: Pass variable as argument
for i := 0; i < 10; i++ {
go func(id int) {
fmt.Println(id) // Each prints different value
}(i)
}
4. Limit the number of goroutines
While goroutines are lightweight, unlimited goroutines can still cause problems:
// ❌ BAD: May create too many goroutines
func processItems(items []Item) {
for _, item := range items {
go processItem(item) // Could be millions!
}
}
// ✅ GOOD: Use worker pool pattern
func processItems(items []Item) {
const numWorkers = 10
jobs := make(chan Item, len(items))
var wg sync.WaitGroup
// Start workers
for w := 0; w < numWorkers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for item := range jobs {
processItem(item)
}
}()
}
// Send jobs
for _, item := range items {
jobs <- item
}
close(jobs)
wg.Wait()
}
5. Handle errors in goroutines
Errors in goroutines are easy to miss. Use channels or error groups:
// ✅ GOOD: Collect errors from goroutines
func processWithErrors(items []Item) error {
errChan := make(chan error, len(items))
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(it Item) {
defer wg.Done()
if err := processItem(it); err != nil {
errChan <- err
}
}(item)
}
go func() {
wg.Wait()
close(errChan)
}()
// Collect errors
var errors []error
for err := range errChan {
errors = append(errors, err)
}
if len(errors) > 0 {
return fmt.Errorf("processing failed: %v", errors)
}
return nil
}
6. Use context for cancellation
Context provides a standard way to cancel goroutines:
// ✅ GOOD: Use context for cancellation
func processWithContext(ctx context.Context, items []Item) error {
for _, item := range items {
select {
case <-ctx.Done():
return ctx.Err() // Cancelled
default:
if err := processItem(item); err != nil {
return err
}
}
}
return nil
}
These practices help you write robust, efficient concurrent Go programs.