Concurrency and Race Conditions in Go: A Complete Guide from Basics to Production Patterns
22 min read

Concurrency and Race Conditions in Go: A Complete Guide from Basics to Production Patterns

Concurrency is one of the most prominent features that made Go popular. With the two-letter go keyword, you can run thousands of tasks simultaneously without the expensive overhead of OS threads. Go was designed from the start with concurrency as a first-class citizen — not a later addition — and its famous design philosophy states: “Do not communicate by sharing memory; instead, share memory by communicating.” This philosophy isn’t just a slogan; it reflects how Go encourages developers to think about concurrency through channels, rather than through locks and shared variables.

However, the ease of writing concurrent code in Go also brings new responsibility. Lightweight, easy-to-create goroutines actually make developers sometimes too quick to launch goroutines without thinking about who owns the data, who may read it, and when it may be written. The result is a race condition — the most dangerous bug and also the hardest to trace, because it can appear only under certain loads, only in production environments, and can’t be reproduced consistently. This article covers the entire concurrency ecosystem in Go from the basics, including how to detect, prevent, and fix race conditions, as well as the concurrency patterns commonly used in production code.

Goroutines: The Basic Unit of Concurrency in Go

A goroutine is a function that runs concurrently with other functions in the same program. Unlike traditional OS threads, goroutines are very lightweight — their initial stack size is only about 2 KB and can grow dynamically as needed, compared to OS threads which typically have a fixed stack of 1–8 MB.

Creating a goroutine is very simple, just use the go keyword in front of a function call:

package main

import (
    "fmt"
    "time"
)

func cetakAngka(id int) {
    for i := 0; i < 5; i++ {
        fmt.Printf("Goroutine %d: %d\n", id, i)
        time.Sleep(10 * time.Millisecond)
    }
}

func main() {
    go cetakAngka(1) // first goroutine
    go cetakAngka(2) // second goroutine
    cetakAngka(3)    // runs on the main goroutine
}

The output order of this program can’t be predicted — goroutines 1, 2, and 3 run simultaneously and Go’s scheduler decides when each gets its turn on the CPU.

Goroutine vs OS Thread

The fundamental difference between goroutines and OS threads lies in who manages them:

AspectOS ThreadGoroutine
Managed byOS kernelGo runtime
Initial stack size1–8 MB (fixed)~2 KB (dynamic)
Context switchSlow (kernel mode)Fast (user space)
Practical countHundredsMillions
CommunicationShared memory + locksChannels or shared memory

The M:N Scheduler Model

Go uses the M:N scheduler model — meaning M goroutines are scheduled on top of N OS threads. The Go runtime has its own scheduler (the runtime package) responsible for:

  • Distributing goroutines to available OS threads
  • Performing context switches between goroutines without kernel help
  • Handling goroutines doing blocking I/O so they don’t block an OS thread
flowchart TD
    subgraph "Go Runtime"
        subgraph "P1 (Processor)"
            LRQ1[Local Run Queue]
        end
        subgraph "P2 (Processor)"
            LRQ2[Local Run Queue]
        end
        GRQ[Global Run Queue]
    end
    subgraph "OS"
        M1[OS Thread 1]
        M2[OS Thread 2]
    end
    subgraph "Goroutines"
        G1[G1] & G2[G2] & G3[G3] --> LRQ1
        G4[G4] & G5[G5] --> LRQ2
        G6[G6] --> GRQ
    end
    LRQ1 --> M1
    LRQ2 --> M2

The number of P (Processors) is determined by GOMAXPROCS, which by default equals the number of available CPU cores.


Channels: Communication Between Goroutines

Channels are the primary communication mechanism between goroutines in Go. A channel is a typed “pipe” that lets one goroutine send values and another goroutine receive those values, with built-in synchronization.

// Creating a channel
ch := make(chan int)         // unbuffered channel
ch := make(chan int, 10)     // buffered channel with capacity 10

// Sending a value to the channel
ch <- 42

// Receiving a value from the channel
nilai := <-ch

Unbuffered Channels

