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.

BirdNest Tech Talk
BirdNest Tech Talk
BirdNest Tech Talk
How Go’s New synctest Feature Eliminates Flaky Concurrent Tests

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 ?

synctest

is 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 -v

Output 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

synctest

is 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/

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

testingConcurrencyGoflaky-testssynctestdeterministic
BirdNest Tech Talk
Written by

BirdNest Tech Talk

Author of the rpcx microservice framework, original book author, and chair of Baidu's Go CMC committee.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.