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:
| Aspect | OS Thread | Goroutine |
|---|---|---|
| Managed by | OS kernel | Go runtime |
| Initial stack size | 1–8 MB (fixed) | ~2 KB (dynamic) |
| Context switch | Slow (kernel mode) | Fast (user space) |
| Practical count | Hundreds | Millions |
| Communication | Shared memory + locks | Channels 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 --> M2The 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, usesync.Onceor additional coordination to ensurecloseis 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:
- Read the
countervalue from memory into a CPU register - Add 1 to the value in the register
- 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
}
| Condition | sync.Mutex | sync.RWMutex |
|---|---|---|
| Only writers | ✓ Simpler | ✓ Possible, but overkill |
| Many readers, few writers | ✓ Possible, but slow | ✓ Ideal |
| Many writers | ✓ Suitable | RWMutex 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 callwg.Add(n)before the goroutine starts, not inside the goroutine. IfAddis called inside the goroutine,Wait()could be called beforeAdd()gets executed, causingWait()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
endPattern 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 thecancelfunction returned bycontext.WithCancel,context.WithTimeout, andcontext.WithDeadline— even if the context already expired naturally. Not callingcancelcauses 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 -raceandgo test -raceregularly; the race detector is very effective at finding race conditions invisible from the code alone.sync.Mutexfor general protection;sync.RWMutexfor read-heavy optimization;sync/atomicfor 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.Contextis the idiomatic way to handle cancellation and timeouts; always callcancel()viadeferand make sure goroutines respectctx.Done().- Go’s built-in maps are not thread-safe; use
sync.Mapormap+sync.RWMutexfor concurrent access.