Concurrency and Multithreading in Go: Advanced Level
Go (Golang) was designed from the start as a language for writing scalable and performant applications. At its core lies a powerful concurrency model based on goroutines and channels. If you've already mastered basic examples with go func() and simple channels, it's time to move to the next level. In this article, we will explore advanced concurrency patterns in Go that will help you write reliable, efficient, and safe code.
1. Managing Goroutine Lifecycle: Context and WaitGroup
One of the most common problems is goroutine leaks. A goroutine that endlessly waits for data from a channel will never complete if the channel is closed incorrectly. Go provides two main tools for managing goroutine lifecycles: sync.WaitGroup and context.Context.
sync.WaitGroup for Waiting for Completion
Use sync.WaitGroup when you need to wait for a fixed set of goroutines to finish. It's a simple and efficient mechanism.
package main
import (\t"fmt"\t"sync"\t"time")
func worker(id int, wg *sync.WaitGroup) {\tdefer wg.Done() // Decrement the counter upon exit\tfmt.Printf("Worker %d started work", id)\ttime.Sleep(time.Second)\tfmt.Printf("Worker %d finished work", id)}
func main() {\tvar wg sync.WaitGroup
\tfor i := 1; i <= 3; i++ {\t\twg.Add(1) // Increment the counter before launching\t\tgo worker(i, &wg)\t}
\twg.Wait() // Block until the counter becomes 0\tfmt.Println("All workers have finished")}context.Context for Cancellation and Deadlines
When a goroutine runs in a loop or performs a long operation, it needs to be able to terminate gracefully upon an external signal. context.Context is the standard way to pass a cancellation signal, deadline, or timeout.
package main
import (\t"context"\t"fmt"\t"time")
func longRunningTask(ctx context.Context) {\tfor {\t\tselect {\t\tcase <-ctx.Done():\t\t\tfmt.Println("Goroutine received stop signal")\t\t\treturn\t\tdefault:\t\t\ttime.Sleep(500 * time.Millisecond)\t\t\tfmt.Println("Working...")\t\t}\t}}
func main() {\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\tdefer cancel() // Important to call cancel to free resources
\tgo longRunningTask(ctx)
\ttime.Sleep(3 * time.Second)\tfmt.Println("Main finished")}2. Channels: Buffering, Closing, and the Fan-Out/Fan-In Pattern
Channels are the primary way to synchronize and pass data between goroutines. At an advanced level, it's important to understand the difference between buffered and unbuffered channels, close them correctly, and use aggregation patterns.
Buffered vs Unbuffered
An unbuffered channel (no buffer) blocks the sender until the receiver reads the data. A buffered channel allows sending several messages without waiting until the buffer is full. The choice depends on the task: use unbuffered for strict synchronization, and buffered for smoothing load spikes.
Fan-Out/Fan-In Pattern
This is one of the most powerful patterns. Fan-Out distributes tasks across multiple goroutines (workers). Fan-In collects results from multiple goroutines into a single channel.
package main
import (\t"fmt"\t"sync")
func producer(nums ...int) <-chan int {\tout := make(chan int)\tgo func() {\t\tfor _, n := range nums {\t\t\tout <- n\t\t}\t\tclose(out)\t}()\treturn out}
func square(in <-chan int) <-chan int {\tout := make(chan int)\tgo func() {\t\tfor n := range in {\t\t\tout <- n * n\t\t}\t\tclose(out)\t}()\treturn out}
func merge(channels ...<-chan int) <-chan int {\t