JavaRush /Курсы /Go SELF /Тесты конкурентности: ожидание завершения goroutine

Тесты конкурентности: ожидание завершения goroutine

Go SELF
71 уровень , 2 лекция
Открыта

1. Почему тесты конкурентности любят зависать

Тесты конкурентности — это как проверка пожарной сигнализации: если она срабатывает только «когда повезёт», то это не сигнализация, а декоративный пластиковый круг на потолке. Конкурентный код ломается часто не «ошибкой компиляции», а тем, что одна goroutine ждёт другую, та ждёт третью, и в итоге все смотрят друг на друга, как в очереди в поликлинику.

Главная опасность здесь в том, что зависший тест выглядит как “просто долго выполняется”. CI может крутить его 10 минут, потом 30, потом вы в отчаянии нажимаете “Cancel job”… и никто не знает, было ли там падение, гонка, дедлок или просто у GitHub Actions настроение плохое.

Мини‑пример «как сделать зависон одной строчкой»

package conc

import (
	"testing"
)

func TestHangForever(t *testing.T) {
	ch := make(chan int)
	_ = <-ch // никто не пишет => тест зависнет навсегда
}

Этот тест не упадёт. Он не “fail”. Он просто никогда не закончится. И самое неприятное: иногда похожий тест может «случайно» завершиться у вас локально, а в CI зависнуть (из-за другого расписания goroutine).

2. Контракт теста конкурентности: “ждём, но ограниченно”

Если упростить до одной фразы, то хороший тест конкурентного кода — это тест, который умеет сказать: «Я подожду завершения, но не буду ждать вечность». Для этого нам нужен таймаут ожидания.

В Go классический способ — это select, который слушает либо “успех” (done‑сигнал), либо “не успели” (таймаут). Этот паттерн — часть базовой культуры конкурентного Go: таймаут реализуется каналом, по которому приходит сигнал позже, а select выбирает, что наступит раньше.

Done‑канал: самый простой сигнал «я закончил»

Сигнал “готово” обычно выражают как chan struct{}. Это канал без данных: нам важно не “что”, а “что факт произошёл”.

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 завершилась
	case <-time.After(100 * time.Millisecond):
		t.Fatalf("timeout: goroutine didn't finish")
	}
}

Обратите внимание на смысловую дисциплину: goroutine, которая выполняет работу, сама закрывает done. Тест — только ждёт. Это хороший “протокол”: тот, кто знает, когда всё закончено, тот и сигналит.

Почему time.Sleep — плохая «синхронизация»

Очень хочется написать так: «Запустил goroutine, подождал 50ms, проверил результат». Кажется логичным: “ну моя функция же быстрая”. Но это ловушка. В конкурентности “быстро” — это слово, которое гарантированно портит репутацию.

Проблема time.Sleep в тестах в том, что это не ожидание события, а ставка на тайминг. Если машина под нагрузкой, планировщик переключил goroutine позже, GC вмешался, CI перегрелся — и ваш тест становится flaky (то проходит, то падает).

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) // ставка на удачу
	if atomic.LoadInt32(&flag) != 1 {
		t.Fatalf("flag not set yet")
	}
}

Даже если вы увеличите sleep до 100ms, вы не сделаете тест правильным — вы просто сделаете его медленнее и всё равно не гарантируете стабильность.

Правильная мысль: вместо “подождать время” мы должны “подождать событие”.

3. Хелперы ожидания: done‑канал и WaitGroup с таймаутом

В реальных тестах вы не хотите копировать select { case <-done: ... case <-time.After(...): ... } десять раз подряд. Во‑первых, это шум. Во‑вторых, шум в конкурентных тестах маскирует реальную логику — а она и так нервная.

Сделаем маленький helper. Мы уже умеем t.Helper(), так что сообщение об ошибке будет указывать на строку вызова, а не на строку внутри хелпера.

Хелпер: “ждать, но не вечно” для done‑канала

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)
	}
}

И теперь тест читается человечески:

package conc

import (
	"testing"
	"time"
)

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

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

Это мелочь, но она очень влияет на качество тестов: меньше шума — легче увидеть, что именно мы проверяем.

WaitGroup в тестах: как “Wait”, но с таймаутом

sync.WaitGroup — отличный инструмент, когда у вас несколько goroutine и вы хотите дождаться их всех. Но у wg.Wait() есть особенность: он ждёт без таймаута. То есть, если ваши goroutine зависли — тест зависнет вместе с ними.

Поэтому в тестах обычно делают так: ждём wg.Wait() в отдельной goroutine, а тест ждёт уже done‑канал с таймаутом. Да, это чуть «матрёшечно», но это нормальная цена за гарантию завершения.

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)
	}
}

Смысл здесь простой: если ваш код сломался и wg.Done() где-то не вызвался — вы увидите контролируемое падение теста, а не вечную загрузку CPU и вашу печаль.

4. Контекст в тестах: “единый рубильник” для остановки работы

Таймаут через time.After хорош, когда тест “снаружи” просто ждёт событие. Но в серьёзном конкурентном коде часто важно не только “не ждать бесконечно”, а ещё и попросить код завершиться: отменить работу, остановить воркеры, закрыть обработку.

Вот тут контекст — король вечеринки. У context.Context есть Done() — канал, который закрывается при отмене/таймауте, и Err(), объясняющий причину. Это ровно тот механизм, которым Go “протаскивает” отмену через границы API и между goroutine.

Мини‑паттерн: тест задаёт таймаут контекстом

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: таймаут наступил
		if ctx.Err() == nil {
			t.Fatalf("expected ctx.Err() to be non-nil")
		}
	}
}

