Go Changed Forloop Semantics Inconsistencies
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
}
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
}
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
}
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
}
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
}
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
}
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
}