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. Его надо решать протоколом завершения и таймаутом ожидания, а не магией.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