Goroutine
Lesson 13: Goroutine
Imagine a restaurant: if there's only one waiter (single thread), they must finish taking orders, serving food, and billing each table before moving to the next. Hiring many waiters (multi-threading) would be expensive. Go's solution is to hire a group of lightweight part-time waiters (goroutines) who share the same restaurant system (scheduler) — whoever is available serves next, handling a large number of customers with minimal resources.
Core Concepts
- A Goroutine is a lightweight concurrent execution unit managed by the Go runtime, launched with the
gokeyword. - Goroutines are not OS threads; their initial stack is only about 2KB (threads typically require 1-8MB), and you can easily create hundreds of thousands of them.
- The Go runtime uses an M:N scheduling model (G-M-P model): mapping M goroutines onto N OS threads for execution.
- Goroutines communicate via channels (covered in detail in later lessons) and can also be synchronized using the
syncpackage. - When the main goroutine exits, all child goroutines are forcibly terminated without waiting for them to finish.
Basic Syntax and Usage
Launching a Goroutine
Add the go keyword before a function call to launch a new goroutine:
go functionName(args)
// or
go func() {
// anonymous function body
}()
Waiting for Goroutines with sync.WaitGroup
Since the main goroutine does not automatically wait for child goroutines, use sync.WaitGroup for synchronization:
var wg sync.WaitGroup
wg.Add(1) // increment counter by 1
go func() {
defer wg.Done() // decrement counter when done
// perform task
}()
wg.Wait() // block until counter reaches zero
wg.Add() must be called before the go statement, otherwise a race condition may occur — the main goroutine might return from Wait before Add is called.
wg.Add() inside the goroutine, otherwise wg.Wait() may execute before Add.
defer wg.Done() to ensure the counter decrements correctly even if a panic occurs.
Examples
Example: Launching Multiple Goroutines (Difficulty ⭐)
package main
import (
"fmt"
"sync"
)
func sayHello(id int, wg *sync.WaitGroup) {
defer wg.Done() // notify WaitGroup when done
fmt.Printf("Hello, I am goroutine #%d\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go sayHello(i, &wg)
}
wg.Wait() // wait for all goroutines to finish
fmt.Println("All goroutines have completed")
}
Output order is non-deterministic (concurrent execution):
Hello, I am goroutine #3
Hello, I am goroutine #1
Hello, I am goroutine #5
Hello, I am goroutine #2
Hello, I am goroutine #4
All goroutines have completed
Example: Concurrent Computation and Result Aggregation (Difficulty ⭐⭐)
package main
import (
"fmt"
"sync"
)
// Concurrently compute squares and store results in a shared slice
func main() {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
results := make([]int, len(numbers))
var wg sync.WaitGroup
for i, num := range numbers {
wg.Add(1)
go func(index, val int) {
defer wg.Done()
results[index] = val * val // each goroutine writes to a different index, no lock needed
}(i, num) // pass loop variables as arguments to avoid closure capture issues
}
wg.Wait()
fmt.Println("Original data:", numbers)
fmt.Println("Squared results:", results)
}
Output:
Original data: [1 2 3 4 5 6 7 8 9 10]
Squared results: [1 4 9 16 25 36 49 64 81 100]
for loop variable creates a new copy on each iteration, so explicit parameter passing is no longer required. However, for compatibility and readability, explicit passing is still recommended.
Example: Concurrent Task Pool and Goroutine Leak Prevention (Difficulty ⭐⭐⭐)
package main
import (
"context"
"fmt"
"math/rand"
"sync"
"time"
)
// task represents a task that needs to be processed
func task(ctx context.Context, id int) (string, error) {
// Simulate a time-consuming operation
duration := time.Duration(rand.Intn(500)) * time.Millisecond
select {
case <-time.After(duration):
return fmt.Sprintf("Task #%d completed (took %v)", id, duration), nil
case <-ctx.Done():
return "", ctx.Err() // return immediately when context is cancelled, preventing goroutine leak
}
}
func main() {
rand.Seed(time.Now().UnixNano())
// Set a total timeout to prevent goroutine leaks
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // ensure resource release
taskCount := 20
results := make(chan string, taskCount) // buffered channel to collect results
var wg sync.WaitGroup
// Start 5 worker goroutines as a task pool
workerCount := 5
for w := 1; w <= workerCount; w++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for i := workerID; i <= taskCount; i += workerCount {
res, err := task(ctx, i)
if err != nil {
fmt.Printf("Worker %d: Task #%d cancelled\n", workerID, i)
return
}
results <- res
}
}(w)
}
// Close the channel after all workers finish
go func() {
wg.Wait()
close(results)
}()
// Collect results
for res := range results {
fmt.Println(res)
}
fmt.Println("All tasks processed")
}
Key Points:
- Using
context.WithTimeoutcontrols overall timeout, avoiding infinite goroutine waiting. - The task pool pattern (fixed number of workers) is more controllable than one goroutine per task.
close(results)closes the channel after all workers finish;range resultsautomatically exits when the channel is closed.
Practical Use Cases
Case 1: Concurrent HTTP Requests
package main
import (
"fmt"
"io"
"net/http"
"sync"
"time"
)
func fetchURL(url string, wg *sync.WaitGroup) {
defer wg.Done()
start := time.Now()
resp, err := http.Get(url)
if err != nil {
fmt.Printf("[Error] %s: %v\n", url, err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
elapsed := time.Since(start)
fmt.Printf("[Done] %s — Status: %d, Size: %d bytes, Time: %v\n",
url, resp.StatusCode, len(body), elapsed)
}
func main() {
urls := []string{
"https://httpbin.org/get",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/2",
"https://httpbin.org/status/200",
}
var wg sync.WaitGroup
start := time.Now()
for _, url := range urls {
wg.Add(1)
go fetchURL(url, &wg)
}
wg.Wait()
fmt.Printf("\nAll done, total time: %v\n", time.Since(start))
// Total time is approximately equal to the slowest request, not the sum of all requests
}
Case 2: Asynchronous Log Writing
package main
import (
"fmt"
"time"
)
type LogEntry struct {
Level string
Message string
}
// logWriter asynchronously consumes logs in a background goroutine
func logWriter(entries <-chan LogEntry, done chan<- struct{}) {
for entry := range entries {
// Simulate writing to a file or remote service
time.Sleep(50 * time.Millisecond)
fmt.Printf("[%s] %s\n", entry.Level, entry.Message)
}
done <- struct{}{} // notify the main goroutine that writing is complete
}
func main() {
logCh := make(chan LogEntry, 100) // buffered channel as a log queue
done := make(chan struct{})
// Start background log writing goroutine
go logWriter(logCh, done)
// Main program runs normally, logging asynchronously
for i := 1; i <= 5; i++ {
logCh <- LogEntry{
Level: "INFO",
Message: fmt.Sprintf("Processing request #%d", i),
}
fmt.Printf("Submitted log #%d\n", i)
}
close(logCh) // close the channel to signal the writer to exit
<-done // wait for the writer to finish
fmt.Println("Program exited")
}
❓ FAQ
1. Why isn't my goroutine executing?
When the main goroutine exits, it forcibly terminates all child goroutines. Common cause:
// ❌ Wrong: main goroutine exits immediately, child goroutine has no chance to run
func main() {
go fmt.Println("Hello")
}
// ✅ Correct: use WaitGroup to wait
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("Hello")
}()
wg.Wait()
}
2. When do goroutines leak?
A goroutine leak means a goroutine can never exit, continuously occupying memory and scheduler resources. Common cause:
// ❌ Leak: channel has no receiver
func leaky() <-chan int {
ch := make(chan int)
go func() {
ch <- 42 // permanently blocked, goroutine cannot exit
}()
return ch
}
// ✅ Safe: use a buffered channel or context cancellation
func safe() <-chan int {
ch := make(chan int, 1) // buffer of 1, send won't block
go func() {
ch <- 42
}()
return ch
}
3. How to avoid the classic closure capture trap with loop variables?
// ❌ Go 1.21 and earlier: all goroutines may print the same value
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // may all print 5
}()
}
// ✅ Recommended: explicit parameter passing, compatible with all Go versions
for i := 0; i < 5; i++ {
go func(n int) {
fmt.Println(n) // correctly prints 0, 1, 2, 3, 4
}(i)
}
4. Can I get the current number of running goroutines?
import "runtime"
fmt.Println("Current goroutine count:", runtime.NumGoroutine())
This is very useful for debugging goroutine leaks. A normal idle program typically has only 1-2 goroutines; if the number keeps growing, there may be a leak.
📖 Summary
| Concept | Description |
|---|---|
go keyword |
Launches a goroutine, syntax is go func() |
| Scheduling model | G-M-P model, Go runtime handles scheduling, transparent to the user |
| Lightweight | Initial stack is about 2KB, can create hundreds of thousands |
| WaitGroup | Used to wait for a group of goroutines to complete |
| Context | Used to propagate cancellation signals and timeout control |
| Leak prevention | Ensure every goroutine has an exit path |
Core Principles:
- Always ensure goroutines have clear exit conditions.
- Use
sync.WaitGroupor channels to synchronize the main goroutine with child goroutines. - Use
contextto control goroutine lifecycles and prevent leaks. - Be mindful of the interaction between closures and loop variables (explicit parameter passing is recommended).
📝 Exercises
Exercise 1: Concurrent Prime Number Checker
Write a program that uses goroutines to concurrently check whether a set of numbers are prime, then aggregate and output the results.
// Hints:
// - Define an isPrime(n int) bool function
// - Launch a goroutine for each number
// - Use WaitGroup to wait for all to complete
// - Use a mutex or channel to collect results
Exercise 2: Concurrent Download Simulator
Simulate concurrent file downloads: start 3 worker goroutines that pick tasks from a task queue, each download takes a random 100-1000ms, and report the total time and the number of tasks completed by each worker.
// Hints:
// - Use a channel as the task queue
// - Each worker reads tasks from the channel
// - Use context.WithTimeout to control the total timeout
// - Use sync.Map or a mutex to count each worker's completions
Exercise 3: Goroutine Leak Detector
Write a utility function that accepts a task function as a parameter, executes it, and monitors the goroutine count. If the goroutine count after the task completes is higher than before execution, it indicates a possible leak — output a warning.
// Hints:
// - Use runtime.NumGoroutine() to get the goroutine count
// - Record the count before and after task execution
// - Wait a short time (e.g., 100ms) after execution before checking
// - Output the detection result



