Loop Variable scope changes in Go

Alexandr Kára
Outreach Prague
Published in
7 min readOct 18, 2023
Sci-fi purple loops (image generated by Midjourney)

The issue

One of the frequent mistakes people were making in Go was to forget that the loop variable declared using := in for loops used a single instance for all iterations as opposed to one instance per iteration. At Outreach — where we use Go extensively — I have seen this issue as a bug at least twice.

This intuitively makes sense since iterations are done sequentially so only one iteration may “run at the same time”. But reusing the same variable leads to a common bug that has its own section in the Go FAQ document ([1]).

This is one example of the problem (from the FAQ):

func main() {
done := make(chan bool)

values := []string{"a", "b", "c"}
for _, v := range values {
go func() {
fmt.Println(v)
done <- true
}()
}

// wait for all goroutines to complete before exiting
for _ = range values {
<-done
}
}

This is another, without creating goroutines:

func main() {
functions := make([]func(), 0, 3)

values := []string{"a", "b", "c"}
for _, v := range values {
functions = append(functions, func() {
fmt.Println(v)
})
}

for _, fn := range functions {
fn()
}
}

In both cases, you will probably see “c”, “c”, “c” (although in the case of goroutines, it’s not guaranteed) instead of the (possibly expected): “a”, “b”, “c”.

The problem can also be seen without using closures, by making explicit references to the variable using pointers — with the same effect — and perhaps is easier to understand since we don’t rely on “implicit capturing” but we reference the variable directly:

func main() {
pointers := make([]*string, 0, 3)

values := []string{"a", "b", "c"}
for _, v := range values {
pointers = append(pointers, &v)
}

for _, ptr := range pointers {
fmt.Println(ptr)
}
}

The problematic aspect is a combination of two things: the way loop variables are reused and how variables are captured.

If the loop variables weren’t reused for different invocations of the loop, we wouldn’t have the problem.

The second aspect is how the variables are captured. In closures (functions that reference variables from outer scope), variables are shared (see the language specification) with the outer scope. This would roughly correspond to capturing the variables by reference in C++, except that it doesn’t cause dangling references since a variable in Go “lives” as long as there is a reference to it. If there is a possibility that it would “escape” the current scope, it’s allocated on the heap to last (“live”) as long as it is referenced (this is called escape analysis, see [5]).

When using pointers directly, it’s the same story — it’s just more obvious that we are taking a reference to the variable, not copying the value.

The solution

This problem was so common that Go 1.21 (released 2023–08–08) contains a new experimental feature which changes the semantics so that each iteration would use a new variable. It can be turned on with GOEXPERIMENT=loopvar (see [2] and [3]) and that behavior will probably be enabled by default in Go 1.22 ([4]).

This corresponds to the common fix of the above code that explicitly creates a new variable:

func main() {
functions := make([]func(), 0, 3)

values := []string{"a", "b", "c"}
for _, v := range values {
v := v // Here we create a new variable explicitly.
functions = append(functions, func() {
fmt.Println(v)
})
}

for _, fn := range functions {
fn()
}
}

The new approach can have its downsides, though. One obvious problem is that since we can have more variables now, we might be using more memory and copying data around. The other problem is that some (admittedly obscure) code will now behave differently. Let’s look at both of them in more detail and then see how it looks in practice.

Additional copies

Since a new variable is used, we can potentially use additional memory and/or perform unnecessary copying. In most cases, the escape analysis can figure out that the variable cannot be used outside of a single iteration and each instance which gets created on each loop iteration will share the same location (e.g. a register or the same place on stack). I would expect that this should happen in all cases when you don’t take a pointer to it or use it in a closure. However, even if you take a pointer to it but the compiler can deduce that the pointer cannot live past the current iteration, it might still be able to perform this optimization (but see Notes below for examples showing that taking a pointer to a variable happens in more cases than one might expect).

