Go Concurrency

Goroutines, channels, and the sync primitives that keep concurrent code correct.

Goroutines Channels Select WaitGroup Mutex sync.Once sync/atomic Context
๐Ÿš€

Goroutines

โ„น๏ธ
A goroutine is a lightweight thread managed by the Go runtime โ€” not an OS thread. Starting one costs ~2 KB of stack (grows as needed). You can run hundreds of thousands concurrently. The runtime multiplexes them onto OS threads automatically.
Starting goroutines go
// go keyword starts a goroutine โ€” returns immediately
go doWork()

// With an anonymous function
go func() {
    fmt.Println("running concurrently")
}()

// Pass args at launch โ€” value captured now, not when goroutine runs
msg := "hello"
go func(s string) {
    fmt.Println(s)
}(msg)

// main returning kills all goroutines immediately
func main() {
    go doWork()
    // program may exit before doWork runs โ€” use WaitGroup or channel
}
โš ๏ธ
Goroutine leak: never start a goroutine you can't stop. If it's blocked on a channel receive with no sender, or loops forever with no exit path, it stays in memory for the life of the process. Always provide a way out โ€” a done channel, a context cancellation, or a timeout.
๐Ÿ“ก

Channels

  Unbuffered channel โ€” sender and receiver must meet simultaneously

  goroutine A                  ch               goroutine B
  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”           โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”           โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚  ch <- v  โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚        โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚  v := <-ch โ”‚
  โ”‚  (blocks) โ”‚           โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜           โ”‚ (unblocks  โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                               โ”‚   sender)  โ”‚
                                               โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
  Built-in synchronisation โ€” the send only completes when received.
Basic send, receive & close chan
// make(chan T) โ€” unbuffered channel
ch := make(chan int)

go func() { ch <- 42 }()  // send โ€” blocks until received
v := <-ch                   // receive โ€” blocks until sent
fmt.Println(v)              // 42

// Comma-ok โ€” detect if channel is closed
v, ok := <-ch  // ok=false when channel is closed and drained

// close signals no more values will be sent
jobs := make(chan string, 3)
jobs <- "a"; jobs <- "b"; jobs <- "c"
close(jobs)

// range drains channel then exits when closed
for j := range jobs {
    fmt.Println(j) // a, b, c
}
Directional channels โ€” restrict send or receive Direction
// chan<- T  send-only      <-chan T  receive-only
// Use in signatures to document intent and let the compiler enforce it

func producer(out chan<- int) {
    for i := 0; i < 5; i++ {
        out <- i
    }
    close(out)
}

func consumer(in <-chan int) {
    for v := range in {
        fmt.Println(v)
    }
}

ch := make(chan int)
go producer(ch)
consumer(ch)
โš ๏ธ
Sending on a closed channel panics. Closing an already-closed channel panics. Only the sender should close, and only when there is exactly one sender. With multiple senders, use a sync.WaitGroup to detect when all are done, then close from a separate goroutine.
๐Ÿ—„๏ธ

Buffered Channels

  ch := make(chan int, 3)    // capacity 3

  โ”Œโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”
  โ”‚  1  โ”‚  2  โ”‚  3  โ”‚  <โ”€โ”€ full: next send blocks until a slot is freed
  โ””โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”˜

  Sender blocks only when buffer is full.
  Receiver blocks only when buffer is empty.
Buffered basics & semaphore pattern Buffered
// Sender doesn't block until buffer is full
ch := make(chan int, 3)
ch <- 1; ch <- 2; ch <- 3
// ch <- 4  would block here โ€” buffer full

fmt.Println(len(ch)) // 3 โ€” items currently buffered
fmt.Println(cap(ch)) // 3 โ€” total capacity

// Semaphore โ€” limit to N goroutines running at once
const maxWorkers = 5
sem := make(chan struct{}, maxWorkers)

