Concurrency#

Go was designed with concurrency as a first-class feature. Goroutines are lightweight threads managed by the Go runtime, and channels are typed conduits for communication between them. The motto: “Don’t communicate by sharing memory; share memory by communicating.”


Goroutines#

A goroutine is a lightweight concurrent function invocation. Starting one costs ~2 KB of stack (grows as needed) and is scheduled by the Go runtime, not the OS.

go doWork()  // starts doWork as a goroutine

go func() {
    // anonymous goroutine
    fmt.Println("running concurrently")
}()

Goroutines vs Threads#

GoroutineOS Thread
Stack size~2 KB (grows dynamically)~1 MB (fixed)
Creation cost~1 μs~100 μs
SchedulingGo runtime (M:N)OS kernel
Practical countMillionsThousands
CommunicationChannels (safe by design)Shared memory + locks

Channels#

Channels are typed conduits for sending and receiving values between goroutines:

ch := make(chan int)  // unbuffered channel of int

// Send
go func() {
    ch <- 42  // blocks until someone receives
}()

// Receive
value := <-ch  // blocks until someone sends
fmt.Println(value)  // 42

Buffered Channels#

ch := make(chan int, 3)  // buffer size 3

ch <- 1  // does not block
ch <- 2  // does not block
ch <- 3  // does not block
ch <- 4  // BLOCKS — buffer full, waits for a receive
Channel TypeBehaviour
Unbuffered make(chan T)Send blocks until receive, and vice versa — synchronisation point
Buffered make(chan T, n)Send blocks only when buffer is full; receive blocks when empty

Directional Channels#

Restrict a channel to send-only or receive-only in function signatures:

func producer(out chan<- int) {  // send-only
    for i := 0; i < 10; i++ {
        out <- i
    }
    close(out)
}

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

Closing Channels#

close(ch)

// Receiving from a closed channel returns the zero value immediately
v := <-ch  // 0 for int (and ok=false)

// Range loop exits when channel is closed
for v := range ch {
    process(v)
}

// Check if channel is closed
v, ok := <-ch
if !ok {
    fmt.Println("channel closed")
}

Rules:

  • Only the sender should close a channel
  • Sending on a closed channel panics
  • Receiving from a closed channel returns zero values

Select#

select is like switch for channels — it waits on multiple channel operations:

select {
case msg := <-ch1:
    fmt.Println("received from ch1:", msg)
case msg := <-ch2:
    fmt.Println("received from ch2:", msg)
case ch3 <- value:
    fmt.Println("sent to ch3")
default:
    fmt.Println("no channel ready")  // non-blocking if included
}

Timeout Pattern#

select {
case result := <-ch:
    fmt.Println(result)
case <-time.After(5 * time.Second):
    fmt.Println("timed out")
}

Context Cancellation#

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

select {
case result := <-doWork(ctx):
    fmt.Println(result)
case <-ctx.Done():
    fmt.Println("cancelled:", ctx.Err())
}

Sync Primitives#

WaitGroup#

Wait for a collection of goroutines to finish:

var wg sync.WaitGroup

for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(n int) {
        defer wg.Done()
        fmt.Println("worker", n)
    }(i)
}

wg.Wait()  // blocks until all Done() calls

Mutex#

Protect shared state:

type SafeCounter struct {
    mu sync.Mutex
    v  map[string]int
}

func (c *SafeCounter) Inc(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.v[key]++
}

func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.v[key]
}

RWMutex#

Multiple readers, single writer:

var mu sync.RWMutex

// Read lock — multiple goroutines can hold simultaneously
mu.RLock()
defer mu.RUnlock()

// Write lock — exclusive
mu.Lock()
defer mu.Unlock()

Once#

Execute something exactly once, regardless of how many goroutines call it:

var once sync.Once
var db *sql.DB

func getDB() *sql.DB {
    once.Do(func() {
        db, _ = sql.Open("postgres", connStr)
    })
    return db
}

Concurrency Patterns#

Fan-Out / Fan-In#

// Fan-out: distribute work across multiple goroutines
func fanOut(input <-chan int, workers int) []<-chan int {
    channels := make([]<-chan int, workers)
    for i := 0; i < workers; i++ {
        channels[i] = worker(input)
    }
    return channels
}

// Fan-in: merge multiple channels into one
func fanIn(channels ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    for _, ch := range channels {
        wg.Add(1)
        go func(c <-chan int) {
            defer wg.Done()
            for v := range c {
                out <- v
            }
        }(ch)
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

Pipeline#

func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

// Usage: generate → square → consume
for v := range square(generate(1, 2, 3, 4)) {
    fmt.Println(v)  // 1, 4, 9, 16
}

Worker Pool#

func workerPool(jobs <-chan Job, results chan<- Result, numWorkers int) {
    var wg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                results <- process(job)
            }
        }()
    }
    go func() {
        wg.Wait()
        close(results)
    }()
}

Graceful Shutdown#

func main() {
    ctx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    server := &http.Server{Addr: ":8080"}

    go func() {
        if err := server.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()

    <-ctx.Done()  // wait for interrupt
    log.Println("shutting down...")

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    if err := server.Shutdown(shutdownCtx); err != nil {
        log.Fatal(err)
    }
    log.Println("server stopped")
}

Common Mistakes#

MistakeProblemFix
Goroutine leakGoroutine blocks on channel foreverUse context cancellation or done channels
Race conditionConcurrent map/slice accessUse mutex or channels
Forgetting wg.Add before goWaitGroup count wrongAlways Add before launching goroutine
Closing channel from receiverPanicOnly senders close channels
No synchronisation at exitmain returns before goroutines finishUse WaitGroup or channel receive

Race Detector#

go run -race main.go
go test -race ./...

The race detector instruments memory access at runtime. Always run tests with -race.


Key Takeaways#

  • Goroutines are cheap (~2 KB, ~1 μs to start). Use them freely for concurrent work.
  • Channels synchronise goroutines. Unbuffered channels are synchronisation points; buffered channels are queues.
  • select multiplexes channel operations. Combine with context for timeouts and cancellation.
  • Use sync.WaitGroup to wait for goroutine completion, sync.Mutex for shared state, sync.Once for one-time initialisation.
  • Always run with -race during development and testing. Data races are undefined behaviour.
  • Prefer channels for orchestration, mutexes for protecting shared data structures.