An unbuffered channel provides a synchronous rendezvous — the sender blocks until a receiver is ready, and the receiver blocks until a sender sends a value. This makes the unbuffered channel a powerful synchronization mechanism, not just data transfer.

func main() {
    ch := make(chan string)

    go func() {
        fmt.Println("Goroutine starting work...")
        time.Sleep(500 * time.Millisecond)
        ch <- "done" // blocks here until main() is ready to receive
    }()

    hasil := <-ch // blocks here until the goroutine sends
    fmt.Println("Goroutine:", hasil)
}

Buffered Channels

A buffered channel has an internal capacity. Senders only block when the buffer is full, and receivers block when the buffer is empty. This allows decoupling between sender and receiver up to the buffer capacity limit.

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

    ch <- 1 // doesn't block, buffer still empty
    ch <- 2 // doesn't block
    ch <- 3 // doesn't block, buffer full
    // ch <- 4 // this would block because the buffer is full

    fmt.Println(<-ch) // 1
    fmt.Println(<-ch) // 2
    fmt.Println(<-ch) // 3
}

The Select Statement

select lets a single goroutine wait on several channels at once. Go picks the first case that’s ready; if more than one is ready at the same time, one is chosen at random.

func prosesRequest(reqCh <-chan string, stopCh <-chan struct{}) {
    for {
        select {
        case req := <-reqCh:
            fmt.Println("Processing:", req)
        case <-stopCh:
            fmt.Println("Stopped")
            return
        }
    }
}

select with a default case makes it non-blocking:

select {
case nilai := <-ch:
    fmt.Println("Got a value:", nilai)
default:
    fmt.Println("Channel empty, continue without blocking")
}

Closing Channels

A channel can be closed by the sender with close(ch). Receivers can detect a closed channel using the two-value idiom:

nilai, ok := <-ch
if !ok {
    fmt.Println("Channel is closed")
}

Or by ranging directly over the channel, which automatically stops when the channel is closed:

for nilai := range ch {
    fmt.Println(nilai)
}
Only the sender may close a channel, not the receiver. Sending a value to a closed channel causes a panic. Closing an already-closed channel also causes a panic. If there are multiple senders, use sync.Once or additional coordination to ensure close is only called once.

What Is a Race Condition?

A race condition occurs when two or more goroutines access the same memory location concurrently, and at least one of them performs a write, without proper synchronization. The program’s result in this condition becomes non-deterministic — depending on the execution order controlled by the scheduler, not the program’s logic.

Here’s the most classic race condition example: a counter accessed by several goroutines simultaneously:

// ANTI-PATTERN: race condition on a shared counter
package main

import (
    "fmt"
    "sync"
)

var counter int // shared state

func increment(wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 1000; i++ {
        counter++ // NOT SAFE: read + add + write is not an atomic operation
    }
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go increment(&wg)
    }
    wg.Wait()
    fmt.Println("Counter:", counter) // unpredictable result, rarely 10000
}

This program should print 10000 (10 goroutines × 1000 increments). But in reality, the result is different every run — it could be 8573, 9102, or another number. Why?

Why Is counter++ Unsafe?

Even though it looks like one instruction, counter++ actually consists of three separate steps at the hardware level:

  1. Read the counter value from memory into a CPU register
  2. Add 1 to the value in the register
  3. Write the register value back to memory

When two goroutines do this simultaneously without synchronization, interleaving can occur that makes one increment “lost”:

sequenceDiagram
    participant G1 as Goroutine 1
    participant Mem as Memory (counter)
    participant G2 as Goroutine 2
    Note over Mem: counter = 5
    G1->>Mem: Read (gets 5)
    G2->>Mem: Read (gets 5)
    G1->>G1: Add 1 = 6
    G2->>G2: Add 1 = 6
    G1->>Mem: Write 6
    G2->>Mem: Write 6
    Note over Mem: counter = 6, not 7!<br/>One increment lost!

This is why race conditions are so dangerous — from code that looks logically correct, a bug can appear just because of slightly different execution timing, which is very hard to reproduce and trace.


Detecting Race Conditions with the Race Detector

Go provides a very powerful built-in race detector. It works by instrumenting the program binary to monitor every memory access and detect unsynchronized concurrent accesses.

