Hunting Deadlocks in Go: Using GoLand and Delve for Goroutine Inspection
Learn how to use GoLand's integration with Delve to visualize goroutine stacks, set conditional breakpoints, and resolve complex deadlocks in Go applications.
21 Jul 2025, 06:52 UTC

The Invisible Wall: When Your Go App Just Stops
\nYou have a Go service that works perfectly in unit tests but occasionally hangs in production or during integration tests. There are no panic logs, no stack traces in the output, and the CPU usage has dropped to near zero. You are likely facing a deadlock—a state where two or more goroutines are waiting for each other to release resources, and none can proceed.
\n\nVisualizing the Goroutine Stack
\nWhen a Go program hangs, the most critical piece of information is not the current line of code, but the state of all other goroutines. GoLand's debugger provides a Goroutines view that lists every active goroutine, its current status (Running, Waiting, or Sleeping), and its current stack trace.
\n\nBy pausing a hanging application, you can scan this list to find goroutines stuck on channel operations. If you see multiple goroutines waiting on chan receive or chan send without a corresponding sender or receiver, you have found your deadlock site. This is significantly faster than manually analyzing a pprof dump because you can click a goroutine in the list and be taken directly to the line of code causing the block.
Isolating Noise with Conditional Breakpoints
\nConcurrency bugs often only trigger after thousands of successful iterations. Stopping the program at every loop iteration is impractical and destroys the timing of the execution, making the bug disappear (a “Heisenbug”).
\n\nConditional breakpoints allow you to tell the debugger: “Only stop here if i == 5000” or “Stop here if userID == \"test-123\". This allows the application to run at near-native speed until the specific edge case is hit, preserving the state of the system as closely as possible to the failure condition.
Worked Example: Diagnosing a Circular Wait
\nConsider a scenario where two goroutines are trying to lock two different mutexes in opposite orders. This is a classic circular dependency.
\n\n// Run this with: go run -gcflags=\\"all=-N -l\\" main.go
package main
import (
"fmt"
"sync"
"time"
)
var lockA = &sync.Mutex{}
var lockB = &sync.Mutex{}
func main() {
go func() {
lockA.Lock()
fmt.Println(\\"Goroutine 1: Locked A\\")
time.Sleep(time.Millisecond * 10)
lockB.Lock() // Will hang here
}()
go func() {
lockB.Lock()
fmt.Println(\\"Goroutine 2: Locked B\\")
time.Sleep(time.Millisecond * 10)
lockA.Lock() // Will hang here
}()
select {}
}
\n\nDiagnostic Steps in GoLand
\n- \n
- Build Configuration: Ensure you are running with
-gcflags=\\"all=-N -l\\". This disables compiler optimizations and inlining, ensuring that the debugger can see all local variables and accurate line numbers. \n - Trigger the Hang: Run the program in Debug mode. The console will show the first two print statements, then stop. \n
- Pause Execution: Click the Pause Program button in the Debug tool window. \n
- Inspect Goroutines: Open the Goroutines tab. You will see two goroutines blocked on
sync.runtime_SemacquireMutex. \n - Trace the Chain: Clicking the first goroutine shows it is waiting for
lockBwhile holdinglockA. Clicking the second shows it is waiting forlockAwhile holdinglockB. The circular dependency is now visually confirmed. \n
Trade-offs and Debugging Risks
\nWhile powerful, using a debugger for concurrency introduces specific risks:
\n- \n
- Timing Shifts: Pausing one goroutine does not always pause the entire world instantly. The act of hitting a breakpoint can change the interleaving of threads, potentially masking the race condition you are trying to find. \n
- Performance Overhead: Running with
-N -lflags makes the binary slower. In extremely high-throughput systems, the debugger might introduce enough latency to prevent the deadlock from occurring. \n - State Mutation: Be cautious when using the “Evaluate Expression” tool to change variable values while paused; modifying a mutex state manually can lead to unpredictable crashes. \n
Verification and Closing
\nTo verify your fix, restart the session without the pause button and monitor the Goroutines view periodically. If the number of goroutines remains stable and the application continues to process requests, the deadlock is resolved. For long-running services, use the Attach to Process feature in GoLand to inspect a live instance without restarting the entire environment, provided the process was started with the necessary debug symbols.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.