Go Real-Life Concurrency Patterns Reference
A comprehensive collection of concurrent application building blocks.
Why
During tech interviews, I’ve seen many developers struggle with fundamental concurrency concepts like “how to get a result from another goroutine.” This reference was created to address these common knowledge gaps and provide practical, copy-paste solutions for real-world concurrent programming challenges.
How to use this guide:
- Before implementing - Review relevant patterns before writing concurrent code
- During debugging - Reference these patterns to identify common anti-patterns
- For interviews - Study these fundamentals that frequently appear in technical discussions
- As building blocks - Combine these patterns to solve complex concurrency problems
Each pattern includes the specific problem it solves, making it easy to find the right solution for your use case.
🔄 Channel Patterns
Channels are communication links between goroutines.
1. Channel as Pipe (Not Broadcast)
Problem: Channels do NOT broadcast values! Each value is received by exactly one goroutine.
1
2
3
4
5
6
7
8
9
10
11
12
|
package main
func channelPipeDemo() {
ch := make(chan string, 1)
// Two receivers
go func() { fmt.Println("Receiver 1:", <-ch) }()
go func() { fmt.Println("Receiver 2:", <-ch) }()
ch <- "message" // Only ONE receiver gets it
time.Sleep(100 * time.Millisecond)
}
|
Common Pitfall: Expecting multiple receivers to get the same message. Only one goroutine will receive each sent value.

2. Channel as Queue: Buffered Channels
Problem: Non-blocking task scheduling
Why Buffered? Buffered channels allow asynchronous communication up to the buffer size, preventing immediate blocking.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
package main
// Unbuffered - synchronous communication
func unbuffered() {
ch := make(chan int)
go func() {
ch <- 42 // Blocks until receiver ready
}()
fmt.Println(<-ch) // Sender blocks until this line
}
// Buffered - asynchronous up to buffer size
func buffered() {
ch := make(chan int, 3) // Can hold 3 values
ch <- 1 // Doesn't block
ch <- 2 // Doesn't block
ch <- 3 // Doesn't block
// ch <- 4 // Would block - buffer full
}
|
Performance Note: Buffered channels are faster for high-throughput scenarios where you can tolerate some message buffering.
3. Non-blocking Channel Operations
Problem: Avoiding goroutine blocking when channels are full/empty.
Why Select? Select with default case enables non-blocking operations, preventing goroutine starvation.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
package main
func nonBlockingSend(ch chan<- string, msg string) bool {
select {
case ch <- msg:
return true // Sent successfully
default:
return false // No receiver available AND channel is full (or unbuffered), message NOT sent (dropped)
}
}
func nonBlockingReceive(ch <-chan string) (string, bool) {
select {
case msg := <-ch:
return msg, true // Received message
default:
return "", false // No sender available AND channel is empty, no message
}
}
|
4. Channel Timeout/Cancel Operations
Problem: Preventing indefinite blocking with timeouts.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
package main
import "context"
func sendWithTimeout(ctx context.Context, ch chan<- string, msg string) error {
select {
case ch <- msg:
return nil // Sent successfully
case <-ctx.Done():
return fmt.Errorf("send timeout after %v", ctx.Err())
}
}
func receiveWithTimeout(ctx context.Context, ch <-chan string) (string, error) {
select {
case msg := <-ch:
return msg, nil
case <-ctx.Done():
return "", fmt.Errorf("receive timeout after %v", ctx.Err())
}
}
|
5. Channel Closing and Detection
Problem: Properly signaling completion and detecting closed channels.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
package main
func channelClosingDemo() {
ch := make(chan int, 2)
// Send some data
ch <- 1
ch <- 2
close(ch) // Signal no more data
// Read until closed
for {
if val, ok := <-ch; ok {
fmt.Println("Received:", val)
} else {
fmt.Println("Channel closed")
break
}
}
// Or use range (automatically handles closing)
ch2 := make(chan int, 2)
ch2 <- 3
ch2 <- 4
close(ch2)
for val := range ch2 {
fmt.Println("Range received:", val)
}
}
|
6. Broadcasting Signal
Problem: Notify all listeners ONCE.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
package main
import "time"
func callbackDemo() {
signal := make(chan struct{})
for i := 0; i < 10; i++ {
go func() {
<-signal // default value read after channel is closed
println(i, "notified")
}()
}
close(signal)
time.Sleep(time.Second)
}
|
2. Callback: Async Result Returning
Problem: Set task and wait for result.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
package main
type task struct {
value int
result chan int
}
func callbackDemo() {
tasks := make(chan task)
// Worker goroutine
go func() {
for t := range tasks {
t.result <- t.value * 2
}
}()
r := make(chan int)
tasks <- task{
value: 10,
result: r,
}
println("result", <-r)
}
|
7. Broadcasting Messages (Fan-out)
Problem: Pass data to all listeners many times.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
package main
import "time"
func broadcastingDemo() {
var listeners []chan string
for i := 0; i < 10; i++ {
ch := make(chan string)
listeners = append(listeners, ch)
go func() {
for msg := range ch {
println(i, "notified with", msg)
}
println(i, "notified to finish")
}()
}
for _, ch := range listeners {
ch <- "msg 1"
}
for _, ch := range listeners {
ch <- "msg 1"
close(ch)
}
time.Sleep(time.Second)
}
|
8. Channel as Queue with Sampling
Problem: Handling overloaded channels by dropping messages intelligently.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
package main
func samplingQueue(ch chan string, msg string) {
select {
case ch <- msg:
// Sent successfully
default:
// Channel full - apply sampling
select {
case <-ch: // Drop oldest
ch <- msg // Add newest
default:
// Channel completely stuck, drop message
}
}
}
|
9. Concurrency-Safe State Management
Problem: State loss or panic during concurrent writes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
package main
import "time"
func stateManagerDemo() {
// map panics on concurrent writes
m := map[int]struct{}{}
updates := make(chan int)
for i := 0; i < 10; i++ {
go func() {
// m[i] = struct{}{} would panic
updates <- i
}()
}
go func() {
for key := range updates {
m[key] = struct{}{}
}
}()
time.Sleep(time.Second)
println(m)
}
|
10. Semaphore/Limiter
Problem: Allow only N concurrent actions at the same time.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
package main
import "time"
func semaphoreDemo() {
// map panics on concurrent writes
m := map[int]struct{}{}
semaphore := make(chan struct{}, 1) // max 1 token in bucket
for i := 0; i < 10; i++ {
go func() {
semaphore <- struct{}{} // blocks if bucket is full
defer func() {
<- semaphore // unblock another goroutine
}()
m[i] = struct{}{}
}()
}
time.Sleep(time.Second)
println(m)
}
|
11. Rate Limiting Pattern
Problem: Controlling the rate of operations and dropping overload.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
package main
type RateLimiter struct {
limiter chan struct{}
}
func NewRateLimiter(rate int) *RateLimiter {
limiter := make(chan struct{}, rate)
// Refill periodically
go func() {
ticker := time.NewTicker(time.Second / time.Duration(rate))
defer ticker.Stop()
for range ticker.C {
select {
case limiter <- struct{}{}:
default: // Bucket full
}
}
}()
return &RateLimiter{limiter: limiter}
}
// Allow waits until token appears
func (rl *RateLimiter) Allow() {
<-rl.limiter
}
// Backoff returns whether operation is allowed right now or request should be rejected
func (rl *RateLimiter) Backoff() bool {
select {
case <-rl.limiter:
return false
default:
return true
}
}
|
🔒 Mutex Patterns
With mutexes, you don’t need to run and manage goroutine state.
12. Basic Mutex Protection
Problem: Protecting shared data from race conditions.
Why Mutex? It serializes access - only one goroutine can hold the lock at a time. Others wait in line.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
package main
import "sync"
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock() // Always use defer for unlock
c.value++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
|
Common Pitfall: Forgetting to unlock causes deadlock. Always use defer
right after locking!

