Skip to main content

Go Goroutine Loop Variables: Before and After Go 1.22

··3 mins
Sebastian Scheibe
Author
Sebastian Scheibe
Table of Contents

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&rsquo;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&rsquo;s stack trace can help identify where the failure originated.