Go Changed Forloop Semantics Inconsistencies

Posted on Mar 27, 2024

Binding a local variable in the scope of a go routine in a for-loop used to have unexpected semantic behaviour: you wouldn’t get the value of the variable at the moment of the iteration, but the actual last value (but not always! It depends on when the go routine actually runs)

package main

import (
	"fmt"
	"time"
)

func main() {
	for i := 0; i < 2; i++ {
		go func() {
			fmt.Println("Loop", i)
		}()
	}
	time.Sleep(time.Second) // allow go routines to run
}
playground link, works as long as 1.21 is the “prev” version

Which outputs a warning and

Loop 2
Loop 2

This doesn’t happen always - it depends on what the value of i is at the moment the go-routine starts. Adding a sleep will (in general) use the current value, not the last value.

func main() {
	for i := 0; i < 2; i++ {
		go func() {
			fmt.Println("Loop", i)
		}()
		time.Sleep(time.Second)
	}
	time.Sleep(time.Second) // allow go routines to run
}
playground link, works as long as 1.21 is the “prev” version

Which doesn’t output a warning and prints

Loop 0
Loop 1

This behaviour is logical and consistent but unexpected due to the asynchronous nature of go routines. The same happens in python if a closure is added to a list and executed later

l = []
for i in range(2):
    def f():
        print(i)
    l.append(f)
for f in l:
    f()

Which outputs

1
1

It’s a common pitfall. Just more common with Go.

This has been “fixed” in go 1.22. If your code explicitly depends on go 1.22 it will get the actual iteration value, but only if you use the short := variable declarion form

So,

package main

import (
	"fmt"
	"time"
)

func main() {
	for i := 0; i < 2; i++ {
		go func() {
			fmt.Println("Loop", i)
		}()
	}
	time.Sleep(time.Second) // allow go routines to run
}
playground link, using latest version

Which outputs

Loop 1
Loop 0

(note that the order is undefined!)

However, using the assignment operator on an existing variable won’t change the behaviour at all:

package main

import (
	"fmt"
	"time"
)

func main() {
    i := 99
	for i = 0; i < 2; i++ {
		go func() {
			fmt.Println("Loop", i)
		}()
	}
	time.Sleep(time.Second) // allow go routines to run
}
playground link, using latest version

Which outputs

Loop 2
Loop 2

However, the warning ./prog.go:12:24: loop variable i captured by func literal is no longer displayed.

In a way, the semantics of the for loop with short variable declaration has changed, where the value is re-declared (:=) on each iteration, in stead of reassigned (=), similar to:

package main

import (
	"fmt"
	"time"
)

func main() {
	for i := 0; i < 2; i++ {
		i := i
		go func() {
			fmt.Println("Loop", i)
		}()
	}
	time.Sleep(time.Second) // allow go routines to run
}
playground link

Variables can still be captured by func literals!

Consider the somewhat contrived example

package main

import (
	"fmt"
	"time"
)

func main() {
	if i := 10; i != 0 {
		go func() {
			fmt.Println("Val", i)
		}()
		i = 20
	}
	time.Sleep(time.Second) // allow go routines to run
}
playground link

This will (most likely) print 20

Conclusion

The change in go 1.22 doesn’t magically make go routines in general use the “correct” or expected value: it’s purely a change in the for-loop semantics. You should remain aware of this. The old behaviour itself wasn’t inconsistent; other languages behave in a similar way. However, it was a very common pitfall due to the common pattern of creating a bunch of go routines in a for loop.

The safest pattern remains to explicitly pass the variable to the go routine:

package main

import (
	"fmt"
	"time"
)

func main() {
	if i := 10; i != 0 {
		go func(i int) {
			fmt.Println("Val", i)
		}(i)
		i = 20
	}
	time.Sleep(time.Second) // allow go routines to run
}
playground link