Using it is very easy, just add the -race flag:

# Running a program with race detection
go run -race main.go

# Running tests with race detection
go test -race ./...

# Building a binary with race detection (for staging/testing environments)
go build -race -o app

For the counter example above, the race detector produces output like this:

==================
WARNING: DATA RACE
Write at 0x00c000018090 by goroutine 7:
  main.increment()
      /path/to/main.go:14 +0x30

Previous write at 0x00c000018090 by goroutine 6:
  main.increment()
      /path/to/main.go:14 +0x30

Goroutine 7 (running) created at:
  main.main()
      /path/to/main.go:22 +0x78
==================

This output provides very useful information:

  • Race location: main.go:14 — which line causes the race
  • Operation type: Write — two goroutines performing writes
  • Which goroutines are involved and where those goroutines were created
Run go test -race regularly in your CI/CD pipeline. The race detector has a performance overhead (usually 5–10x slower), so it’s not suitable for production builds — but it’s very valuable in the test suite because it can catch race conditions invisible from code logic alone.

How to Fix Race Conditions

There are several approaches to fixing race conditions in Go. The right choice depends on the data access pattern and the performance trade-offs required.

1. Mutex — Mutual Exclusion Lock

sync.Mutex is the most fundamental locking mechanism. It ensures only one goroutine can be between Lock() and Unlock() at a time. Other goroutines attempting Lock() will block until the mutex is released.

// CORRECT: protecting counter access with a mutex
package main

import (
    "fmt"
    "sync"
)

type SafeCounter struct {
    mu    sync.Mutex
    value int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock() // always use defer so Unlock is never forgotten
    c.value++
}

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

func main() {
    counter := &SafeCounter{}
    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := 0; j < 1000; j++ {
                counter.Increment()
            }
        }()
    }

    wg.Wait()
    fmt.Println("Counter:", counter.Value()) // always 10000
}

2. RWMutex — Optimization for Read-Heavy Data

If data is read more often than written, sync.RWMutex provides better performance. Multiple goroutines can read simultaneously (read lock), but writes require exclusive access (write lock).

type Cache struct {
    mu    sync.RWMutex
    data  map[string]string
}

// Many goroutines can call Get simultaneously
func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    val, ok := c.data[key]
    return val, ok
}

// Set requires exclusive access
func (c *Cache) Set(key, value string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = value
}
Conditionsync.Mutexsync.RWMutex
Only writers✓ Simpler✓ Possible, but overkill
Many readers, few writers✓ Possible, but slow✓ Ideal
Many writers✓ SuitableRWMutex overhead wasted

3. sync/atomic — Atomic Operations for Simple Types

For simple operations on numeric types, the sync/atomic package provides operations guaranteed to be atomic at the hardware level — without locking overhead:

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

func main() {
    var counter int64 // must use a type supported by atomic
    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := 0; j < 1000; j++ {
                atomic.AddInt64(&counter, 1) // atomic, safe without a mutex
            }
        }()
    }

    wg.Wait()
    fmt.Println("Counter:", atomic.LoadInt64(&counter)) // always 10000
}

sync/atomic also provides Compare-And-Swap (CAS) operations useful for building lock-free data structures:

// Only update if the current value is expected
swapped := atomic.CompareAndSwapInt64(&counter, old, new)
sync/atomic is very efficient for simple types (integers, pointers), but can’t be used for composite types like structs or maps. For structs, still use a mutex.

4. Channels as a Synchronization Mechanism

Instead of sharing data and protecting it with locks, Go encourages another approach: give data ownership to only one goroutine, and let other goroutines communicate with it through channels. This is the CSP (Communicating Sequential Processes) philosophy that forms the foundation of concurrency in Go.

// CORRECT: one goroutine "owns" the counter, others communicate via channels
type CounterMsg struct {
    op     string   // "inc" or "get"
    replyCh chan int // channel for returning the value
}

func counterActor(msgCh <-chan CounterMsg) {
    count := 0 // only this goroutine touches count
    for msg := range msgCh {
        switch msg.op {
        case "inc":
            count++
        case "get":
            msg.replyCh <- count
        }
    }
}

