How Go’s New synctest Feature Eliminates Flaky Concurrent Tests
This article explains why Go tests that rely on goroutine scheduling are flaky, demonstrates the problem with concrete code examples, and shows how the experimental synctest feature provides deterministic execution by controlling synthetic time, coordinating goroutine lifecycles, and offering a reliable Wait primitive.
Flaky tests in Go often arise when concurrent code synchronises using real‑time functions such as time.Sleep. The scheduler’s timing and system load are nondeterministic, so a test that appears to wait long enough may still fail, producing outputs like shared = 0, want 2 or shared = 1, want 2.
func TestSharedValue(t *testing.T) {
var shared atomic.Int64
go func() {
shared.Store(1)
time.Sleep(1 * time.Microsecond)
shared.Store(2)
}()
// 5 µs later check the shared value
time.Sleep(5 * time.Microsecond)
if shared.Load() != 2 {
t.Errorf("shared = %d, want 2", shared.Load())
}
}Running this test thousands of times shows intermittent failures because the goroutine may not have completed before the check.
What is synctest ?
synctestis a Go 1.24 experiment that runs goroutines inside an isolated “bubble” with a synthetic clock, making concurrent tests deterministic.
Without synctest
func TestTimingWithoutSynctest(t *testing.T) {
start := time.Now().UTC()
time.Sleep(5 * time.Second)
t.Log(time.Since(start))
}Typical output ranges from 5.329s to 5.456s because the real scheduler and system load affect the sleep duration.
With synctest
import "testing/synctest"
func TestTimingWithSynctest(t *testing.T) {
synctest.Run(func() {
start := time.Now().UTC()
time.Sleep(5 * time.Second)
t.Log(time.Since(start))
})
}Run the test with the experiment flag:
GOEXPERIMENT=synctest go test -run TestTimingWithSynctest -vOutput is always exactly 5s and the test finishes in 0.00s because time.Sleep returns immediately and the synthetic clock jumps forward instantly.
Wait mechanism
synctest.Wait()blocks until every other goroutine in the same bubble has either completed or is permanently blocked.
synctest.Run(func() {
ctx, cancel := context.WithCancel(context.Background())
afterFuncCalled := false
context.AfterFunc(ctx, func() { afterFuncCalled = true })
cancel()
synctest.Wait()
fmt.Printf("after context is canceled: afterFuncCalled=%v
", afterFuncCalled)
})The root goroutine tracks all bubble goroutines, guaranteeing that when Wait() returns the asynchronous callbacks have reached a stable state.
How synctest works
A bubble is an isolated execution environment with its own synthetic clock that starts at the epoch 2000‑01‑01 00:00:00 UTC. Time only moves forward when every goroutine inside the bubble is blocked (e.g., on time.Sleep, channel receive, mutex, etc.).
func TestSyntheticClock(t *testing.T) {
synctest.Run(func() {
t.Log(time.Now().UTC()) // → 2000-01-01 00:00:00 +0000 UTC
})
}If a goroutine sleeps for 5 seconds while all others are blocked, the synthetic clock jumps forward by 5 seconds instantly, allowing the sleeping goroutine to resume without real waiting.
Goroutine coordination
The goroutine that calls synctest.Run(f) becomes the bubble’s root. It launches f in a new goroutine, then enters a loop that manages synthetic time and schedules other bubble goroutines.
func (sg *synctestGroup) maybeWakeLocked() *g {
if sg.running > 0 || sg.active > 0 {
return nil
}
sg.active++
if gp := sg.waiter; gp != nil {
return gp
}
return sg.root
}The root locates the next scheduled timer event (from time.Sleep, time.Timer, time.Ticker, or time.AfterFunc), sets sg.now = next, and yields to the scheduler to run the appropriate goroutine.
Goroutine blocking states
External blocking – waiting on real I/O, network, or events outside the bubble (e.g., file reads, socket reads). These are considered running because their progress depends on the external world.
Persistent blocking – waiting on constructs fully controlled by the bubble such as time.Sleep(), sync.Cond.Wait(), sync.WaitGroup.Wait(), nil‑channel operations, select on bubble channels, or sends/receives on bubble‑created channels.
If any goroutine is externally blocked, the synthetic clock does not advance, and persistently blocked goroutines remain paused.
Decision logic when all goroutines are blocked
When no goroutine is running and all active ones are persistently blocked, the bubble either wakes a goroutine waiting on synctest.Wait() or continues the root goroutine. The root’s algorithm determines the next timer event and jumps synthetic time accordingly (see maybeWakeLocked above).
Limitations
synctestis designed for testing the timing and correctness of synchronisation logic, not for fully emulating real‑world timing. Misuse may hide bugs that only appear under actual time constraints.
Concrete fix for the flaky example
func TestSharedValue(t *testing.T) {
synctest.Run(func() {
var shared atomic.Int64
go func() {
shared.Store(1)
time.Sleep(1 * time.Microsecond)
shared.Store(2)
}()
time.Sleep(5 * time.Microsecond)
if shared.Load() != 2 {
t.Errorf("shared = %d, want 2", shared.Load())
}
})
}Because the synthetic clock is frozen until the goroutine blocks, the 5 µs sleep is simulated, guaranteeing that the goroutine has stored 2 before the check. The test now passes deterministically.
References
Go synctest: Solving Flaky Tests – https://victoriametrics.com/blog/go-synctest/
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
BirdNest Tech Talk
Author of the rpcx microservice framework, original book author, and chair of Baidu's Go CMC committee.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
