JavaRush /Courses /Go SELF /Concurrency tests: waiting for a goroutine to finish

Concurrency tests: waiting for a goroutine to finish

Go SELF
Level 71, Lesson 2
Available

1. Why concurrency tests love to hang

Concurrency tests are like checking a fire alarm: if it only goes off “when you get lucky”, it’s not an alarm, it’s a decorative plastic circle on the ceiling. Concurrent code often breaks not with a “compile error”, but because one goroutine waits for another, that one waits for a third, and in the end they all stare at each other—like a line at a clinic.

The main danger here is that a hung test looks like “it’s just taking a long time”. CI may spin it for 10 minutes, then 30, then in despair you click “Cancel job”… and nobody knows whether there was a crash, a race, a deadlock, or GitHub Actions was just in a bad mood.

Mini example: “how to create a hang with one line”

package conc

import (
	"testing"
)

func TestHangForever(t *testing.T) {
	ch := make(chan int)
	_ = <-ch // nobody writes => the test will hang forever
}

This test won’t fail. It won’t “fail”. It will just never finish. And the worst part: sometimes a similar test can “accidentally” finish locally, but hang in CI (due to different goroutine scheduling).

2. The concurrency-test contract: “wait, but with a limit”

If we simplify it to one sentence, a good concurrent-code test is a test that can say: “I’ll wait for completion, but I won’t wait forever.” For that, we need a waiting timeout.

In Go, the classic approach is select, which listens for either “success” (a done signal) or “too late” (a timeout). This pattern is part of the basic culture of concurrent Go: the timeout is implemented by a channel that delivers a signal later, and select chooses what happens first.

Done channel: the simplest “I’m finished” signal

A “done” signal is typically expressed as chan struct{}. It’s a channel with no data: what matters is not “what”, but “that the fact happened”.

package conc

import (
	"testing"
	"time"
)

func TestDoneWithTimeout(t *testing.T) {
	done := make(chan struct{})

	go func() {
		defer close(done)
		time.Sleep(10 * time.Millisecond)
	}()

	select {
	case <-done:
		// ok: goroutine finished
	case <-time.After(100 * time.Millisecond):
		t.Fatalf("timeout: goroutine didn't finish")
	}
}

Note the semantic discipline: the goroutine that does the work closes done itself. The test only waits. That’s a good “protocol”: whoever knows when everything is done is the one who signals it.

Why time.Sleep is bad “synchronization”

It’s very tempting to write: “Start a goroutine, wait 50ms, check the result.” It seems logical: “my function is fast.” But that’s a trap. In concurrency, “fast” is the word that reliably ruins reputations.

The problem with time.Sleep in tests is that it’s not waiting for an event—it’s betting on timing. If the machine is under load, the scheduler runs the goroutine later, GC steps in, CI is overheating—your test becomes flaky (sometimes passes, sometimes fails).

package conc

import (
	"sync/atomic"
	"testing"
	"time"
)

func TestSleepIsNotSync(t *testing.T) {
	var flag int32

	go func() {
		atomic.StoreInt32(&flag, 1)
	}()

	time.Sleep(1 * time.Millisecond) // betting on luck
	if atomic.LoadInt32(&flag) != 1 {
		t.Fatalf("flag not set yet")
	}
}

Even if you increase sleep to 100ms, you won’t make the test correct—you’ll just make it slower, and still won’t guarantee stability.

The right idea: instead of “wait some time” we must “wait for an event”.

3. Waiting helpers: done channel and WaitGroup with a timeout

In real tests you don’t want to copy select { case <-done: ... case <-time.After(...): ... } ten times in a row. First, it’s noise. Second, noise in concurrency tests masks the real logic—and it’s already tense enough.

Let’s make a small helper. We already know t.Helper(), so the error message will point to the call site line, not the line inside the helper.

Helper: “wait, but not forever” for a done channel

package conc

import (
	"testing"
	"time"
)

func waitDone(t *testing.T, done <-chan struct{}, timeout time.Duration) {
	t.Helper()

	select {
	case <-done:
		return
	case <-time.After(timeout):
		t.Fatalf("timeout after %s", timeout)
	}
}

And now the test reads like a human wrote it:

package conc

import (
	"testing"
	"time"
)

func TestSomething(t *testing.T) {
	done := make(chan struct{})
	go func() { close(done) }()

	waitDone(t, done, 100*time.Millisecond)
}