for _, task := range tasks {
    sem <- struct{}{}           // acquire โ€” blocks when 5 are running
    go func(t Task) {
        defer func() { <-sem }() // release on exit
        process(t)
    }(task)
}
๐Ÿ’ก
Use a buffered channel when producer and consumer run at different speeds and you want to absorb bursts. Use an unbuffered channel when you need guaranteed synchronisation โ€” the send only completes when someone is ready to receive.
๐Ÿ”€

Select

Multiplexing channels select
// select blocks until one case is ready โ€” random if multiple are ready
select {
case v := <-ch1:
    fmt.Println("from ch1:", v)
case v := <-ch2:
    fmt.Println("from ch2:", v)
case ch3 <- 99:
    fmt.Println("sent to ch3")
}

// default โ€” makes select non-blocking
select {
case v := <-results:
    handle(v)
default:
    fmt.Println("nothing ready")
}
Timeout pattern Timeout
// time.After returns a channel that fires after d
select {
case result := <-work:
    fmt.Println("got:", result)
case <-time.After(2 * time.Second):
    fmt.Println("timed out")
}
Done channel โ€” broadcast stop Done
// Closing a channel wakes ALL receivers at once
done := make(chan struct{})

go func() {
    for {
        select {
        case <-done:
            return
        case v := <-work:
            process(v)
        }
    }
}()

close(done) // signal all workers to stop
โณ

WaitGroup

  wg.Add(3) โ”€โ”€โ–ถ internal counter = 3

  go work() โ”€โ”€โ”
  go work() โ”€โ”€โ”ผโ”€โ”€ each calls wg.Done() on exit  (counter--)
  go work() โ”€โ”€โ”˜

  wg.Wait() โ”€โ”€ blocks main goroutine until counter reaches 0
Fan-out with WaitGroup WaitGroup
var wg sync.WaitGroup

urls := []string{"a.com", "b.com", "c.com"}

for _, url := range urls {
    wg.Add(1)                  // before launching โ€” not inside the goroutine
    go func(u string) {
        defer wg.Done()       // defer so it runs even on panic
        fetch(u)
    }(url)
}

wg.Wait()
fmt.Println("all fetches done")
Fan-out + fan-in โ€” collecting results Pattern
results := make(chan string, len(urls))

var wg sync.WaitGroup
for _, url := range urls {
    wg.Add(1)
    go func(u string) {
        defer wg.Done()
        results <- fetch(u)
    }(url)
}

// Close results once all workers finish
go func() { wg.Wait(); close(results) }()

for r := range results {
    fmt.Println(r)
}
๐Ÿ”

Mutex

โ„น๏ธ
Use a mutex when multiple goroutines share mutable state and channels would overcomplicate the design. The Go mantra is "share memory by communicating" โ€” but a mutex is simply the right tool when protecting a single shared value or struct.
sync.Mutex โ€” exclusive access Mutex
type SafeCounter struct {
    mu    sync.Mutex
    count int
}

func (c *SafeCounter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()  // defer prevents forgetting to unlock on every path
    c.count++
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

sc := &SafeCounter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() { defer wg.Done(); sc.Inc() }()
}
wg.Wait()
fmt.Println(sc.Value()) // 1000
sync.RWMutex โ€” concurrent reads, exclusive writes RWMutex
// RLock/RUnlock โ€” many readers allowed simultaneously
// Lock/Unlock   โ€” exclusive write, blocks all readers and writers
type Cache struct {
    mu    sync.RWMutex
    items map[string]string
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    v, ok := c.items[key]
    return v, ok
}

func (c *Cache) Set(key, val string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = val
}
โš ๏ธ
Never copy a mutex after first use โ€” the internal state makes a copy invalid. Pass structs containing mutexes by pointer, not by value. go vet detects this automatically.
1๏ธโƒฃ

sync.Once

Guaranteed single execution across goroutines Once
// Do(f) runs f exactly once, even if called from many goroutines
var (
    once     sync.Once
    instance *DB
)

func GetDB() *DB {
    once.Do(func() {
        instance = connectDB()
    })
    return instance
}

