DEV Community

Cover image for Concurrency gotchas in go
Walid LAGGOUNE
Walid LAGGOUNE

Posted on

Concurrency gotchas in go

Hi, my name is Walid, a backend developer who’s currently learning Go and sharing my journey by writing about it along the way.
Resource :

  • The Go Programming Language book by Alan A. A. Donovan & Brian W. Kernighan
  • Matt Holiday go course
    Concurrency is a cornerstone of Go's design, enabling developers to build efficient, high-performance applications. However, leveraging concurrency effectively requires a deep understanding of potential pitfalls. This article delves into common concurrency challenges in Go and offers strategies to address them.

1. Race Conditions

Race conditions occur when multiple goroutines access and modify shared data concurrently without proper synchronization, leading to unpredictable behavior. For example, incrementing a shared counter without synchronization can result in incorrect values.

Solution: Utilize synchronization primitives like sync.Mutex to ensure exclusive access to shared resources.

var (
    counter int
    mu      sync.Mutex
)

func increment() {
    mu.Lock()
    counter++
    mu.Unlock()
}
Enter fullscreen mode Exit fullscreen mode

Additionally, Go's race detector is a powerful tool for identifying race conditions early in the development process. Activate it using the -race flag:

go run -race main.go
go test -race ./...
Enter fullscreen mode Exit fullscreen mode

2. Goroutine Leaks

Goroutine leaks occur when goroutines remain blocked indefinitely, consuming resources and potentially leading to memory exhaustion. This often happens when goroutines are waiting on channels that are never closed or when exit conditions are not properly managed.

Solution: Ensure that all goroutines have a clear exit strategy. Use context cancellation to signal goroutines to terminate gracefully.

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go func() {
    select {
    case <-ctx.Done():
        // Cleanup and exit
    }
}()
Enter fullscreen mode Exit fullscreen mode

Regularly monitoring and profiling your application can help detect unexpected goroutine growth.

3. Deadlocks

Deadlocks occur when two or more goroutines are waiting indefinitely for each other to release resources, causing the program to halt. For instance, if two goroutines each hold a lock and attempt to acquire the other's lock, a deadlock ensues.

Solution: Adopt a consistent locking order to prevent circular dependencies. Alternatively, consider using channels for synchronization, adhering to Go's philosophy of communicating by sharing memory.

ch := make(chan struct{})

go func() {
    // Perform operations
    ch <- struct{}{}
}()

<-ch // Wait for goroutine to finish
Enter fullscreen mode Exit fullscreen mode

This approach reduces the risk of deadlocks by avoiding explicit locks.

4. Starvation and Livelocks

Starvation occurs when a goroutine is perpetually denied the resources it needs to proceed, often due to higher-priority goroutines monopolizing those resources. Livelocks are similar, where goroutines continuously change state in response to each other without making progress.

Solution: Design your concurrency control mechanisms to ensure fair access to resources. Avoid excessive locking durations and consider using buffered channels to manage IO resource availability.

sem := make(chan struct{}, N) // N is the maximum number of concurrent goroutines

for i := 0; i < totalTasks; i++ {
    sem <- struct{}{} // works like token ring , get a token and release it after
    go func(task int) {
        defer func() { <-sem }()
        // Perform task
    }(i)
}
Enter fullscreen mode Exit fullscreen mode

This pattern, known as a semaphore, limits the number of concurrent goroutines, preventing resource starvation.

A counting semaphore is a type of lock that allows you to limit the number of processes that can concurrently access a resource to some fixed number. You can think of the lock that we just created as being a counting semaphore with a limit of 1.

5. Improper Use of Channels

Channels are powerful tools for communication between goroutines, but misuse can lead to issues such as deadlocks, data races, and goroutine leaks. Common mistakes include:

  • Unbuffered Channels: Requiring both sender and receiver to be ready can cause deadlocks if not managed carefully.

  • Buffered Channels: While they can decouple sender and receiver, improper sizing or assumptions about buffer capacity can hide race conditions or lead to unexpected behavior.

  • Channel Closure: Closing a channel from multiple goroutines or sending on a closed channel can cause panics.