It’s a small thing, but it strongly affects test quality: less noise means it’s easier to see what exactly we’re checking.

WaitGroup in tests: like “Wait”, but with a timeout

sync.WaitGroup is a great tool when you have multiple goroutines and want to wait for all of them. But wg.Wait() has a feature: it waits with no timeout. That is, if your goroutines hang, the test hangs with them.

So in tests people usually do this: wait for wg.Wait() in a separate goroutine, and the test waits on a done channel with a timeout. Yes, it’s a bit like nesting dolls, but it’s a normal price for a completion guarantee.

package conc

import (
	"sync"
	"testing"
	"time"
)

func waitWG(t *testing.T, wg *sync.WaitGroup, timeout time.Duration) {
	t.Helper()

	done := make(chan struct{})
	go func() {
		defer close(done)
		wg.Wait()
	}()

	select {
	case <-done:
		return
	case <-time.After(timeout):
		t.Fatalf("timeout waiting WaitGroup after %s", timeout)
	}
}

The idea is simple: if your code is broken and wg.Done() wasn’t called somewhere, you’ll get a controlled test failure, not an endless CPU load and your sadness.

4. Context in tests: a “single kill switch” for stopping work

A timeout via time.After is good when the test “from the outside” just waits for an event. But in serious concurrent code it’s often important not only to “not wait forever”, but also to ask the code to stop: cancel work, stop workers, shut down processing.

That’s where context is king. context.Context has Done()—a channel that is closed on cancel/timeout—and Err(), explaining the reason. This is exactly the mechanism Go uses to “thread” cancellation across API boundaries and between goroutines.

Mini pattern: the test sets a timeout via context

package conc

import (
	"context"
	"testing"
	"time"
)

func TestWithContextTimeout(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()

	select {
	case <-ctx.Done():
		// ok: the timeout fired
		if ctx.Err() == nil {
			t.Fatalf("expected ctx.Err() to be non-nil")
		}
	}
}

Here the test itself doesn’t check anything useful—it’s just an illustration of the mechanics. But the key idea matters: context is not “just another timeout method”, it’s a way to tell your concurrent code: “that’s it, we’re done.”

Table: time.After vs context.WithTimeout

Tool When it’s more convenient What the wait looks like What happens to the code under test
time.After(d)
when we wait for an event “from the outside” and don’t control the code
select { case <-done: ... case <-time.After(d): ... }
the test just stops waiting; the code may keep running on its own (if you didn’t stop it)
context.WithTimeout
when the code under test knows how to stop based on context
select { case <-done: ... case <-ctx.Done(): ... }
the test also signals cancellation, and the code should stop correctly

If your code already accepts ctx, it’s usually better to keep the timeout in the context: you both limit waiting and request shutdown.

5. Mini example: a background task “saver”

So this doesn’t remain a set of abstract tricks, let’s assemble a small piece of logic that looks like a real application component: there is “task saving”, but we want to do it asynchronously (in the background), and the test must guarantee everything finishes and doesn’t hang.

Let’s say we have a Task and a Saver interface that can save a task somewhere (memory, file, network—doesn’t matter).

package taskapp

import "context"

type Task struct {
	ID    int
	Title string
}

type Saver interface {
	Save(ctx context.Context, t Task) error
}

Now, an async wrapper: it receives tasks via a channel and saves them in the background.

package taskapp

import (
	"context"
)

type AsyncSaver struct {
	s Saver
	in chan Task
}

func NewAsyncSaver(s Saver) *AsyncSaver {
	return &AsyncSaver{s: s, in: make(chan Task, 1)}
}

func (a *AsyncSaver) Enqueue(t Task) {
	a.in <- t
}

func (a *AsyncSaver) Run(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			return
		case t := <-a.in:
			_ = a.s.Save(ctx, t)
		}
	}
}

The code is intentionally simple: we’re not discussing architecture, 1000-task buffers, or graceful shutdown of the whole system. Right now we care about testing the concurrency mechanics.

Test: Run must stop when the context is canceled

Let’s make a fake saver that just counts calls.

package taskapp

import (
	"context"
	"sync/atomic"
)

type CountingSaver struct {
	n int32
}

func (s *CountingSaver) Save(ctx context.Context, t Task) error {
	atomic.AddInt32(&s.n, 1)
	return nil
}

Now the test itself with a timeout and a done channel:

