ζeta

by Luka Napotnik

Debugging Without A Debugger In Go

When browsing through the Internets I noticed complaints about missing debuggers for the Go programming language. Then I asked myself when was the last time I really missed a debugger in my 2-years of developing production code in Go? Well, turns out I didn’t.

There is partial support for the GNU gdb debugger, but if debugging concurrent code, the debugger becomes unusable. When I was still programming in C, I used gdb together with valgrind quite frequently to debug invalid memory accesses or strange race conditions. The debugger was a tool I just couldn’t afford to live without.

In this article I’ll explain some methods that I use to report bugs (see Graceful Panics) and the tools I use to detect performance bugs and race conditions.

Unit Tests

Because of the nature of the language and good standard library support, test-driven development in Go comes as natural as water to a fish. The only requirement is that the code you’re testing must be decoupled from other components as much as it’s possible. Because some components are a requirement (like databases and file system) it’s possible to mock those objects and pass interfaces to the code.

So, to be short:

Test functions in the *_test.go file must have the following signature:

func TestXYZ (t *testing.T) {

where XYZ is the name of the module to test. Notice the prepended ‘Test’ in the function name that is a requirement for the testing tool to detect functions that should be run.

Running the tests is as trivial as:

$ go test program

If any of the tests fail, a nice report with an error message will show up. Which is great if I start to refactor the code and mess up the code. The tests will show up the bug.

Graceful Panics

Bugs can often show up because of unhandled errors. Error handling, or rather error propagation in Go is somehow unique from other C-family languages. The language provides mechanisms to terminate execution by panicking. By calling panic() the execution of the current function ends and the panic is propagated through the call stack upwards. It is meant for exceptional errors that the calling function doesn’t know how to handle. To recover from such a call, a call to recover() will stop program panic.

func doPanic() {
    panic("SOS, SOS")
}
func main() {
    defer func() {
    if e := recover(); e != nil {
        fmt.Fprintf(os.Stderr, "PANIC: %v\n", e)
    }
    }()
doPanic()
}

The example above shows how to catch a program panic and print the received message. But there are errors that can be handled only by the first call of a process, that is, the main() function. To generalize the previous panic/recover method, I create global error handling for such exceptional errors using channels and a main loop.

Define an error type which should have an error code and an accompanying message:

type ErrFatal struct {
    Message string
    Code int
}
func (e ErrFatal) Error() string {
    return fmt.Sprintf("fatal error(code %d): %s", e.Code, e.Message)
}

Create a global channel for errors:

var MainChannel chan error = make(chan error)

The function ErrorOut() will generate the ErrFatal error and send it to the main loop for further handling:

func ErrorOut() {
    MainChannel <- ErrFatal{"Please stop!", -2}
}

The MainLoop() will set up the programs main loop and will catch messages sent from the program:

func MainLoop() {
    value := <-MainChannel
    switch v := value.(type) {
    case ErrFatal:
        fmt.Fprintf(os.Stderr, "FATAL: %v", v)
        debug.PrintStack()
        os.Exit(v.Code)
    default:
        fmt.Printf("Generic error: %v", v)
    }
}

We now have a global error handler for custom or generic errors. Depending of the error type, I can now call appropriate functions before I terminate program execution (if at all):

func main() {
    go func() { time.Sleep(time.Second); ErrorOut(); }()
    MainLoop()
}   

Tools

Race Conditions

Let’s take a look at this example:

func main() {
    aVal := 0
    endChan := make(chan bool)
    go func() {
        for i := 0; i < 3; i++ {
            aVal++
            time.Sleep(200 * time.Millisecond)
        }
    }()
    go func() {
        for i := 0; i < 10; i++ {
            fmt.Printf("aVal is %d\n", aVal)
        }
        endChan <- true
    }()
    <-endChan
}

Now we run the program:

$ go run ex1.go
aVal is 1
aVal is 1
aVal is 1
...

The output seems correct since the second gorutine just hasn’t yielded for the other gorutine to change the value of aVal. But to an experienced programmer it is quickly noticeable that the program introduces a data race between two gorutines which may not be visible at this particular execution. The problem is with more complicated code where the problem isn’t so obvious.

The standard Go implementation provides a -race flag when compiling. This will build the program with a race detector which may slow down the execution a bit, but we get a nice error. Let’s build and re-run the previous example again:

$ go run -race ex1.go
==================
WARNING: DATA RACE
Read by goroutine 6:
  main.func·002()
      /home/napsy/ex1.go:19 +0x64

Previous write by goroutine 5:
  main.func·001()
      /home/napsy/ex1.go:13 +0x62

The race detector caught two gorutines, one reading and one writing to the same object (namely aVal). Now we can fix the problem by introducing mutexes.

Profiling

Profiling is a great way to detect performance bugs. The standard Go library provides a CPU and memory profiling API. To profile the program, start the profile with an output file and end profiling when the program finishes:

import "runtime/pprof"

func main() {
    profOut, err := os.Create("output.prof")
    if err != nil {
        fmt.Fprintf(os.Stderr, "Couldn't create profile output: %v", err)
        return
    }
    pprof.StartCPUProfile(profOut)
    defer pprof.StopCPUProfile()
    // ...
}

When the program execution ends, the pprof tool helps out with the data, gathered from the profiling:

$ go tool pprof ./program output.prof

Conclusion

At the beginning I was a bit shocked that there was so little or no support for any debuggers for Go: no debugger is shipped together with the standard implementation, the support for GNU gdb is very poor or unusable. But as I used Go more and more I learned I didn’t even need a debugger. The code either worked out of the box or the shipped tools detected the problem that I later fixed.

There are more interesting tools available for easing the development in Go that I didn’t cover. I encourage you to find out!

About the author

Copyright © 2014


diaspora*