Skip to content

Instantly share code, notes, and snippets.

@kklysenko
Created June 22, 2024 23:35
Show Gist options
  • Save kklysenko/cbebb95190b4f4970962c21f15800caa to your computer and use it in GitHub Desktop.
Save kklysenko/cbebb95190b4f4970962c21f15800caa to your computer and use it in GitHub Desktop.
# Deadlocks in Go: Understanding and Preventing for Production Stability
`fatal error: all goroutines are asleep - deadlock!`
Damn, again! 💀
Yes, that’s often the reaction if you’re not aware of all possible deadlocks that might happen.
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.
This article will help you prevent all possible deadlocks and understand their nature.
## 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.
```go
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
}
```
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:
```go
package main
import "fmt"
func main() {
goChannel := make(chan int)
goChannel <- 1
// Attempt to read the value from the channel
fmt.Println(<-goChannel)
}
```
Wait... What? Why does it still throw deadlock? 😡
This is because channel **aims 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:
```go
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)
}
```
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:
```go
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)
}
```
Yeah, finally it works. 🥳
This rule doesn't apply to buffered channels since they have **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:
```go
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)
}
```
#### No sender deadlock
It occurs when a goroutine tries to read data from a channel, but **no value will ever be sent**:
```go
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
}
```
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:
```go
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")
}
```
#### 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.
```go
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)
}
```
#### 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)`:
```go
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")
}
```
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:
```go
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()
}
```
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:
```go
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.
}
```
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! 🐈‍⬛
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment