skip to content

In Go, when are the function value and arguments of go f(x) evaluated?

level: middleimportance: must knowfreq 68%

answer

  1. half of it happens before the goroutine exists
  2. the caller does the reading
  3. arguments snapshot, closures re-read
  4. Go 1.22 changed the loop, not the capture rule

basics

~20 s

Immediately, in the calling goroutine, at the go statement itself; only the call runs in the new goroutine. So go f(x) snapshots x on the spot, while go func(){ f(x) }() reads x later, whenever the goroutine happens to run.

solid answer

~50 s

The Go specification says the function value and the parameters of a `go` statement are evaluated as usual **in the calling goroutine**; the call itself is what happens in the new goroutine. So in `go check(h)`, `h` is read at that line and the goroutine receives a copy — reassigning `h` afterwards cannot change what `check` sees. A function literal behaves differently, because a closure captures the *variable*, not its value: `go func() { check(h) }()` reads `h` whenever the goroutine gets scheduled, so a later write is both visible and, if unsynchronised, a data race. The same rule covers the receiver: in `go conn.ping()`, `conn` is evaluated at the `go` statement. The practical idiom is to pass anything the goroutine needs as an argument, which both snapshots it and makes the dependency visible in the code.

code

go · 7 lines
go
h := hosts[0]

go check(h)  // h is evaluated here; the goroutine gets this value
h = hosts[1] // has no effect on the call above

go func() { check(h) }() // reads h when the goroutine runs: may see hosts[1]
go func(h string) { check(h) }(h) // fixed: h is an argument, evaluated now

go deeper

for a junior

Remember that go f(x) evaluates x right away, so the goroutine works on the value at that line. If you need a variable inside a goroutine, pass it as an argument.

for a middle

State the two halves of the rule — evaluation in the calling goroutine, invocation in the new one — and contrast an argument with a closure capture, including what changed for loop variables in Go 1.22.

for a senior

Diagnose it in review: spot the captured variable that is written after the go statement, name it as a data race, and show the argument-passing rewrite plus the race detector run that proves the fix.

for a principal

Decide the house rule and why: passing values into goroutines rather than capturing them keeps the data each goroutine touches visible at the call site and keeps code correct regardless of which Go version a file assumes.

## The rule, stated precisely A `go` statement has two halves, and they run in different goroutines: 1. **Evaluation** of the function value, the receiver (for a method call) and every argument — this happens *in the calling goroutine*, at the moment the `go` statement executes, in the ordinary left-to-right order. 2. **Invocation** — this happens in the new goroutine, at some unspecified later point. So `go` behaves like an ordinary call right up to the instant of calling, then diverges. ## Passing versus capturing This is the distinction the rule exists to make: ```go h := hosts[0] go check(h) // h is read now; the goroutine gets a copy of that value h = hosts[1] // cannot change the call above go func() { check(h) }() // h is read when this goroutine runs h = hosts[2] // the goroutine may see hosts[1] or hosts[2] ``` The first form **snapshots**. The second form **captures the variable**: a function literal closes over `h` itself, not over its value at the time the literal was written, so it reads whatever `h` holds at execution time. And because the goroutine's read and the caller's write are unsynchronised, that second pattern is a genuine data race — run it under the race detector (`go test -race`, `go run -race`) and it reports one. The fix is one of the two obvious shapes: ```go go check(h) // pass it go func(h string) { check(h) }(h) // or pass it into the literal ``` ## The receiver counts too ```go conn := dial(primary) go conn.ping() // conn is evaluated here conn = dial(backup) // ping still runs against the primary connection ``` The method value, including its receiver, is fixed at the `go` statement. If the receiver is a value type, the goroutine gets a copy of it; if it is a pointer, the goroutine holds that pointer and will see later mutations through it. ## Loop variables and Go 1.22 Historically the most famous version of this bug used a loop: ```go for _, h := range hosts { go func() { check(h) }() } ``` Before Go 1.22, `h` was **one variable reused by every iteration**, so all the goroutines closed over the same slot and typically saw the last hostname — or a mix, unpredictably. Since Go 1.22, each iteration of a `for` loop declares fresh loop variables, so each closure captures its own `h` and this exact loop is correct. The capture rule itself did not change: variables declared *outside* the loop, or reassigned inside it, are still shared, and a closure still reads them at execution time. Passing the value as an argument is the version-independent habit, and it reads unambiguously to reviewers who are not sure which Go version a file is built with. ## Why the compiler may move the variable to the heap A variable captured by a closure that a `go` statement starts can outlive the function that declared it, so the compiler cannot leave it on that function's stack. **Escape analysis** decides this at compile time; `go build -gcflags=-m` reports it, with lines such as `moved to heap: h` and `func literal escapes to heap`. That is not a bug to fix — it is the compiler doing what correctness requires — but it is a useful thing to see when you are explaining to someone why the closure form is not merely a style difference. Passing the value as an argument often avoids the capture entirely. ## The interview shape A common exercise: given a short program that starts a goroutine and then reassigns the variable, say what the goroutine observes. The answer is a two-step: *is the value an argument of the `go` statement, or is it captured by a literal?* Arguments are snapshots taken at the statement; captures are reads that happen later. Everything else — loop variables, receivers, method values — follows from that one distinction.

  • Rewrite go func() { check(h) }() so the goroutine cannot see a later value of h.
    Either drop the literal, `go check(h)`, or give the literal a parameter and pass the variable in: `go func(h string) { check(h) }(h)`. Both make `h` an argument of the `go` statement, so it is evaluated in the calling goroutine at that line and the new goroutine works on a copy.
  • Does the same rule apply to the receiver in go conn.ping()?
    Yes. The method value and its receiver are evaluated at the `go` statement, so reassigning `conn` afterwards does not redirect the call. Note the copy is of the receiver expression: with a pointer receiver the goroutine still sees mutations made through that pointer.
  • Why does go build -gcflags=-m report a variable captured by a goroutine's closure as moved to heap?
    Escape analysis cannot prove the variable's lifetime ends with the enclosing function, because the closure the `go` statement starts may still be running after that function returns. So the variable is heap-allocated and the closure references it there. Passing it as an argument often removes the capture.
  • Did Go 1.22 make closure capture safe in general?
    No. Go 1.22 gave each loop iteration its own loop variables, which fixes the classic `for` loop case. Any other variable a closure captures — one declared before the loop, or assigned inside it — is still shared and is still read at execution time, so it can still surprise you and still race.

saying these in an interview costs you the question

  • Thinks go f(x) reads x when the goroutine finally runs
  • Treats passing an argument and capturing a variable as identical
  • Claims Go 1.22 made all closure capture safe
  • Assumes the receiver in go conn.ping() is resolved later