Deadlocks in Go: Understanding and Preventing for Production Stability

Kostiantyn Lysenko - Jun 26 - - Dev Community

gopher unchained

fatal error: all goroutines are asleep - deadlock!
Enter fullscreen mode Exit fullscreen mode

Damn, not again! šŸ’€

Yes, thatā€™s often the reaction if youā€™re not aware of all possible deadlocks that might happen.

This article will help you prevent all possible deadlocks and understand their nature.

Introduction

Deadlocks in Go can cause your program to become unresponsive, leading to poor user experiences and potentially costly downtime. Even more, the Go compiler will NOT warn you about these issues because deadlocks are a runtime phenomenon, not a compile-time error. This means that your code might compile and pass initial tests, only to fail under specific conditions in a production environment.

Understanding Deadlocks

Deadlocks happen when a task gets stuck waiting for something that will never happen , causing it to stop moving forward.

This principle forms the foundation of your coding approach. Always inquire:

- Will someone write something into the channel Iā€™ve created?

Similarly, consider:

- Am I trying to write to a channel without space?

Also:

- Am I attempting to read from a channel that is currently empty?

In other words:

  • to store something, you need a *place ready to receive * it;
  • to read something, you must ensure thereā€™s something available to be read ;
  • to lock something, you must verify itā€™s free and will be released when needed.

Deadlock types

All possible cases might be grouped into the next two groups:

  • Channel misuse
  • Circular dependencies between goroutines

Letā€™s consider them in more detail.

Channel Misuse

Using channels incorrectly, like reading from an empty channel or writing to a full one, can make goroutines wait forever, causing deadlock. Letā€™s delve into and discuss all possible types within this category.

No receiver deadlock

It occurs when a goroutine attempts to send data to an unbuffered channel, but there is no corresponding receiver ready to receive that data.

package main

func main() {
   // Create an unbuffered channel of integers
   goChannel := make(chan int)

   // Attempt to send the value 1 into the channel
   // This operation will block indefinitely because there's no receiver 
   // ready to receive the value
   goChannel <- 1
}
Enter fullscreen mode Exit fullscreen mode

This occurs specifically with unbuffered channels due to their lack of storage space. The same code with a buffered channel wonā€™t have deadlock.

Got it! Letā€™s add a receiver and make it work:

package main

import "fmt"

func main() {
   goChannel := make(chan int)
   goChannel <- 1

   // Attempt to read the value from the channel
   fmt.Println(<-goChannel)
}
Enter fullscreen mode Exit fullscreen mode

Waitā€¦ What? Why does it still throw deadlock? šŸ˜”

This is because channels aim to synchronize two or more goroutines. And if one writes to the channel, there should be another goroutine that reads from the channel. Letā€™s fix it:

package main

import (
   "fmt"
   "time"
)

func main() {
   goChannel := make(chan int)
   goChannel <- 1

   go func() {
      // Attempt to read from the channel
      fmt.Println(<-goChannel)
   }()

   // Waiting for the goroutine to be executed
   time.Sleep(2 * time.Second)
}
Enter fullscreen mode Exit fullscreen mode

Wait! Why still? šŸ¤¬

Because, before putting value we need to make sure it will be received and reading must be called before writing. Letā€™s switch places of read and write calls:

package main

import (
   "fmt"
   "time"
)

func main() {
   goChannel := make(chan int)

   // Start reading from the channel
   go func() {
      // Attempt to read from the channel
      fmt.Println(<-goChannel)
   }()

   // Start writing to channel
   goChannel <- 1

   // Waiting for the goroutine to be executed
   time.Sleep(2 * time.Second)
}
Enter fullscreen mode Exit fullscreen mode

Yeah, finally it works. šŸ„³

This rule doesnā€™t apply to buffered channels since they have a place to store value and one goroutine might handle both actionsā€Šā€”ā€Šread and write. The following code that previously didnā€™t work with unbuffered channel works now:

package main

import (
   "fmt"
)

func main() {
   // Define buffered channel
   goChannel := make(chan string, 1)
   // Attempt to write to the channel
   goChannel <- "hey!"
   // Attempt to read from the channel
   fmt.Println(<-goChannel)
}
Enter fullscreen mode Exit fullscreen mode

No sender deadlock

It occurs when a goroutine tries to read data from a channel, but no value will ever be sent :

package main

func main() {
   // Create a channel of integers
   goChannel := make(chan int)

   // Attempt to read a value from the channel
   // This operation will block indefinitely because there's no value
   // previously sent
   <- goChannel
}
Enter fullscreen mode Exit fullscreen mode

This logic applies to both buffered and unbuffered channels.

Writing to a Full Channel

A deadlock can occur when a goroutine attempts to write to a buffered channel that is already full, and no other goroutine is available to read from the channel. This leads to the write operation blocking indefinitely , causing deadlock:

package main

import "fmt"

