Skip to main content
Go Concurrency Patterns

Concurrency in Go

Go is famous for its concurrency model. Unlike many other languages that treat concurrency as an afterthought or a library add-on, Go builds concurrency directly into the language core.

Goroutines

A goroutine is a lightweight thread managed by the Go runtime.

The go Keyword

To start a goroutine, simply use the go keyword before a function call.
package main

import (
	"fmt"
	"time"
)

func say(s string) {
	for i := 0; i < 3; i++ {
		time.Sleep(100 * time.Millisecond)
		fmt.Println(s)
	}
}

func main() {
	go say("world") // Starts a new goroutine
	say("hello")    // Runs in the main goroutine
}

How Goroutines Work

Goroutines are multiplexed onto a smaller number of OS threads using the GMP model (Goroutines, Machine threads, Processors). This M:N scheduling allows Go to efficiently run thousands of goroutines on just a handful of OS threads. Key Characteristics:
  • Lightweight: Start with a tiny 2KB stack that grows/shrinks dynamically.
  • Fast Context Switching: Switching between goroutines is much faster than OS thread context switches.
  • Work Stealing: Idle processors can steal work from busy ones for load balancing.
Go Runtime Architecture Memory Allocation: Local variables in goroutines typically live on the goroutine’s stack. However, if a variable “escapes” (e.g., returned as a pointer, captured by a closure that outlives the function), it’s allocated on the heap. Go Memory Allocation
func createPerson() *Person {
    p := Person{Name: "Alice"} // Allocated on heap (escapes via return)
    return &p
}

func localOnly() {
    x := 42 // Allocated on stack (doesn't escape)
    fmt.Println(x)
}

Channels

Channels are the pipes that connect concurrent goroutines. You can send values into channels from one goroutine and receive those values into another goroutine.

Creating Channels

ch := make(chan int) // Unbuffered channel of integers

Sending and Receiving

ch <- v    // Send v to channel ch.
v := <-ch  // Receive from ch, and assign value to v.

Unbuffered vs. Buffered Channels

  • Unbuffered Channels: Sending blocks until the receiver is ready. Receiving blocks until the sender is ready. This provides synchronization.
  • Buffered Channels: Sending only blocks if the buffer is full. Receiving only blocks if the buffer is empty.
ch := make(chan int, 100) // Buffered channel with capacity 100

Channel Internals

Under the hood, a channel is a struct hchan that contains a circular buffer, send/receive indices, and wait queues for blocked goroutines. Go Channel Internals

The select Statement

The select statement lets a goroutine wait on multiple communication operations. A select blocks until one of its cases can run, then it executes that case. It chooses one at random if multiple are ready.
select {
case msg1 := <-c1:
    fmt.Println("received", msg1)
case msg2 := <-c2:
    fmt.Println("received", msg2)
case <-time.After(1 * time.Second):
    fmt.Println("timeout")
default:
    fmt.Println("no activity")
}

Synchronization Primitives (sync package)

While channels are great for passing data, sometimes you just need to coordinate state.

WaitGroup

sync.WaitGroup waits for a collection of goroutines to finish.
var wg sync.WaitGroup

for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(i int) {
        defer wg.Done()
        fmt.Printf("Worker %d starting\n", i)
        time.Sleep(time.Second)
        fmt.Printf("Worker %d done\n", i)
    }(i)
}

wg.Wait() // Blocks until the WaitGroup counter is zero

Mutex

sync.Mutex provides a mutual exclusion lock to prevent data races.
type SafeCounter struct {
	mu sync.Mutex
	v  map[string]int
}

func (c *SafeCounter) Inc(key string) {
	c.mu.Lock()
	// Lock so only one goroutine at a time can access the map c.v.
	c.v[key]++
	c.mu.Unlock()
}

Best Practices

  1. Share Memory by Communicating: Don’t communicate by sharing memory. Use channels to pass ownership of data.
  2. Detect Race Conditions: Run your tests with go test -race to detect data races.
  3. Avoid Leaking Goroutines: Ensure every goroutine you start has a way to exit.

Summary

  • Goroutines are lightweight threads managed by the Go runtime.
  • Channels allow safe communication and synchronization between goroutines.
  • Select allows waiting on multiple channel operations.
  • Sync Package provides low-level primitives like Mutex and WaitGroup for finer control.