Здесь тест сам по себе ничего полезного не проверяет — это просто иллюстрация механики. Но важна мысль: контекст — это не “ещё один способ таймаута”, это способ сказать вашему конкурентному коду: “всё, заканчиваем”.

Табличка: time.After vs context.WithTimeout

Инструмент Когда удобнее Как выглядит ожидание Что происходит с тестируемым кодом
time.After(d) когда мы ждём событие “извне” и не управляем кодом select { case <-done: ... case <-time.After(d): ... } тест просто перестаёт ждать, код сам по себе может продолжить жить (если вы его не остановили)
<context.WithTimeout когда тестируемый код умеет завершаться по контексту select { case <-done: ... case <-ctx.Done(): ... }/td> тест ещё и сигналит отмену, и код должен корректно остановиться

Если ваш код уже принимает ctx, обычно выгоднее держать таймаут через контекст: и ожидание ограничили, и остановку запросили.

5. Мини‑пример: фоновой “сейвер” задач

Чтобы это не осталось набором абстрактных трюков, давайте соберём небольшую часть логики, похожую на реальный кусок приложения: есть “сохранение задачи”, но мы хотим делать его асинхронно (в фоне), а тест должен гарантировать, что всё завершается и не зависает.

Представим, что у нас есть Task и интерфейс Saver, который умеет сохранять задачу куда-то (в память, файл, сеть — неважно).

package taskapp

import "context"

type Task struct {
	ID    int
	Title string
}

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

Теперь — асинхронная обёртка: она принимает задачи через канал и сохраняет их в фоне.

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)
		}
	}
}

Код намеренно простой: мы не обсуждаем здесь архитектуру, буферы на 1000 задач и graceful shutdown всей системы. Нам сейчас важно тестировать конкурентную механику.

Тест: Run должен завершаться по отмене контекста

Сделаем фейковый saver, который просто считает вызовы.

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
}

Теперь сам тест с таймаутом и done‑каналом:

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")
	}
}

Здесь у нас два уровня защиты: мы просим остановиться (cancel()), и мы не ждём вечно (таймаут в тесте). Это базовый «скелет» для любых конкурентных тестов.

6. Каналы результатов и буфер: как не устроить утечку goroutine в тесте

В конкурентных тестах часто бывает так: вы запускаете goroutine, она должна отправить результат в канал, а тест ждёт этот результат. Если тест по таймауту выходит раньше, отправитель может навсегда зависнуть на ch <- result (если канал небуферизированный и получателя уже нет).

Это настолько частая грабля, что про неё говорят в классических материалах по конкурентности Go: таймаутный “сигнальный” канал удобно делать буферизированным, чтобы goroutine не зависла на отправке сигнала, даже если результат уже никому не нужен.

Пример: безопаснее делать канал результата буфером 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")
	}
}

Почему “1” — магическое число? Потому что нам часто нужен только первый результат. Буфер 1 гарантирует, что отправитель сможет завершиться независимо от того, успел ли тест начать чтение.

Если вам нужно несколько значений, то уже придётся думать о протоколе завершения аккуратнее (например, кто закрывает канал, кто и сколько читает), но идея та же: не оставляйте goroutine в состоянии “я пытаюсь отправить, но меня никто не слушает”.

7. Типичные ошибки

Ошибка №1: “таймаут есть, значит тест не зависнет” — но таймаут только в тесте, а goroutine остаётся жить.
Иногда тест делает time.After, падает по таймауту и завершает выполнение, но запущенная goroutine продолжает работать и держит ресурсы: блокируется на канале, крутит цикл, пишет в общий map. В больших пакетах это приводит к странным побочным эффектам: следующий тест падает, хотя “не должен”. Лечится это дисциплиной остановки: если возможно, тестируемый код должен принимать context и уважать ctx.Done(), а тест должен этот контекст отменять.

Ошибка №2: ожидание через time.Sleep вместо ожидания события.
time.Sleep в конкурентных тестах — это ставка на расписание goroutine, а расписание вам никто не обещал. Вчера работало, сегодня не работает, завтра будет “работать на моей машине”. Правильный подход — ждать событие через канал (done, result) или через WaitGroup, и всегда ограничивать ожидание таймаутом.

Ошибка №3: wg.Wait() напрямую в тесте без таймаута.
WaitGroup не виноват — виноваты мы. Если wg.Done() не случился из‑за бага, Wait() будет ждать до тепловой смерти Вселенной (или пока вы не убьёте процесс). В тестах оборачивайте wg.Wait() в отдельную goroutine и ждите done с таймаутом.

Ошибка №4: небуферизированный канал результата + таймаут в тесте = зависшая goroutine.
Тест ушёл по таймауту, а воркер пытается отправить результат — и завис. Иногда это незаметно, потому что процесс тестов всё равно завершится, но иногда это вызывает каскад проблем в следующих тестах. Часто помогает буфер 1 (если нужен один результат) или явный протокол “закрытие/отмена” через контекст. Идея о буферизации таймаутных сигналов и результатов напрямую связана с классическими паттернами select‑таймаутов в Go.

Ошибка №5: “поймаю всё recover‑ом”.
Это уже из другой оперы, но встречается: попытка спрятать проблему зависания/гонок через “глобальный try/catch”. В Go это не стиль, да и не работает как надо. Зависание — это не panic. Его надо решать протоколом завершения и таймаутом ожидания, а не магией.

1
Задача
Go SELF, 71 уровень, 2 лекция
Недоступна
Сигнал по таймеру
Сигнал по таймеру
1
Задача
Go SELF, 71 уровень, 2 лекция
Недоступна
Счётчик пульсов
Счётчик пульсов
1
Задача
Go SELF, 71 уровень, 2 лекция
Недоступна
Отправка с отменой
Отправка с отменой
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