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.
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 goto
s 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.