Featured image of post Go Real-Life Concurrency Patterns Reference

Go Real-Life Concurrency Patterns Reference

A comprehensive collection of concurrent application building blocks.

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

  1. Always use defer for unlock operations
  2. Prefer channels for communication, mutexes for shared state
  3. Use buffered channels for performance, unbuffered for synchronization
  4. Close channels to signal completion, not to clean up
  5. Use select with default for non-blocking operations
  6. Use context.Context for cancellation propagation
  7. Test concurrent code with -race flag
  8. Monitor goroutine count to detect leaks
  9. Use atomic operations for simple shared state
  10. 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.

Licensed under CC BY-NC-SA 4.0
Diagrams by Mermaid, C4-PlantUML, Kroki
Built with Hugo
Theme Stack designed by Jimmy