func main() {
   // Create a buffered channel with a capacity of 1
   ch := make(chan int, 1)

   // Send a value into the channel
   ch <- 1

   // Attempt to send another value into the channel
   // This will block because the channel is full and there's no receiver or place to store value
   ch <- 2

   fmt.Println("This line will never be printed")
}
Enter fullscreen mode Exit fullscreen mode

Reading from an Empty Channel

Another case is when a goroutine attempts to read from an already emptied channel , resulting in the read operation blocking indefinitely:

package main

import "fmt"

func main() {
   // Create a buffered channel with a capacity of 1
   ch := make(chan string, 1)

   // Send a value into the channel 
   ch <- "first"

   // Attempt to read the value (will be printed)
   fmt.Println(<-ch)

   // Attempt to read the value again (will fail)
   fmt.Println(<-ch)
}
Enter fullscreen mode Exit fullscreen mode

Unclosed Channel Before Range

The following code demonstrates one of the most common deadlocks that happens to developers when a goroutine iterates over a channel using a for-range loop, but the channel is never closed. The for-range loop requires the channel to be closed to terminate iteration. If the channel is not closed, the loop will block indefinitely, leading to a deadlock. Try to run with commented and uncommented close(ch):

package main

import "fmt"

func main() {
   // Create a buffered channel
   ch := make(chan int, 2)

   // Send some values into the channel
   ch <- 1
   ch <- 2

   // Close the channel to prevent deadlock
   // close(ch) // This line is intentionally commented out 
   // to demonstrate deadlock

   // Iterate over the channel using a for-range loop
   for val := range ch {
      fmt.Println(val)
   }

   fmt.Println("This line will be printed only if the channel was closed")
}
Enter fullscreen mode Exit fullscreen mode

Donā€™t leave channels opened šŸ«¢

Circular dependencies between goroutines

Circular dependencies between goroutines occur when multiple goroutines are waiting on each other to perform actions or exchange data, creating a situation where none of them can proceed without the othersā€™ participation, leading to a deadlock.

Correct managing dependencies between goroutines is crucial to prevent the mentioned deadlocks-situations. Letā€™s discuss the most common cases.

Mutex and Locking Issues

If one goroutine locks resource A first and then waits to lock resource B, while another goroutine locks resource B first and then waits to lock resource A, a deadlock can occur if both goroutines end up waiting indefinitely for each other to release their locks.

Try the following example:

package main

import (
 "fmt"
 "sync"
 "time"
)

func main() {
   // Declare two mutexes
   var mu1, mu2 sync.Mutex

   var wg sync.WaitGroup
   wg.Add(2)

   // Goroutine 1
   go func() {
      defer wg.Done()

      mu1.Lock()
      // Simulate some work or delay
      time.Sleep(1 * time.Second)
      mu2.Lock()

      mu2.Unlock()
      mu1.Unlock()
      fmt.Println("Goroutine 1: Unlocked")
   }()

   // Goroutine 2
   go func() {
      defer wg.Done()

      mu2.Lock()
      // Simulate some work or delay
      time.Sleep(1 * time.Second)
      mu1.Lock()

      mu1.Unlock()
      mu2.Unlock()
      fmt.Println("Goroutine 2: Unlocked")
   }()

   // Wait for all goroutines to finish
   wg.Wait()
}
Enter fullscreen mode Exit fullscreen mode

To prevent such deadlocks in Go, ensure that goroutines acquire locks in a consistent and mutually agreed order. This prevents situations where one goroutine is waiting for a lock held by another goroutine, which is also waiting for a lock held by the first goroutine.

Deadlock Due to Misuse of WaitGroup

Missing to call wg.Done() can cause other goroutines waiting on the WaitGroup to block indefinitely, assuming that all tasks have been completed:

package main

import (
   "sync"
   "time"
)

func main() {
   var wg sync.WaitGroup

   wg.Add(1)

   go func() {
      // Uncomment wg.Done below to make it work
      // defer wg.Done()

      // Simulating some work
      time.Sleep(1 * time.Second)
   }()

   wg.Wait()
   // This would deadlock if wg.Done() was missing.
}
Enter fullscreen mode Exit fullscreen mode

Make sure to always use wg.Done() when using sync.WaitGroup to properly signal the completion of goroutines and avoid the deadlock.

Conclusion

By implementing these best practices and understanding the scenarios that lead to deadlocks, you can significantly reduce the risk of encountering them in your Go programs.

Consider using this checklist when developing your program in Golang:

ā— Channel: Ensure no receiver or sender deadlock.

ā— Channel: Avoid writing to a full channel or reading from an empty one.

ā— Channel: Always close before using range.

ā— Mutex: Prevent deadlocks by managing locking order.

ā— WaitGroup: Use wg.Done() correctly to avoid blocking.

Keep coding efficiently and watch out for those sneaky deadlocks! šŸˆā€ā¬›

. .