// All concurrent callers wait for Do to finish, then get the same instance
for i := 0; i < 10; i++ {
    go func() { use(GetDB()) }()
}
๐Ÿ’ก
sync.Once is the correct way to implement lazy initialisation in Go. It's safer than a nil check guarded by a mutex because it handles the double-checked locking problem automatically โ€” and the compiler can't reorder it away.
โš›๏ธ

sync/atomic

โ„น๏ธ
Atomic operations are lock-free and faster than a mutex for single values. Use them for counters, flags, and version stamps. Use a mutex when you need to protect a group of related fields together as a unit.
Counters & flags atomic
import "sync/atomic"

var counter atomic.Int64

counter.Add(1)           // increment
counter.Add(-1)          // decrement
v := counter.Load()     // safe read
counter.Store(0)         // reset

// Bool flag
var ready atomic.Bool
ready.Store(true)
if ready.Load() {
    fmt.Println("ready")
}
Compare-and-swap (CAS) CAS
// Swaps only if current value == old; returns true if it did
var state atomic.Int32

const (idle = 0; running = 1)

// Transition idle โ†’ running exactly once
if state.CompareAndSwap(idle, running) {
    go doWork()
}

// Swap โ€” unconditional, returns old value
old := state.Swap(idle)
fmt.Println("was:", old)
๐ŸŽฏ

Context

โ„น๏ธ
Context is the standard way to propagate cancellation and deadlines across goroutine boundaries. Pass it as the first argument to any function that starts goroutines or calls external systems. The full context API is on the context package page.
Cancelling goroutines WithCancel
ctx, cancel := context.WithCancel(context.Background())
defer cancel()  // always defer โ€” releases context resources

go func() {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("stopped:", ctx.Err())
            return
        case v := <-work:
            process(v)
        }
    }
}()

cancel() // ctx.Done() is closed, goroutine exits on next select
Timeout โ€” deadline across the call tree WithTimeout
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

// HTTP request honours the deadline automatically
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if errors.Is(err, context.DeadlineExceeded) {
    fmt.Println("timed out")
}

// Manual check in your own goroutine
select {
case result := <-compute(ctx):
    use(result)
case <-ctx.Done():
    fmt.Println("deadline exceeded")
}
๐Ÿ“‹

Quick Reference

Concept Syntax Notes
Start goroutinego f()Returns immediately; goroutine runs concurrently
Unbuffered channelmake(chan T)Sender blocks until receiver is ready
Buffered channelmake(chan T, n)Sender blocks only when buffer is full
Sendch <- vBlocks if full / no receiver
Receivev := <-chBlocks if empty / no sender
Comma-ok receivev, ok := <-chok=false when closed and drained
Closeclose(ch)Sender only; panics if already closed
Drain channelfor v := range chExits when channel is closed
Send-only paramfunc f(ch chan<- T)Compiler enforces direction
Receive-only paramfunc f(ch <-chan T)Compiler enforces direction
Selectselect { case <-ch: }Blocks until one case is ready
Non-blocking selectselect { โ€ฆ default: }Falls through if no case ready
Timeoutcase <-time.After(d):Fires after duration d
Broadcast stopclose(done)Unblocks all goroutines selecting on done
WaitGroup addwg.Add(n)Call before launching the goroutine
WaitGroup donedefer wg.Done()Inside each goroutine; defer is safest
WaitGroup waitwg.Wait()Blocks until counter reaches 0
Mutex lockmu.Lock() + defer mu.Unlock()Exclusive; always defer unlock
RWMutex readmu.RLock() / mu.RUnlock()Multiple readers allowed at once
Onceonce.Do(func() { โ€ฆ })Runs exactly once across all goroutines
Atomic countervar c atomic.Int64; c.Add(1)Lock-free; one value at a time
Atomic loadc.Load()Safe concurrent read
CASc.CompareAndSwap(old, new)Returns true if swap occurred
Cancel contextctx, cancel := context.WithCancel(p)Always defer cancel()
Timeout contextcontext.WithTimeout(p, d)ctx.Done() closes after d
Check cancelled<-ctx.Done()Closed on cancel or deadline