func main() {
    msgCh := make(chan CounterMsg, 100)
    go counterActor(msgCh)

    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := 0; j < 1000; j++ {
                msgCh <- CounterMsg{op: "inc"}
            }
        }()
    }

    wg.Wait()

    replyCh := make(chan int)
    msgCh <- CounterMsg{op: "get", replyCh: replyCh}
    fmt.Println("Counter:", <-replyCh) // always 10000
}

This approach — often called the actor model — eliminates the need for locks entirely because there’s no shared state. The count data is only ever accessed by one goroutine (counterActor), so a race condition is impossible.


WaitGroup and Once: Goroutine Lifecycle Synchronization

sync.WaitGroup

sync.WaitGroup is used to wait for a group of goroutines to finish. It’s not for directly preventing race conditions, but for coordinating when goroutines are done.

func main() {
    var wg sync.WaitGroup
    results := make([]string, 5)

    for i := 0; i < 5; i++ {
        wg.Add(1)            // add the counter BEFORE the goroutine starts
        go func(idx int) {
            defer wg.Done() // decrement the counter when done
            results[idx] = fmt.Sprintf("result-%d", idx)
        }(i)
    }

    wg.Wait() // blocks until the counter reaches zero
    fmt.Println(results)
}
Always call wg.Add(n) before the goroutine starts, not inside the goroutine. If Add is called inside the goroutine, Wait() could be called before Add() gets executed, causing Wait() to return too early.

sync.Once

sync.Once ensures a function is executed exactly once, no matter how many goroutines try to call it. This is very useful for thread-safe lazy initialization.

type Singleton struct {
    db *sql.DB
}

var (
    instance *Singleton
    once     sync.Once
)

func GetInstance() *Singleton {
    once.Do(func() {
        // only executed once, even if called from many goroutines
        instance = &Singleton{
            db: initDatabase(),
        }
    })
    return instance
}

Common Anti-Patterns in Concurrent Go Code

Anti-Pattern 1: Goroutine Leaks

A goroutine that never finishes is one of the most common and hardest-to-trace bugs. If a goroutine waits on a channel that’s never closed or never receives a value, it will live forever and consume memory.

// ANTI-PATTERN: goroutine leak — goroutine waits forever
func fetchData(url string) <-chan string {
    ch := make(chan string)
    go func() {
        result := doHTTPRequest(url) // assume this takes time
        ch <- result                  // if the caller doesn't receive, this goroutine blocks forever
    }()
    return ch
}

func main() {
    ch := fetchData("https://example.com")
    // if main() finishes or we never read from ch,
    // the goroutine inside fetchData() will leak
}
// CORRECT: use context for cancellation
func fetchData(ctx context.Context, url string) <-chan string {
    ch := make(chan string, 1) // buffered, the goroutine doesn't need to wait for a receiver
    go func() {
        result := doHTTPRequest(url)
        select {
        case ch <- result:
            // send successful
        case <-ctx.Done():
            // the caller already cancelled, we can exit safely
        }
    }()
    return ch
}

Anti-Pattern 2: Closures Capturing Loop Variables

This is a very common bug, especially for developers new to Go:

// ANTI-PATTERN: all goroutines capture the same variable i
func main() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println(i) // BUG: all goroutines may print 5
        }()
    }
    wg.Wait()
}

The problem: all goroutines capture a reference to the same i variable. When those goroutines execute, the loop may already be finished and i’s value is already 5.

// CORRECT: pass the value as an argument
func main() {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(val int) { // val is a copy of i's value at that moment
            defer wg.Done()
            fmt.Println(val) // correct: 0, 1, 2, 3, 4 (in random order)
        }(i)
    }
    wg.Wait()
}
Since Go 1.22, loop variable behavior has changed so each loop iteration has its own copy of the variable. This fixes the closure-capturing-loop-variable problem by default. However, if you work with codebases targeting older Go versions, you still need to be careful with this pattern.

Anti-Pattern 3: Deadlocks

A deadlock occurs when two or more goroutines wait on each other to release a resource, so neither can continue executing forever.

