A goroutine can capture a variable from the surrounding function. Whether a loop gives each goroutine a separate variable depends on how that variable is declared and which Go language version the package uses.
For modules declaring go 1.22 or later, variables declared by a loop have a new instance on each iteration. The common loop-capture bug shown in older Go tutorials therefore no longer applies to those declarations. Variables declared outside the loop can still be shared.
A runnable example #
Save this as main.go:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
start := make(chan struct{})
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
fmt.Println(i)
}()
}
close(start)
wg.Wait()
}
The channel makes every goroutine wait until the loop has finished. The wait group keeps main alive until all five goroutines return. This lets us compare the scoping rules without concurrent reads and writes to the old shared variable.
With a Go 1.22-or-newer toolchain, use this go.mod:
module example.com/loopvars
go 1.22
Run go run .. The output contains 0, 1, 2, 3, and 4, each once. Their order is unspecified because the goroutines run concurrently.
Change the module’s language version to go 1.21 and run it again. Under those older semantics, the loop has one i, which has reached 5 before the channel opens. The program prints 5 five times.
Using a newer compiler alone does not necessarily change an older module’s language semantics. The go directive matters. See Go’s explanation of the transition
and the Go 1.22 release notes
.
Variables declared outside the loop are still shared #
This declaration places i outside the loop:
var i int
for i = 0; i < 5; i++ {
// A closure referring to i still captures the same variable.
}
Go 1.22 does not turn this existing variable into five separate variables. If goroutines read it while the loop writes it, the program also has a data race. Waiting for all goroutines at the end does not make those concurrent accesses safe.
Passing the value explicitly #
Passing the value as an argument works with both language versions and makes the goroutine’s input clear:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(value int) {
defer wg.Done()
fmt.Println(value)
}(i)
}
wg.Wait()
}
The argument i is evaluated before the new goroutine starts. Each call receives its own integer value, so both Go 1.21 and Go 1.22 semantics produce the values 0 through 4 in an unspecified order.
Run go run -race . when experimenting with concurrent code. Passing a pointer or slice does not automatically isolate the data it refers to; synchronization may still be needed when goroutines share mutable data.
When a concurrent operation fails, capturing an error’s stack trace can help identify where the failure originated.