In some cases, though, a new copy will need to be created. What’s worse is that if new variables need to be created, they are likely to be created on the heap, creating more pressure on the garbage collector and likely worsening the cache locality of the values.

In addition to using more memory, there is some additional cost of copying the value if it cannot be optimized away.

For variables that are small (integers, strings, slices, maps, interfaces, etc.), there should be no penalty if a new variable is introduced and there can even be performance improvements since it’s clearer for the compiler that the value cannot be additionally referenced through other variables (it aids the compiler with the alias analysis).

Behavior changes

For the second issue, let’s see code that will behave differently with the change:

 for i, p := 0, new(int); p != &i; {
p = &i
}

Currently, the loop exits after the first iteration but with the change it will never exit since the assignment

p = &i

will assign to p the address of the previous iteration but the test will compare p with the address of the next iteration. More generally, if the condition or the post statement of a for loop references a loop variable, it will now reference a different variable than before and this can cause subtle changes — although in practice this should be rare.

In practice — when we look “under the hood”

I tried a slightly simplified example above (without appending to functions) and compiled it on go 1.21.0 with or without the line v := v commented out and with the GOEXPERIMENT=loopvar variable:

func main() {
functions := make([]func(), 3)

values := []string{"a", "b", "c"}
for i, v := range values {
v := v // This line is potentially commented out.
fn := func() {
fmt.Println(v)
}
functions[i] = fn
}

for _, fn := range functions {
fn()
}
}

Compilation results (on go 1.21.0):

The two last cases compiled to exactly the same code, so in practice, it looks like the new experimental feature will be similar to removing the need for adding a re-assignment like:

v := v

In all three cases, it created a closure object (for the anonymous function) on the heap but in the first case, it used a closure like:

struct { F uintptr; main.v *string }

with v (or main.v as denoted here) captured by pointer but in the other two cases, it used:

struct { F uintptr; main.v string }

with main.v as a value which is then filled directly from the value of v. The whole code (in AMD64 assembly) looks like:

1. LEAQ type:noalg.struct { F uintptr; main.v string }(SB), AX
2. CALL runtime.newobject(SB)
3. LEAQ main.main.func1(SB), CX
4. MOVQ CX, (AX)
5. MOVQ main.v.len+16(SP), DX
6. MOVQ DX, 16(AX)

Where it fills the address of the anonymous closure type into the register AX, then it calls runtime.newobject(), which creates a closure on the heap and returns its address in the register CX. Then it pushes the address of the anonymous function:

func() { fmt.Println(v) }

into CX and stores it into the first data member of the closure (called F in our case). Then it copies v (stored temporarily on the stack) to DX and then writes it to the main.v member of the closure.

Since the closure is allocated on the heap in all cases, adding v := v or compiling with GOEXPERIMENT=loopvar doesn’t cause any new allocations.

Conclusion

The change has the potential to simplify many loops and reduce the cognitive burden on the programmer who won’t need to be thinking about whether they should do an explicit copy of the variable to be safe when capturing it.

In practice, the performance and correctness downsides of the change should be very small. The change has been run on large code corpuses at Google and only a small number of tests failed (which were usually incorrect to begin with) and no production issues or bug reports have been observed (see [7]).

Notes

As an interesting aside, taking the address of a variable is more common than many realize. For example, every assignment to an interface variable takes the address of the right-hand side (if it’s not a pointer already) since an interface is represented as a pointer to the itable which contains information about the type (and associated functions) and a pointer to the data. So for example, calling:

fmt.Print(v)

will implicitly take the address of v (see [6]).

References

[1] Go FAQ section: What happens with closures running as goroutines

[2] Go Proposal: Less Error-Prone Loop Variable Scoping

[3] Experimental change in v1.21: LoopvarExperiment

[4] The Go Blog: Fixing For Loops in Go 1.22

[5] Eli Bendersky: Go internals: capturing loop variables in closures

[6] Representation of integers: Go Data Structures: Interfaces

[7] Blog post about the safety of the change: Google testing

--

--