Skip to content
gohacks.dev
Concurrency

Modernize Old Go Concurrency Code for Newer Releases

Update old Go concurrency idioms to current ones, with before and after code that gives the same result: WaitGroup.Go, typed atomics, OnceValue.

7 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

Go's concurrency toolbox has been quietly improving for years, and code written in the first half of the language's life still compiles and still works. That is a strength of the compatibility promise and a small tax on readers: the old idioms are longer and easier to get wrong than their replacements. This article is a migration guide. Each section shows an old pattern, its modern form, the Go version that made it possible, and what to check. The two are run side by side where it is practical, to show they produce the same result.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

It relates to the loop variable change, WaitGroup.Go, typed atomics, and belongs to the concurrency patterns cluster. Everything ran on Go 1.25.6.

First: the go line

Several of these changes are gated by the go directive in go.mod, not by the toolchain you build with. A Go 1.25 compiler building a module that says go 1.21 still uses the 1.21 loop variable semantics. So the first step in a modernization is deciding the language version to move to, reading the release notes between the old and the new, running the tests, and only then editing go.mod (go mod edit -go=1.25 or go get [email protected]). Everything below assumes the module is on a version that supports the new form.

WaitGroup: Add, go, Done becomes wg.Go (Go 1.25)

The classic three-line shape has a well-known failure: Add placed inside the goroutine (the mistake). The method added in Go 1.25 does the Add, starts the goroutine, and calls Done when the function returns:

old.goGo
package main

import (
	"sync"
	"sync/atomic"
)

func oldWaitGroup() int64 {
	var wg sync.WaitGroup
	var total int64
	for i := 1; i <= 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			atomic.AddInt64(&total, int64(i)) // old: function form on a plain int64
		}()
	}
	wg.Wait()
	return atomic.LoadInt64(&total)
}
new.goGo
package main

import (
	"sync"
	"sync/atomic"
)

func newWaitGroup() int64 {
	var wg sync.WaitGroup
	var total atomic.Int64 // typed atomic: only atomic access is possible
	for i := range 100 {   // range over an integer (Go 1.22)
		wg.Go(func() { // Add, go and defer Done in one call (Go 1.25)
			total.Add(int64(i + 1))
		})
	}
	wg.Wait()
	return total.Load()
}

Three modernizations are in that pair. wg.Go removes the Add/go/defer Done boilerplate. atomic.Int64 replaces the int64 and the function calls, with the benefit that a later edit cannot read total non-atomically by mistake, because the field has no plain access. And for i := range 100 replaces the three-clause loop for a simple count. Remember that wg.Go takes a function with no result: if the work can fail, you still need an error channel or errgroup.

Loop variables: delete the copies (Go 1.22)

Go
for _, item := range items {
	item := item // old: copy so each goroutine sees its own item
	go process(item)
}

With go 1.22 or later in go.mod the copy is unnecessary, since each iteration already has its own variable. Remove x := x lines in the loops that start goroutines or capture closures. The linter copyloopvar finds them. Keep them only in modules that still declare an older version.

Atomics: functions become types (Go 1.19)

The function forms (atomic.AddInt64, LoadInt32, StorePointer) work on ordinary variables, so nothing prevents a plain read of the same variable elsewhere, which is a data race the compiler cannot see. The typed forms (atomic.Int32, Int64, Uint32, Uint64, Bool, Pointer[T]) hold the value privately. The migration is mechanical: change the field type, replace atomic.AddInt64(&s.n, 1) with s.n.Add(1), LoadInt64(&s.n) with s.n.Load(), and so on. Typed atomics also guarantee 64-bit alignment on 32-bit platforms, which the function forms did not, and which caused crashes in code that put an int64 after a smaller field. The replacement for a mutex around a single value is the subject of typed atomics for mutex-guarded values.

sync.Once plus a variable plus a getter becomes OnceValue (Go 1.21)

Go
var (
	cfgOnce sync.Once
	cfg     *Config
)

func Get() *Config {
	cfgOnce.Do(func() { cfg = load() })
	return cfg
}

Three package-level names for one lazy value. The modern form is one:

Go
var Get = sync.OnceValue(load) // load() runs on the first call; everyone sees its result