// ANTI-PATTERN: classic deadlock — two goroutines waiting on each other
func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)

    go func() {
        val := <-ch1     // waiting for ch1
        ch2 <- val + 1   // then sending to ch2
    }()

    go func() {
        val := <-ch2     // waiting for ch2
        ch1 <- val + 1   // then sending to ch1 — DEADLOCK!
    }()

    // Both goroutines wait forever, neither starts the flow
    time.Sleep(1 * time.Second)
}

Deadlocks can also happen from unreleased mutexes or double-locking:

// ANTI-PATTERN: mutex double-lock causes a deadlock
func (s *Store) GetAndSet(key, value string) {
    s.mu.Lock()
    _ = s.get(key) // if get() also calls s.mu.Lock(), deadlock!
    s.mu.Set(key, value)
    s.mu.Unlock()
}
// CORRECT: separate internal functions (no lock) from public functions (with lock)
func (s *Store) get(key string) string { // no locking
    return s.data[key]
}

func (s *Store) Get(key string) string {
    s.mu.RLock()
    defer s.mu.RUnlock()
    return s.get(key) // safe because get() does no locking
}

func (s *Store) GetAndSet(key, value string) {
    s.mu.Lock()
    defer s.mu.Unlock()
    _ = s.get(key) // safe, because get() does no locking
    s.data[key] = value
}

Anti-Pattern 4: Locks That Are Too Broad

Taking a lock for an entire long operation — including parts that don’t need protection — hurts performance without reason:

// ANTI-PATTERN: lock too broad, blocking other goroutines too long
func (s *Store) ProcessAndStore(key string) {
    s.mu.Lock()
    defer s.mu.Unlock()

    data := s.data[key]         // needs protection
    result := heavyComputation(data) // DOESN'T need protection, but is locked anyway
    s.data[key] = result        // needs protection
}

// CORRECT: narrow the lock scope to only the data access that actually needs it
func (s *Store) ProcessAndStore(key string) {
    s.mu.RLock()
    data := s.data[key]
    s.mu.RUnlock()

    result := heavyComputation(data) // runs without a lock

    s.mu.Lock()
    s.data[key] = result
    s.mu.Unlock()
}

Anti-Pattern 5: Sending to a Nil Channel

A nil channel (not yet initialized) causes send and receive operations to block forever, rather than panicking immediately:

// ANTI-PATTERN: sending to a nil channel makes the goroutine block forever
func main() {
    var ch chan int // nil channel
    go func() {
        ch <- 42 // blocks forever, goroutine leak!
    }()
    time.Sleep(1 * time.Second)
}

// CORRECT: always initialize a channel before use
func main() {
    ch := make(chan int, 1) // an initialized channel
    go func() {
        ch <- 42
    }()
    fmt.Println(<-ch)
}

Common Concurrency Patterns in Production Code

Pattern 1: Worker Pools

A worker pool is a pattern for limiting the number of simultaneously running goroutines, avoiding resource exhaustion when there’s lots of work to process:

package main

import (
    "fmt"
    "sync"
)

func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for job := range jobs {
        // process the job
        result := job * 2
        results <- result
        fmt.Printf("Worker %d processing job %d\n", id, job)
    }
}

func main() {
    const numWorkers = 3
    const numJobs = 10

    jobs := make(chan int, numJobs)
    results := make(chan int, numJobs)

    var wg sync.WaitGroup

    // Start the worker pool
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, jobs, results, &wg)
    }

    // Send the jobs
    for j := 1; j <= numJobs; j++ {
        jobs <- j
    }
    close(jobs) // close the jobs channel so workers know there's no more work

    // Wait for all workers to finish, then close results
    go func() {
        wg.Wait()
        close(results)
    }()

    // Collect the results
    for result := range results {
        fmt.Println("Result:", result)
    }
}
flowchart LR
    P[Producer] -->|jobs| JQ[Jobs Channel]
    JQ --> W1[Worker 1]
    JQ --> W2[Worker 2]
    JQ --> W3[Worker 3]
    W1 -->|results| RC[Results Channel]
    W2 -->|results| RC
    W3 -->|results| RC
    RC --> C[Consumer]