13. RWMutex for Read-Heavy Workloads
Problem: Allowing concurrent reads while protecting writes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
package main
type ReadHeavyData struct {
mu sync.RWMutex
data map[string]int
}
func (r *ReadHeavyData) Get(key string) (int, bool) {
r.mu.RLock() // Multiple goroutines can hold read lock
defer r.mu.RUnlock()
val, ok := r.data[key]
return val, ok
}
func (r *ReadHeavyData) Set(key string, value int) {
r.mu.Lock() // Exclusive access for writes
defer r.mu.Unlock()
r.data[key] = value
}
|
14. Double-Checked Locking
Problem: Optimizing for common case while maintaining thread safety.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
package main
import "sync"
type LazyInit struct {
mu sync.RWMutex
data map[string]chan int
}
func (l *LazyInit) GetChannel(key string) chan int {
// Fast path - read lock
l.mu.RLock()
ch := l.data[key]
if ch != nil {
l.mu.RUnlock()
return ch
}
// Slow path - write lock
l.mu.Lock()
defer l.mu.Unlock()
// Double-check after acquiring write lock
if ch := l.data[key]; ch != nil {
return ch
}
// Create new channel
ch = make(chan int, 10)
l.data[key] = ch
return ch
}
|
15. Conditional Wait with Mutex
Problem: Waiting for condition to become true while holding lock.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
package main
type ConditionalWait struct {
mu sync.Mutex
cond *sync.Cond
ready bool
}
func NewConditionalWait() *ConditionalWait {
cw := &ConditionalWait{}
cw.cond = sync.NewCond(&cw.mu)
return cw
}
func (cw *ConditionalWait) WaitForReady() {
cw.mu.Lock()
defer cw.mu.Unlock()
for !cw.ready {
cw.cond.Wait() // Releases lock and waits
}
}
func (cw *ConditionalWait) SetReady() {
cw.mu.Lock()
defer cw.mu.Unlock()
cw.ready = true
cw.cond.Broadcast() // Wake all waiters
}
|
Note: Channel closing requires less code.
16. Once Pattern for Initialization
Problem: Ensuring initialization happens exactly once.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
package main
type ExpensiveResource struct {
data string
}
var (
resource *ExpensiveResource
once sync.Once
)
func GetResource() *ExpensiveResource {
once.Do(func() {
fmt.Println("Initializing expensive resource...")
time.Sleep(100 * time.Millisecond) // Simulate expensive init
resource = &ExpensiveResource{data: "initialized"}
})
return resource
}
|
⚡ Atomic Operations Patterns
Atomic operations are much faster than mutexes.
17. Atomic Counters
Problem: High-performance counters without mutex overhead.
Why Atomic? Uses special CPU instructions that guarantee the operation completes without interruption. Much faster than mutex for simple operations.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
package main
type AtomicCounter struct {
value int64
}
func (c *AtomicCounter) Inc() {
atomic.AddInt64(&c.value, 1)
}
func (c *AtomicCounter) Dec() {
atomic.AddInt64(&c.value, -1)
}
func (c *AtomicCounter) Value() int64 {
return atomic.LoadInt64(&c.value)
}
func (c *AtomicCounter) Reset() int64 {
return atomic.SwapInt64(&c.value, 0)
}
|
Limitation: Only works for simple types (int32, int64, etc.) and simple operations. Can’t protect complex logic.
🏃 Goroutine Patterns
18. Worker Pool Pattern
Problem: Limiting concurrent work and reusing goroutines.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
package main
func workerPool(jobs <-chan int, workers int) <-chan int {
results := make(chan int)
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
for job := range jobs {
results <- job * job // Process job
}
}()
}
go func() {
wg.Wait()
close(results)
}()
return results
}
|