sync.OnceValues handles functions that return a value and an error, and sync.OnceFunc handles one-time side effects. One behavior to know: if the function panics, OnceFunc and its relatives panic with the same value on every call, instead of treating the panicking call as done (sync.Once).

Signals: channel and goroutine become NotifyContext (Go 1.16)

Go
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
ctx, cancel := context.WithCancel(context.Background())
go func() { <-sigs; cancel() }()

becomes

Go
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

Same behavior, no goroutine to leak and no buffered channel rule to remember (NotifyContext). Keep the channel form only when you need to keep receiving the same signal, as with SIGHUP reloads.

Cancellation: add a cause (Go 1.20) and cleanup hooks (Go 1.21)

context.WithCancelCause lets the canceller say why, and context.Cause(ctx) reads it, so the error that reaches a log is "reload requested" and not just "context canceled" (context.Cause). context.AfterFunc(ctx, f) runs f when a context ends and replaces the goroutine that blocked on <-ctx.Done() only to call a cleanup function. context.WithoutCancel replaces hand-written detached contexts for cleanup work (AfterFunc and WithoutCancel).

Timers: the drain dance goes away (Go 1.23)

Before 1.23, if !t.Stop() { <-t.C } before every Reset was mandatory, and forgetting it produced stale ticks. With go 1.23 in go.mod, timer channels are unbuffered and Stop and Reset guarantee no stale values, so the drain can be deleted, and unreferenced timers are collected without being stopped (the timer changes). The time.After-in-a-loop leak is gone with it, though reusing one timer still saves allocations in hot paths.

Tests: sleeps become synctest, b.N loops become b.Loop

A test that sleeps to let a goroutine run can be rewritten with testing/synctest (Go 1.25), which runs the code in a bubble with a fake clock and a Wait that returns when everything is idle (synctest). A benchmark written as for i := 0; i < b.N; i++ { ... } becomes for b.Loop() { ... } (Go 1.24), which keeps the compiler from optimizing the body away and excludes setup from the timing without b.ResetTimer.

Proving the pair equivalent

The cheap check of a mechanical rewrite is to run both versions and compare. This program does that for the pair above, and also calls both under many iterations to give the race detector something to see:

main.goGo
package main

import "fmt"

func main() {
	const want = 5050 // 1 + 2 + ... + 100
	oldTotal, newTotal := oldWaitGroup(), newWaitGroup()
	fmt.Println("old:", oldTotal, "new:", newTotal, "equal:", oldTotal == newTotal && newTotal == want)

	same := true
	for range 200 {
		if oldWaitGroup() != want || newWaitGroup() != want {
			same = false
		}
	}
	fmt.Println("200 repeated runs all agree:", same)
}
Text
old: 5050 new: 5050 equal: true
200 repeated runs all agree: true

Both give 5,050, every time. That is the standard of evidence for a modernization: same output, and clean under go vet ./... and go test -race ./... afterwards. Do the rewrites in small commits by kind (all wg.Go conversions together, all atomic conversions together) so a regression is easy to bisect.

A plan for a real codebase

Modernizing in bulk is a project, and the order matters more than the individual edits. Start by raising the go directive one release at a time (1.21, then 1.22, then 1.23) and running the suite under -race at each step, since each version changes semantics that your tests may have been depending on. Land the mechanical rewrites in separate pull requests by kind, so a reviewer can read thirty identical wg.Go conversions in one sitting and trust them. Leave design changes out of the same commits: switching a mutex to an atomic is a modernization only when the code is truly guarding a single value, and otherwise it is a refactor that deserves its own review. Finally, add a lint or vet rule that keeps the old form from coming back. A migration without a guard slips back within a year, as new contributors copy the pattern they see in older files.

Tools that help

go vet reports several of the old mistakes directly. The gopls language server ships a set of "modernize" analyzers that suggest and apply many of the rewrites above as code actions in the editor; I have not run them in this article, so check the gopls release notes for the version you use. Linters such as copyloopvar find redundant loop copies. For the typed-atomic migration, searching for calls such as atomic.AddInt64 finds the sites, and the compiler finds the rest once you change the field type.

The release notes are the authority for each change: Go 1.19 for typed atomics, Go 1.21 for OnceFunc and AfterFunc, Go 1.22 for loop variables, Go 1.23 for timers and Go 1.25 for WaitGroup.Go and synctest.

Taggedmigration
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.