Posted on February 14, 2024

Give Go's goto a retry

Go is known to be a simple language. My favorite measure of language complexity is keyword count. In my experience, language complexity grows quadratically with keyword count. You have to consider not only the keyword themselves, but the various ways you can combine them to achieve the same goal.

Language Number of Keywords
Go 25
Python 35
Java 51
JavaScript 63
C++ 84

Amongst its peers, Go has few keywords, and the Go authors have promised to never remove a keyword.1 So, I believe that each, even the infamous goto, was carefully considered to be necessary in some cases.

goto considered harmful

Most programmers are taught early on that goto is a bad idea. I see Dijkstra's 1968 letter as a frequently cited source2. But, to be frank, I have trouble resonating with this paper, probably because it was written for a time where gotos were everywhere and structured programming (for, while, if, etc.) in dire need of marketing.

xkcd #292

Much of goto's bad reputation comes from its memory safety issues in C. Here's an example of a C program that uses goto to jump into a new scope and bypass the initialization of a variable:

#include <stdio.h>

int main() {
    goto some_scope;
    
    int a = 5;

some_scope:
    printf("%d\n", a);

    return 0;
}

Fortunately, the Go compiler prevents goto from jumping over variable declarations.

The following Go program fails to compile:

package main

func main() {
	goto some_scope
	a := 5
some_scope:
	println(a)
}
// ./prog.go:6:7: goto some_scope jumps over declaration of a at ./prog.go:7:4

So, we can focus purely on readability and style when considering goto in Go.

goto today

I counted the number of gotos in the Go standard library (1.21.4) using the following command:

 cd /usr/local/go
 semgrep --lang=go --pattern='goto $X' --include='*.go' --json \
    | jq '.results | length'

And, the number of for loops

semgrep --lang=go --pattern='for ... {...}' --include='*.go' --json \
    | jq '.results | length'

I was surprised to find 603 goto statements in the standard library, compared to 23,171 for loops. This is a 38:1 ratio of for to goto.

In contrast, the Kubernetes codebase has 8 goto statements for 18,018 for loops. A 2,252:1 ratio.

Here's where the standard library uses goto:

Directory Count
syscall 119
runtime 93
internal/types/testdata/check 80
cmd/compile/internal/types2 45
go/types 42
regexp 24
cmd/internal/obj/x86 24
cmd/compile/internal/syntax 17
index/suffixarray 16
debug/dwarf 15

See the full list here.

And, here's the name of the labels:

Label Count
childerror 118
Error 113
L 63
bad 17
retry 16
top 14
ok 11
CheckAndLoop 11
done 10
error 9

Combing through syscall and runtime code, I find that goto is primarily used for defer-less error handling. A lot of the code follows a layout like this:

func foo() (err error) {
    // ...
    if err != nil {
        goto fail
    }
    // ...
    if err != nil {
        goto fail
    }
    // ...
    if err != nil {
        goto fail
    }
    // ...
    return nil

    fail:
        // cleanup
        return err
}

I suppose the potential overhead of defer is too high for these fundamental functions that sit at the bottom of every call stack. Maybe the compiler is smart enough to convert the semantically equivalent defer code into goto machine code. But, we probably don't want to couple the performance of these functions with non-deterministic compiler optimizations.

Most Go programmers aren't chasing nanoseconds, so it's interesting to consider higher levels packages like regexp.

The regexp package has a tryBacktrack function with 12 gotos that looks a bit like this:

func (re *Regexp) tryBacktrack(b *bitState, i input, pc uint32, pos int) bool {
	for len(b.jobs) > 0 {
		// ...
		goto Skip
	CheckAndLoop:
		// ...
	Skip:
		// ...
	}

	// ...
}

Russ Cox has an excellent article on the design of the Go regular expression engine. My conclusion after reading the code is that goto statements can improve the readability of code that implements complex state machines.

You can have a 1:1 mapping of goto statements to transitions in the state machine—and, you don't have to contort your programs into ifs and fors that are not meaningful in the state machine mental model.

We see a similar goto state machine pattern in index/suffixarray/saic.go.

A pragmatic takeaway could be: consider goto when the data defines the control flow.

Retry

Most Go programmers in their day-to-day are not writing low-level operating system interfaces or complex finite automata. Instead, we're glueing together flakey systems and services. And, to do that successfully, we're writing a bunch of retry loops.

We'll notice 16 different goto retry statements across the standard library. This, I believe, is the most relevant goto use case for most Go programmers. Consider the following two functions:

func ping1() error {
    var err error
	
    for {
        _, err = http.Get("https://google.com")
        if err != nil {
            time.Sleep(time.Second)
            continue
        }
        break
    }
}

func ping2() error {
  retry:
    _, err := http.Get("https://google.com")
    if err != nil {
        time.Sleep(time.Second)
        goto retry
    }
}

I see code that looks like ping1 all the time, and code that looks like ping2 never, yet I find ping2 far more readable.

When I see a for loop, I expect the indented code to be executed on average more than once, but that is not the case for retry loops, causing confusion and cognitive overhead.

At coder we've happily adopted goto-based retries that look like this:

func pingGoogle(ctx context.Context) error {
    var err error

    // exp backoff starting at 1s, maxing at 10s
    r := retry.New(time.Second, time.Second*10);

  retry:
    _, err = http.Get("https://google.com")
    if err != nil {
        if r.Wait(ctx) {
            goto retry
        }
        return err
    }

    return nil
}

And, we've published a tiny library called retry to make this pattern easier for the rest of the Go world to adopt.


  1. Go 1 Compatiblity Promise
  2. StackOverflow: goto still considered harmful