Solution: Adhere to best practices when working with channels:

  • Clearly define channel ownership and responsibility for closing.

  • Use unbuffered channels for synchronization and buffered channels for batching or managing bursts of data.

  • Always check for the closed state when ranging over channels.

ch := make(chan int)
go func() {
    defer close(ch)
    for i := 0; i < 5; i++ {
        ch <- i
    }
}()

for val := range ch {
    fmt.Println(val)
}
Enter fullscreen mode Exit fullscreen mode

This pattern ensures that the receiver terminates gracefully when the channel is closed.

6. Shared Data Structures

Modifying shared data structures like maps without proper synchronization can lead to data corruption or panics. In Go, maps are not safe for concurrent use by default.

Solution: Use synchronization mechanisms such as sync.Mutex or sync.RWMutex to protect access to shared data structures.

var (
    m  = make(map[string]int)
    mu sync.Mutex
)

func set(key string, value int) {
    mu.Lock()
    m[key] = value
    mu.Unlock()
}

func get(key string) int {
    mu.Lock()
    defer mu.Unlock()
    return m[key]
}
Enter fullscreen mode Exit fullscreen mode

Alternatively, consider using sync.Map, which is designed for concurrent use cases.

In Go, managing concurrent access to shared resources can be achieved using synchronization primitives like mutexes and atomic operations. Each has distinct characteristics and use cases, and understanding their differences is crucial for writing efficient concurrent programs.

Atomic Operations

The sync/atomic package provides low-level atomic memory primitives that allow for lock-free synchronization on single variables. These operations are generally faster than mutexes because they translate directly to single CPU instructions, ensuring atomicity without the overhead of locking mechanisms. However, atomic operations are limited to specific types, such as integers and pointers, and are best suited for simple read-modify-write scenarios.

Mutexes

A sync.Mutex is a mutual exclusion lock that provides a way to serialize access to shared resources. Unlike atomic operations, mutexes can protect complex data structures and multiple variables, offering more flexibility. However, they introduce additional overhead due to locking and unlocking operations, which can impact performance, especially in high-contention scenarios.

Choosing Between Atomics and Mutexes

  • Use Atomics When:

    • Performing simple operations on a single variable.
    • Minimizing synchronization overhead is critical.
  • Use Mutexes When:

    • Protecting complex data structures or multiple variables.
    • Requiring more sophisticated synchronization patterns.

It's important to note that while atomics offer performance benefits, they require a solid understanding of memory models and can be error-prone if not used carefully. Mutexes, being more straightforward, are often preferred for their clarity and ease of use in complex scenarios.

In summary, both atomics and mutexes are essential tools in Go's concurrency model. Selecting the appropriate synchronization mechanism depends on the specific requirements of your application and the complexity of the data being managed.

7. Overusing Goroutines

While goroutines are lightweight, spawning an excessive number can lead to high memory consumption and increased scheduling overhead. For example, creating a new goroutine for each incoming request without limitation can overwhelm the system.

Solution: Implement worker pools or use bounded concurrency patterns to control the number of active goroutines.

sem := make(chan struct{}, maxGoroutines)
var wg sync.WaitGroup

for _, task := range tasks {
    sem <- struct{}{} // Acquire a token before launching a goroutine
    wg.Add(1)
    go func(t Task) {
        defer wg.Done()
        defer func() { <-sem }() // Release the token after the goroutine completes

        // Perform the task
        t.Execute()
    }(task)
}

wg.Wait() // Wait for all goroutines to complete


Enter fullscreen mode Exit fullscreen mode

5 Playwright CLI Flags That Will Transform Your Testing Workflow

  • 0:56 --last-failed
  • 2:34 --only-changed
  • 4:27 --repeat-each
  • 5:15 --forbid-only
  • 5:51 --ui --headed --workers 1

Learn how these powerful command-line options can save you time, strengthen your test suite, and streamline your Playwright testing experience. Click on any timestamp above to jump directly to that section in the tutorial!

Top comments (0)

AWS Q Developer image

Your AI Code Assistant

Automate your code reviews. Catch bugs before your coworkers. Fix security issues in your code. Built to handle large projects, Amazon Q Developer works alongside you from idea to production code.

Get started free in your IDE

👋 Kindness is contagious

If this article connected with you, consider tapping ❤️ or leaving a brief comment to share your thoughts!

Okay