Testing concurrent code with synctest

Go 1.24’s synctest makes testing goroutines easy. No more sleep hacks—just reliable concurrency tests

Warm-up Time

  1. 📺️ Go 1.24 Just RELEASED – Weak Pointers, Generic Aliases & More! — 5 minutes video breaking down the Go 1.24 key changes including Generic Type Aliases, Weak Pointers, AddCleanup, Swiss Table and few more changes.

  2. 🛠️ 11 tips for structuring your Go projects — How you should structure your Go project? Well, there’s no right answer for that and it always depends. Although, Alex wrote a good article sharing some tips on finding/scafolding what works for you the best.

  3. 🤖 Building RAG systems in Go with Ent, Atlas, and pgvector —A hands-on blog post about building a RAG (Retrieval-Augmented Generation) system using Ent, Atlas, and pgvector. RAG enables context-aware prompting by retrieving relevant data. This post is a great starting point for becoming familiar with Ent and pgvector.

  4. 📦 How Protobuf Works—The Art of Data Encoding — Protobuf is an efficient, language-agnostic data serialisation mechanism. It produces much smaller output with the cost of not being human-readable. It has 4 wire types (actually 5, but one of them is deprecated) to encode data. This post by VictoriaMetrics is a great deep dive into understanding on-wire format of Protobuf.

  5. 🔍 Map internals in Go 1.24 — Go Map has gone through a complete rewrite and the new version is based on Swiss Table. This post describes how Swiss Table works under the hood. (If you like me to write a deep dive about how it used to work and compare it to the new Go, reply Swiss Table)

Tool Time

  1. 🚀 hugocarreira/easycache — High-performance, in-memory caching library for Go, supporting multiple eviction policies like FIFO, LRU, LFU, and TTL-based expiration. It is thread-safe, lightweight, and provides built-in metrics.

  2. 🌐 yaitoo/xun — Xun (pronounced 'shoon') is a web framework built on Go's built-in html/template and net/http package’s router.

  3. 🔄 pancsta/asyncmachine-go — Declarative control flow library. The main purpose is workflow, but it can also be used for developing other stateful applications.

  4. 📚️ ent/ent — A simple, yet powerful entity framework (Kinda like ORM) for Go. It let you to model and query database as a graph structure.

  5. cenkalti/rain — BitTorrent client and library in Go

  6. 🔍 open-telemetry/opentelemetry-go-instrumentation — Provides OpenTelemetry tracing instrumentation for Go libraries using eBPF.
    (I did a deep dive on compile-time instrumentation—check it out!)

Social Media Marketing GIF by GrowthX

And now from our sponsor, Superhuman AI!

Start learning AI in 2025

Keeping up with AI is hard – we get it!

That’s why over 1M professionals read Superhuman AI to stay ahead.

  • Get daily AI news, tools, and tutorials

  • Learn new AI skills you can use at work in 3 mins a day

  • Become 10X more productive

Deep Dive

Go 1.24 comes with an experimental synctest package to support testing concurrent code. If you’re dealing with goroutines and timing issues in your tests, this package might just be what you need. As it’s experimental, you need to compile your code by GOEXPERIMENT=synctest.

The package is simple, only two methods, synctest.Run and synctest.Wait.

  • Run: This wraps your test code in a special “bubble” – an isolated zone where all the goroutines you start are managed together. The bubble ends when every goroutine inside it exits. If something gets stuck (that is, durably blocked), Run will panic.

  • Wait: This function pauses the test until every goroutine in the bubble is blocked. It’s like saying, “Hold on until all my concurrent operations have either finished or are waiting for something else.”

Let’s See It in Action

  • Testing a Function Called After a Context Cancellation

Imagine you have a function that should only be called after a context is cancelled. Without synctest, you might need to use channels and timers to check this. Here’s how it looks with synctest:

func TestAfterFunc(t *testing.T) {
    synctest.Run(func() {
        ctx, cancel := context.WithCancel(context.Background())
        funcCalled := false

        context.AfterFunc(ctx, func() {
            funcCalled = true
        })

        // Wait for all goroutines to be blocked
        synctest.Wait()
        if funcCalled {
            t.Fatalf("AfterFunc function called before context is canceled")
        }

        cancel()

        synctest.Wait()
        if !funcCalled {
            t.Fatalf("AfterFunc function not called after context is canceled")
        }
    })
}

Here, synctest.Wait makes sure we know exactly when the AfterFunc goroutine has either run or isn’t going to run until we cancel the context. No guessing games!

  • Testing Code That Depends on Time

Testing time-dependent behaviour (like timeouts) can be a real pain. With synctest, you get a fake clock inside the bubble. Here’s an example that tests a context timeout:

func TestWithTimeout(t *testing.T) {
    synctest.Run(func() {
        const timeout = 5 * time.Second
        ctx, cancel := context.WithTimeout(context.Background(), timeout)
        defer cancel()

        // Wait just a bit before the timeout.
        time.Sleep(timeout - time.Nanosecond)
        synctest.Wait()
        if err := ctx.Err(); err != nil {
            t.Fatalf("before timeout, ctx.Err() = %v; want nil", err)
        }

        // Now wait until the timeout passes.
        time.Sleep(time.Nanosecond)
        synctest.Wait()
        if err := ctx.Err(); err != context.DeadlineExceeded {
            t.Fatalf("after timeout, ctx.Err() = %v; want DeadlineExceeded", err)
        }
    })
}

By calling time.Sleep and then synctest.Wait, you control the fake clock, ensuring the context expires exactly when you expect.

Durably Blocked – What’s the Deal?

Imagine you’re in a room full of people (or goroutines), and everyone is waiting on someone else inside the room to give them a signal. When that happens, we say the room (or "bubble") is durably blocked. In simple terms, it means every goroutine in your test is paused, and only something inside the bubble can wake them up.

If even one goroutine might get a nudge from outside the bubble—like a friend getting a text message—the whole group isn’t truly stuck.

Operations that durably block a goroutine:

  • A send or receive on a nil channel

  • A send or receive blocked on a channel created within the same bubble

  • A select statement where every case is durably blocking

  • time.Sleep

  • sync.Cond.Wait

  • sync.WaitGroup.Wait

Also, pay attention to the following:

  • Mutexes: Operations on a sync.Mutex are not durably blocking.

  • Channels: Channels created within a bubble behave differently from those created outside. Channel operations are durably blocking only if the channel is bubbled (created in the bubble). Operating on a bubbled channel from outside the bubble panics.

  • I/O: External I/O operations, such as reading from a network connection, are not durably blocking.

  • Bubble lifetime: The Run function starts a goroutine in a new bubble. It returns when every goroutine in the bubble has exited. It panics if the bubble is durably blocked and cannot be unblocked by advancing time.

If you gave it a try, share your feedback at issue#67434.

How did you like this edition?

Or hit reply and share your thoughts!

Login or Subscribe to participate in polls.

Enjoyed reading? Pass it along to someone else who might! Sharing is caring.

Seth Meyers Thank You GIF by Late Night with Seth Meyers

Reply

or to participate.