As a software engineer, there comes a time when you encounter a problem that a single thread or process might not be enough. So you decide to solve it with multi-threading, or in Go’s case, goroutines. Although, Go makes it easier to create concurrent programs compared to other languages, debugging them is still a big pain. In this post, we will explore a tool that can help us debug them, called Delve debugger.
Debugging with print statements (the ugly way)
Before exploring the Delve debugger, let’s see how most of us debug programs. Let us see the printf debugging technique. For example, let’s assume the following simple Go program, which divides two numbers and prints them to the standard output.
|
|
When we run the program it will print the following output:
100/2=1
The program prints the wrong result. For most, the error would be obvious, but let’s see how someone would debug it using print statements.
|
|
When debbuging with print statements, we usually end up adding many of them in our code to verify the variables, and as you can see our code quickly becomes cluttered.
Now let’s run our program again.
Output:
a=100
b=2
res=1
100/2=1
We have printed on the console the function arguments, as
well as the local value res
. We see that although the args
have been passed corectly, the variable res
doesn’t hold the
expected value.
Using the print statement debugging technique, we managed to
pinpoint the culprit. In our case, it is line 14.
Enter the goroutines
That was pretty easy, now let’s add some goroutines and try the same technique. We are going to modify the code so that it will work with goroutines. We are using a WaitGroup to sync our program and so that the maing goroutine does not exit before the goroutines we have spawned finished their job. We are just printing the result on the console with thefmt
package to keep it
as simple as possible, although it’s not safe and you
should never do this.
|
|
If we run the program, we see the same bug again:
100/5=1
100/2=1
100/1=1
100/4=1
100/3=1
Now let’s add some print statements to debug the goroutines.
|
|
If we run the program after we have added the print statements, we get the following output:
a=100
b=5
res=1
100/5=1
a=100
b=4
res=1
100/4=1
a=100
b=2
res=1
100/2=1
a=100
b=1
res=1
100/1=1
a=100
b=3
res=1
100/3=1
As you can see, now that we introduced goroutines debugging with print statements has become super hard. Even for such a small program as the above. The output order from the print statements is pretty much random. And because of that, it is hard to figure out which goroutine printed it.
A few more reasons to avoid debugging with print statements:
- the
fmt
package is not thread-safe - debugging with print statements makes the whole debugging process slower since you have to recompile each time you add a new print statement.
- after you are done with debugging, it is easy to forget some print statements in your code.
Debugging with Delve (the good way)
Now let’s debug our goroutines with Delve. To use the Delve debugger, we’ll have to install it first. I installed on my Linux system with thego get
method:go get github.com/go-delve/delve/cmd/dlv
If you have a different system, you can check the installation process for your system here.
We are going to debug the program with the goroutines we introduced earlier:
|
|
Now that you have installed Delve successfully, you can load the
program into Delve with dlv debug main.go
, and you should
be able to see the (dlv)
prompt. Here you can use run
commands to help you debug your Go program. You can run
help
to see the options Delve offers.
The most common option you will use is break
, which sets a
breakpoint. And you can list all your breakpoints with
breakpoints
. For example, let’s set a breakpoint at line
19 in our code.
(dlv) break main.go:19
Breakpoint 1 set at 0x4ba9cf for main.div() ./main.go:19
To run our program until the next breakpoint (or until
termination, if none is set), we can use continue
.
(dlv) continue
> main.div() ./main.go:19 (hits goroutine(7):1 total:3) (PC: 0x4ba9cf)
> main.div() ./main.go:19 (hits goroutine(8):1 total:3) (PC: 0x4ba9cf)
> main.div() ./main.go:19 (hits goroutine(10):1 total:3) (PC: 0x4ba9cf)
14: wg.Wait()
15: }
16:
17: func div(a, b int, wg *sync.WaitGroup) {
18: defer wg.Done()
=> 19: res := a / a
20: fmt.Printf("%d/%d=%d\n", a, b, res)
21: }
Delve is now running our program and has paused at the breakpoint on line 19. We can see that Go has created three goroutines, all of which have halted at line 19. The entire Go program was paused at that point time, and that is why the main goroutine didn’t manage to spawn the rest of the goroutines. Delve has also printed the source code near that line.
Here we have many options. For example, we can list the
function’s arguments with the command args
.
(dlv) args
a = 100
b = 5
wg = (*sync.WaitGroup)(0xc00001a0b0)
You can print the local variables with locals
.
(dlv) locals
(no locals)
We see that there is no local variable in the div
function
yet. That’s because we are at line 19, and the command at
that line hasn’t been executed yet. Let’s execute it by
stepping over to the next line with next
.
(dlv) next
> main.div() ./main.go:20 (PC: 0x4baa01)
15: }
16:
17: func div(a, b int, wg *sync.WaitGroup) {
18: defer wg.Done()
19: res := a / a
=> 20: fmt.Printf("%d/%d=%d\n", a, b, res)
21: }
Ok, let’s check the local variables now.
(dlv) locals
res = 1
Using goroutines
we can list the goroutines of the program.
(dlv) goroutines
Goroutine 1 - User: /usr/local/go/src/runtime/sema.go:56 sync.runtime_Semacquire (0x46a725)
Goroutine 2 - User: /usr/local/go/src/runtime/proc.go:307 runtime.gopark (0x43b835)
Goroutine 3 - User: /usr/local/go/src/runtime/proc.go:307 runtime.gopark (0x43b835)
Goroutine 4 - User: /usr/local/go/src/runtime/proc.go:307 runtime.gopark (0x43b835)
Goroutine 5 - User: /usr/local/go/src/runtime/mfinal.go:161 runtime.runfinq (0x41a460)
Goroutine 6 - User: ./main.go:17 main.div (0x4ba960)
Goroutine 7 - User: ./main.go:19 main.div (0x4ba9d7) (thread 559883)
Goroutine 8 - User: ./main.go:20 main.div (0x4baa01) (thread 559881)
Goroutine 9 - User: ./main.go:17 main.div (0x4ba960)
* Goroutine 10 - User: ./main.go:20 main.div (0x4baa01) (thread 558969)
[10 goroutines]
Delve has not only printed our main program’s goroutine, but
also the Go runtime goroutines.
The goroutine we are currently inspecting has an asterisk symbol *
prepended. In our case, it is Goroutine 10. We can switch
to another goroutine using the command goroutine
.
(dlv) goroutine 8
Switched from 10 to 8 (thread 559881)
Now that we switched to Goroutine 8 let’s print the function arguments.
(dlv) args
a = 100
b = 3
wg = (*sync.WaitGroup)(0xc00001a0b0)
We see that this goroutine has different args. But if we print the local variable res we will see that it has the same value:
(dlv) locals
res = 1
One last command we are going to see is set
. This is a very powerful
and useful command, since it let’s you change the value of your
program at runtime.
(dlv) set res=10
(dlv) locals
res = 10
Conclusion
Debugging concurrent programs and goroutines can get very complicated, and because of that, you should use all the tools available that can make the whole process easier. Delve offers many great features for debugging your Go programs, and it will help you a lot when debugging.
Delve also has many integrations with editors, such as GoLand (which I currently use), VSCode, Vim, etc. There is also a nice (if you prefer that) GUI interface for Delve, called Gdlv.
Feel free to share below in the comment section how you are debbuging your Go programs.