Pattern 2: Fan-Out / Fan-In

Fan-out means distributing work from one source to many goroutines. Fan-in means collecting results from many goroutines into one channel.

// Fan-out: one input channel, many processing goroutines
func fanOut(input <-chan int, numWorkers int) []<-chan int {
    outputs := make([]<-chan int, numWorkers)
    for i := 0; i < numWorkers; i++ {
        out := make(chan int)
        outputs[i] = out
        go func(ch chan<- int) {
            for val := range input {
                ch <- val * 2
            }
            close(ch)
        }(out)
    }
    return outputs
}

// Fan-in: many input channels, one output channel
func fanIn(channels ...<-chan int) <-chan int {
    merged := make(chan int)
    var wg sync.WaitGroup

    output := func(ch <-chan int) {
        defer wg.Done()
        for val := range ch {
            merged <- val
        }
    }

    wg.Add(len(channels))
    for _, ch := range channels {
        go output(ch)
    }

    go func() {
        wg.Wait()
        close(merged)
    }()

    return merged
}
flowchart LR
    subgraph Fan-Out
        SRC[Source] --> G1[Goroutine 1]
        SRC --> G2[Goroutine 2]
        SRC --> G3[Goroutine 3]
    end
    subgraph Fan-In
        G1 --> SINK[Merged Output]
        G2 --> SINK
        G3 --> SINK
    end

Pattern 3: Pipelines

A pipeline is a sequence of stages where each stage’s output becomes the next stage’s input, all running concurrently:

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
}

func filter(in <-chan int, pred func(int) bool) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            if pred(n) {
                out <- n
            }
        }
        close(out)
    }()
    return out
}

func main() {
    // Pipeline: generate → square → filter (only > 10)
    nums := generate(1, 2, 3, 4, 5)
    squares := square(nums)
    result := filter(squares, func(n int) bool { return n > 10 })

    for val := range result {
        fmt.Println(val) // 16, 25
    }
}
flowchart LR
    A[generate<br/>1,2,3,4,5] -->|channel| B[square<br/>1,4,9,16,25]
    B -->|channel| C[filter<br/>> 10]
    C -->|channel| D[Output<br/>16, 25]

Pattern 4: Context for Cancellation and Timeouts

context.Context is Go’s idiomatic way to propagate cancellation signals, deadlines, and values through an entire goroutine chain. Every goroutine that might run long should respect the context given to it.

package main

import (
    "context"
    "fmt"
    "time"
)

func longRunningTask(ctx context.Context) error {
    select {
    case <-time.After(5 * time.Second): // work finished
        fmt.Println("Task complete")
        return nil
    case <-ctx.Done(): // context cancelled or deadline passed
        fmt.Println("Task cancelled:", ctx.Err())
        return ctx.Err()
    }
}

func main() {
    // Create a context with a 2-second timeout
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel() // always call cancel to free resources

    if err := longRunningTask(ctx); err != nil {
        fmt.Println("Error:", err) // context deadline exceeded
    }
}

Context with manual cancellation is very useful for stopping goroutines based on events, not time:

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    go func() {
        // Run the goroutine
        for {
            select {
            case <-ctx.Done():
                fmt.Println("Goroutine stopped")
                return
            default:
                doWork()
            }
        }
    }()

    time.Sleep(1 * time.Second)
    cancel() // stop the goroutine
    time.Sleep(100 * time.Millisecond) // give the goroutine time to stop
}
Always call the cancel function returned by context.WithCancel, context.WithTimeout, and context.WithDeadline — even if the context already expired naturally. Not calling cancel causes a resource leak because the context and the internal goroutines monitoring it aren’t freed.

sync.Map: A Map Safe for Concurrent Access

Go’s built-in map (map[K]V) is not thread-safe. Accessing a map from multiple goroutines concurrently without synchronization is a race condition and can cause a crash with the message concurrent map read and map write.

For concurrent access cases, Go provides sync.Map:

// ANTI-PATTERN: a regular map is not thread-safe
var cache = make(map[string]string)

func setCache(key, value string) {
    cache[key] = value // DATA RACE if called from multiple goroutines!
}

