Debug Your Go Code Without 100 Extra Printlns
A lot of people use Println
debugging.
That is: When your program doesn’t work, add a ton of Println
s everywhere so you know whether things are going the way you planned as your code runs.
Take the following code — and yes, there’s a bug in it:
The idea is to listen for connections, and simultaneously send a GET request to that listener. The listener should reply with Howdy!
, and the output should be: The webserver said: Howdy!
Instead, it just hangs without printing my message. In attempting to debug this, you’ll typically see something like:
You’d then add all sorts of variables to these Println
s, track how they change, and so on.
I’m not a fan of this approach, but surprisingly it is the main debugging technique of so many amazing developers.
It’s not ideal though: Your code gets really messy, you forget to delete them afterwards, and often they don’t really provide the information you need.
Luckily, there’s a better way.
Meet the debugger!
A debugger is a tool that breaks down the execution of your code, allowing you to step through it line by line, or dive inside every function call, or just stop at specific points of your code so you can see what’s going on.
To learn how a debugger works, let’s assume you’re a newbie developer and you’re trying to figure out why the code in the excerpt above hangs instead of giving you the expected response.
The best known debugger out there is probably gdb. It’s been around since 1986 — longer than me.
But since we’re using Go, we’re gonna use a debugger made specifically for Go: Delve. On the surface, the way they work is basically the same.
We’ll learn the main debugging commands you need to be familiar with: break
, continue
, next
, step
, restart
, breakpoints
, clear
, and locals
.
See the installation instructions and let’s get to it!
Debugging In Practice
To start debugging, go into the folder where your code is, and type dlv debug
. This will build your project, run it, and attach the debugger to the process. It doesn’t output much:
The first thing you’ll need to do is tell Delve where to stop. You don’t want it to just run through your whole program, you want it to stop somewhere so you can analyse things. These stopping spots are called breakpoints.
Since we don’t really know where the problem lies, just that the program hangs, let’s start with func main()
. We can do that with either break
or just b
.
You’ve set your first breakpoint. Let’s start our program and see what happens.
For that, use continue
, or just c
. It runs the code until it hits the next breakpoint.
Now our program is running, and it has stopped on line 10.
Let’s go to the next line by typing next
, or n
.
If we just press enter, it’ll repeat the previous command, and go to the next line.
You can do that a couple more times, but once you try to get past line 12, it will hang.
That’s our http.ListenAndServe()
call. So there’s probably something weird going on there.
Let’s CTRL-C
out of it, and type restart
or r
to start our program again.
Before we keep going, let’s move our breakpoint to line 12, and clear the previous main.main
breakpoint.
Let’s go to our new breakpoint with continue
, or c
, and then step inside that function call to see what’s going on in there. We can do that with step
, or s
.
Now we’re peeking into code from the net/http
library. Nothing critical there except for calling server.ListenAndServe()
. Let’s next
or n
until we get to line 3004 there, and then step
or s
into it once more.
I can’t see anything that would cause my code to hang there, so let’s keep hitting next
or n
until something fishy pops up.
Line 2764 looks suspicious… Let’s add a breakpoint here, just in case, and delete the previous one.
Alright! I’m ready for the next step
! I’m going in!
A-ha! Found it!
Hitting next
or n
a few times, we find the culprit. There’s an infinite loop there, which is what Serve()
uses to keep listening to new connections indefinitely.
This means that our call to ListenAndServe()
, waaaaay back at the beginning, blocks the control flow.
So if we want to get to the next line while still listening for incoming connections, we’ll need to run these in separate goroutines. An easy fix:
Now when we run our program:
Perfect!
And One More Thing
We can also use the debugger to check the value of variables in our program. Let’s take the fixed version of our exercise and run it again.
Take note of the locals
command, which lists out the local variables, and the print
command, which we’re using to show the value of one of those variables.
In Closing
This is of course just a little example to show you how to use Delve.
Ideally you would read the documentation to find that ListenAndServe()
calls block, as opposed to digging down a few layers deep to figure out what’s going on.
I hope this was helpful in showing you how debuggers work though, and that it’ll inspire you to delve deeper into the subject.
And one last tip: If you’re developing microservices and you’d like to run instances of Delve across various deployed containers simultaneously, check out Squash — that’s exactly what it does.