19. Pipeline Pattern
Problem: Chaining processing stages for data transformation.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
package main
func pipeline() {
// Stage 1: Generate numbers
numbers := make(chan int)
go func() {
defer close(numbers)
for i := 1; i <= 5; i++ {
numbers <- i
}
}()
// Stage 2: Square numbers
squares := make(chan int)
go func() {
defer close(squares)
for num := range numbers {
squares <- num * num
}
}()
// Stage 3: Print results
for square := range squares {
fmt.Println(square)
}
}
|
📊 Testing Patterns
20. Race Condition Testing
Problem: Detecting race conditions in concurrent code.
Important: Always run concurrent tests with go test -race
to detect data races.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
package main
func TestRaceCondition(t *testing.T) {
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // Race condition!
}()
}
wg.Wait()
// Run with: go test -race
// This will detect the race condition
}
|
Common Pitfall: Race conditions might not appear in normal testing but will manifest under load in production.
21. Timeout Testing
Problem: Testing timeout behavior in concurrent operations.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
package main
func TestTimeout(t *testing.T) {
ch := make(chan string)
start := time.Now()
select {
case <-ch:
t.Error("Should not receive from empty channel")
case <-time.After(100 * time.Millisecond):
// Expected timeout
}
elapsed := time.Since(start)
if elapsed < 100*time.Millisecond {
t.Error("Timeout too short")
}
}
|
22. Goroutine Leak Detection
Problem: Ensuring goroutines are properly cleaned up.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
package main
import (
"context"
"runtime"
"testing"
"time"
)
func TestGoroutineLeak(t *testing.T) {
before := runtime.NumGoroutine()
// Code that starts goroutines
ctx, cancel := context.WithCancel(context.Background())
for i := 0; i < 10; i++ {
go func() {
<-ctx.Done()
}()
}
cancel() // Stop all goroutines
time.Sleep(100 * time.Millisecond) // Let them finish
after := runtime.NumGoroutine()
if after > before {
t.Errorf("Goroutine leak detected: before=%d, after=%d", before, after)
}
}
|
🎯 Best Practices Summary
- Always use
defer
for unlock operations
- Prefer channels for communication, mutexes for shared state
- Use buffered channels for performance, unbuffered for synchronization
- Close channels to signal completion, not to clean up
- Use
select
with default
for non-blocking operations
- Use
context.Context
for cancellation propagation
- Test concurrent code with
-race
flag
- Monitor goroutine count to detect leaks
- Use atomic operations for simple shared state
- Design for graceful shutdown from day one
These patterns form the foundation of robust concurrent systems in Go. Each pattern solves specific concurrency
challenges and can be combined to build complex, production-ready applications.