// CORRECT option 1: sync.Map
var cache sync.Map

func setCache(key, value string) {
    cache.Store(key, value) // thread-safe
}

func getCache(key string) (string, bool) {
    val, ok := cache.Load(key)
    if !ok {
        return "", false
    }
    return val.(string), true
}

// CORRECT option 2: regular map + RWMutex (more flexible for complex cases)
type SafeMap struct {
    mu   sync.RWMutex
    data map[string]string
}

func (m *SafeMap) Set(key, value string) {
    m.mu.Lock()
    defer m.mu.Unlock()
    m.data[key] = value
}

func (m *SafeMap) Get(key string) (string, bool) {
    m.mu.RLock()
    defer m.mu.RUnlock()
    val, ok := m.data[key]
    return val, ok
}

sync.Map is optimized for two specific cases: (1) when each key is written only once but read many times, and (2) when goroutines read and write different keys (no hotspot). For cases other than these, a regular map with sync.RWMutex usually provides better performance and is easier to understand.


Concurrent Code Review Checklist

Use this checklist when reviewing code that uses goroutines, channels, or shared state:

GOROUTINES:
  □ Does every goroutine have a way to stop (context, done channel)?
  □ Won't the goroutine leak if the caller has already finished/errored?
  □ Do goroutine closures capture loop variables correctly?
  □ Do background goroutines have an error reporting mechanism?

CHANNELS:
  □ Is the channel always initialized before use?
  □ Is the channel only closed by the sender side?
  □ Is there no possibility of sending to an already-closed channel?
  □ Is the buffered channel sized correctly (not so small it blocks)?

SHARED STATE:
  □ Is every shared variable access protected by a mutex or atomic?
  □ Do maps accessed from multiple goroutines use sync.Map or a mutex?
  □ Is the lock scope as narrow as possible (not covering operations that don't need locks)?
  □ Are there no nested locks that could cause deadlocks?

MUTEXES:
  □ Is Unlock always called (ideally via defer)?
  □ Do internal functions (called while a lock is already held) avoid locking again?
  □ Is RWMutex used correctly (RLock for reads, Lock for writes)?

WAITGROUPS:
  □ Is wg.Add() called before the goroutine starts, not inside the goroutine?
  □ Is wg.Done() always called (ideally via defer)?

CONTEXT:
  □ Does context.WithCancel/WithTimeout always call cancel() via defer?
  □ Do long-running goroutines respect ctx.Done()?
  □ Is context never stored inside a struct (must be passed as a parameter)?

RACE DETECTOR:
  □ Has go test -race been run?
  □ Is there no WARNING: DATA RACE output from the race detector?

Summary

  • Goroutines are very lightweight (~2 KB initial stack, dynamically growable) and managed by the Go runtime’s M:N scheduler, not directly by the OS kernel.
  • Channels are the primary communication mechanism between goroutines; unbuffered channels provide synchronization, buffered channels allow decoupling up to the capacity limit.
  • A race condition occurs when two goroutines access shared memory without synchronization, with at least one performing a write — the result is non-deterministic.
  • Use go run -race and go test -race regularly; the race detector is very effective at finding race conditions invisible from the code alone.
  • sync.Mutex for general protection; sync.RWMutex for read-heavy optimization; sync/atomic for atomic operations on simple numeric types.
  • A goroutine leak is a goroutine that never finishes; always provide a stopping mechanism via context or a done channel.
  • Closures capturing loop variables is a classic bug; pass the value as a function argument, not relying on a reference to the loop variable.
  • Deadlocks can happen from goroutines waiting on each other via channels, or mutexes locked twice (nested locks) — separate internal functions (without locks) from public functions (with locks).
  • Worker pools limit the number of concurrent goroutines; fan-out/fan-in distributes and collects work; pipelines connect concurrent processing stages.
  • context.Context is the idiomatic way to handle cancellation and timeouts; always call cancel() via defer and make sure goroutines respect ctx.Done().
  • Go’s built-in maps are not thread-safe; use sync.Map or map + sync.RWMutex for concurrent access.

Portfolio