package taskapp

import (
	"context"
	"testing"
	"time"
)

func TestAsyncSaverStopsOnCancel(t *testing.T) {
	s := &CountingSaver{}
	a := NewAsyncSaver(s)

	ctx, cancel := context.WithCancel(context.Background())
	done := make(chan struct{})

	go func() {
		defer close(done)
		a.Run(ctx)
	}()

	a.Enqueue(Task{ID: 1, Title: "learn Go"})
	cancel()

	select {
	case <-done:
		// ok
	case <-time.After(100 * time.Millisecond):
		t.Fatalf("timeout: AsyncSaver.Run didn't stop")
	}
}

Here we have two layers of protection: we ask it to stop (cancel()), and we don’t wait forever (a timeout in the test). This is the basic “skeleton” for any concurrency tests.

6. Result channels and buffering: how not to leak a goroutine in a test

In concurrency tests, this often happens: you start a goroutine, it should send a result into a channel, and the test waits for that result. If the test exits earlier due to a timeout, the sender can hang forever on ch <- result (if the channel is unbuffered and there is no receiver anymore).

This pitfall is so common that it’s mentioned in classic Go concurrency materials: it’s convenient to make timeout “signal” channels buffered so a goroutine won’t hang while sending a signal, even if the result is no longer needed by anyone.

Example: it’s safer to make a result channel buffered with size 1

package conc

import (
	"testing"
	"time"
)

func TestResultChannelBuffered(t *testing.T) {
	result := make(chan int, 1)

	go func() {
		result <- 42
	}()

	select {
	case v := <-result:
		if v != 42 {
			t.Fatalf("v=%d, want 42", v)
		}
	case <-time.After(100 * time.Millisecond):
		t.Fatalf("timeout waiting result")
	}
}

Why is “1” a magic number? Because we often only need the first result. A buffer of 1 guarantees the sender can finish regardless of whether the test has started reading yet.

If you need multiple values, you’ll have to think about the completion protocol more carefully (for example, who closes the channel, who reads and how many), but the idea is the same: don’t leave a goroutine in the state “I’m trying to send, but nobody is listening”.

7. Common mistakes

Mistake No. 1: “there’s a timeout, so the test won’t hang”—but the timeout is only in the test, and the goroutine keeps living.
Sometimes a test uses time.After, fails on timeout and finishes execution, but the started goroutine keeps running and holding resources: it blocks on a channel, spins in a loop, writes to a shared map. In large packages this leads to weird side effects: the next test fails even though it “shouldn’t”. The cure is shutdown discipline: if possible, the code under test should accept context and respect ctx.Done(), and the test should cancel that context.

Mistake No. 2: waiting via time.Sleep instead of waiting for an event.
time.Sleep in concurrency tests is betting on goroutine scheduling, and nobody promised you any schedule. It worked yesterday, doesn’t work today, and tomorrow it will “work on my machine”. The correct approach is to wait for an event via a channel (done, result) or via WaitGroup, and always bound the wait with a timeout.

Mistake No. 3: calling wg.Wait() directly in a test without a timeout.
WaitGroup isn’t at fault—we are. If wg.Done() didn’t happen due to a bug, Wait() will wait until the heat death of the universe (or until you kill the process). In tests, wrap wg.Wait() in a separate goroutine and wait on done with a timeout.

Mistake No. 4: unbuffered result channel + timeout in the test = a stuck goroutine.
The test timed out and exited, but the worker is trying to send the result—and hangs. Sometimes this is invisible because the test process still exits, but sometimes it triggers a cascade of problems in subsequent tests. A buffer of 1 often helps (if you need one result), or an explicit “close/cancel” protocol via context. The idea of buffering timeout signals and results is directly related to classic Go select-timeout patterns.

Mistake No. 5: “I’ll catch everything with recover.”
This is from a different opera, but it happens: trying to hide hanging/race issues with a “global try/catch”. In Go, that’s not the style, and it doesn’t work the way you want. A hang is not a panic. You solve it with a shutdown protocol and a waiting timeout, not with magic.

1
Task
Go SELF, level 71, lesson 2
Locked
Timer Signal
Timer Signal
1
Task
Go SELF, level 71, lesson 2
Locked
Pulse counter
Pulse counter
1
Task
Go SELF, level 71, lesson 2
Locked
Send with cancellation
Send with cancellation
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION