Goroutines
go ยท lightweight ยท leakA 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
make ยท send ยท receive ยท close ยท range 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
capacity ยท semaphore ยท decouplech := 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
multiplex ยท default ยท timeout ยท done
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
Add ยท Done ยท Wait ยท fan-outwg.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
Lock ยท Unlock ยท RWMutexUse 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.sync.Once
init once ยท singleton ยท lazy load
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
Add ยท Load ยท Store ยท CASAtomic 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
cancellation ยท timeout ยท propagationContext 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
Syntax cheat-sheet| Concept | Syntax | Notes |
|---|---|---|
| Start goroutine | go f() | Returns immediately; goroutine runs concurrently |
| Unbuffered channel | make(chan T) | Sender blocks until receiver is ready |
| Buffered channel | make(chan T, n) | Sender blocks only when buffer is full |
| Send | ch <- v | Blocks if full / no receiver |
| Receive | v := <-ch | Blocks if empty / no sender |
| Comma-ok receive | v, ok := <-ch | ok=false when closed and drained |
| Close | close(ch) | Sender only; panics if already closed |
| Drain channel | for v := range ch | Exits when channel is closed |
| Send-only param | func f(ch chan<- T) | Compiler enforces direction |
| Receive-only param | func f(ch <-chan T) | Compiler enforces direction |
| Select | select { case <-ch: } | Blocks until one case is ready |
| Non-blocking select | select { โฆ default: } | Falls through if no case ready |
| Timeout | case <-time.After(d): | Fires after duration d |
| Broadcast stop | close(done) | Unblocks all goroutines selecting on done |
| WaitGroup add | wg.Add(n) | Call before launching the goroutine |
| WaitGroup done | defer wg.Done() | Inside each goroutine; defer is safest |
| WaitGroup wait | wg.Wait() | Blocks until counter reaches 0 |
| Mutex lock | mu.Lock() + defer mu.Unlock() | Exclusive; always defer unlock |
| RWMutex read | mu.RLock() / mu.RUnlock() | Multiple readers allowed at once |
| Once | once.Do(func() { โฆ }) | Runs exactly once across all goroutines |
| Atomic counter | var c atomic.Int64; c.Add(1) | Lock-free; one value at a time |
| Atomic load | c.Load() | Safe concurrent read |
| CAS | c.CompareAndSwap(old, new) | Returns true if swap occurred |
| Cancel context | ctx, cancel := context.WithCancel(p) | Always defer cancel() |
| Timeout context | context.WithTimeout(p, d) | ctx.Done() closes after d |
| Check cancelled | <-ctx.Done() | Closed on cancel or deadline |