Команда Go for Devs подготовила перевод статьи о пакете synctest
, который с Go 1.25 стал частью стандартной библиотеки. Он позволяет писать быстрые и надёжные тесты для конкурентного кода, не усложняя сам код. Теперь асинхронные операции можно проверять без долгих ожиданий и флаки-тестов.
В Go 1.24 мы представили пакет testing/synctest
как экспериментальный. Этот пакет может заметно упростить написание тестов для конкурентного и асинхронного кода. В Go 1.25 пакет testing/synctest
вышел из стадии эксперимента и стал общедоступным.
Ниже — блог-версия моего доклада о пакете testing/synctest
на конференции GopherCon Europe 2025 в Берлине.
Что такое асинхронная функция?
Синхронная функция устроена довольно просто. Вы её вызываете — она что-то делает и возвращает результат.
Асинхронная функция работает иначе. Вы её вызываете — она сразу возвращает управление, а работу выполняет потом.
Вот конкретный, пусть и немного искусственный пример. Функция Cleanup
— синхронная: вы её вызываете, она удаляет директорию кэша и завершает выполнение.
func (c *Cache) Cleanup() {
os.RemoveAll(c.cacheDir)
}
А вот CleanupInBackground
— асинхронная. Вы её вызываете, она тут же возвращает управление, а удаление директории кэша произойдёт… рано или поздно.
func (c *Cache) CleanupInBackground() {
go os.RemoveAll(c.cacheDir)
}
Иногда асинхронная функция выполняет действие в будущем. Например, функция WithDeadline
из пакета context
возвращает контекст, который будет отменён в определённый момент времени.
package context
// WithDeadline возвращает производный контекст
// со сроком действия не позднее d.
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
Когда я говорю о тестировании конкурентного кода, я имею в виду именно такие асинхронные операции — как зависящие от реального времени, так и те, что его не используют.
Тесты
Тест проверяет, что система ведёт себя так, как мы ожидаем. Существует множество терминов для описания видов тестирования — модульные тесты, интеграционные и так далее, — но в конечном счёте любой тест сводится к трём шагам:
Задать начальные условия.
Попросить систему выполнить какое-то действие.
Проверить результат.
Тестировать синхронную функцию просто:
Вы вызываете функцию;
функция что-то делает и возвращает результат;
вы проверяете результат.
С асинхронными функциями всё сложнее:
Вы вызываете функцию;
она возвращает управление;
вы ждёте, пока она выполнит своё действие;
вы проверяете результат.
Если подождать недостаточно или слишком долго, можно попасть в ситуацию, когда проверка выполняется над результатом операции, которая ещё не завершилась или завершилась лишь частично. Хорошего из этого не выйдет.
Особенно непросто тестировать асинхронные функции, когда нужно убедиться, что что-то не произошло. Вы можете проверить, что событие пока не наступило, но как с уверенностью доказать, что оно не произойдёт позже?
Пример
Чтобы перейти от теории к практике, возьмём реальный пример. Вернёмся снова к функции WithDeadline
из пакета context
:
package context
// WithDeadline возвращает производный контекст
// со сроком действия не позднее d.
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
Для WithDeadline
можно написать два очевидных теста:
Контекст не отменяется до наступления дедлайна.
Контекст отменяется после дедлайна.
Напишем тест для второго случая: после истечения дедлайна контекст должен быть отменён.
func TestWithDeadlineAfterDeadline(t *testing.T) {
deadline := time.Now().Add(1 * time.Second)
ctx, _ := context.WithDeadline(t.Context(), deadline)
time.Sleep(time.Until(deadline))
if err := ctx.Err(); err != context.DeadlineExceeded {
t.Fatalf("context not canceled after deadline")
}
}
Тест выглядит простым:
создать контекст с дедлайном через секунду;
подождать до этого момента;
проверить, что контекст отменён.
К сожалению, у теста есть очевидная проблема. Он «спит» ровно до момента дедлайна. Но велика вероятность, что к этому времени контекст ещё не успеет отмениться. В лучшем случае тест будет крайне нестабильным.
Попробуем исправить:
time.Sleep(time.Until(deadline) + 100*time.Millisecond)
Теперь мы ждём на 100 мс дольше дедлайна. Для компьютера это вечность, должно хватить.
Однако остаются две серьёзные проблемы:
Тест выполняется 1.1 секунды. Это слишком долго для такого простого теста — он должен укладываться в миллисекунды.
Тест всё равно нестабилен. Для компьютера 100 мс — «вечность», но на перегруженной системе непрерывной интеграции (CI) задержки могут быть куда больше. На рабочем месте разработчика тест, скорее всего, будет проходить всегда, но в CI можно ожидать периодические падения.
Медленно или нестабильно: оба варианта
Тесты, которые зависят от реального времени, всегда либо медленные, либо нестабильные. Обычно они и то и другое. Если тест ждёт дольше, чем нужно — он медленный. Если ждёт меньше — он нестабильный. Можно сделать тест медленнее и менее нестабильным, или быстрее и более нестабильным, но одновременно быстрым и надёжным он никогда не станет.
В пакете net/http
у нас много тестов, построенных именно так. Все они медленные и/или нестабильные, и именно это подтолкнуло меня заняться поиском решений, о которых речь пойдёт дальше.
Писать синхронные функции?
Самый простой способ протестировать асинхронную функцию — не делать этого. Синхронные функции тестировать легко. Если асинхронную функцию можно преобразовать в синхронную, тестирование станет проще.
Например, если взять наши функции очистки кэша, синхронная Cleanup
явно лучше, чем асинхронная CleanupInBackground
. Синхронную функцию легко тестировать, а при необходимости её можно запустить в отдельной горутине. В целом, чем выше по стеку вызовов вы поднимаете конкурентность, тем лучше.
// CleanupInBackground сложно тестировать.
cache.CleanupInBackground()
// Cleanup легко тестировать,
// и при необходимости просто запустить в фоне.
go cache.Cleanup()
К сожалению, такое преобразование возможно не всегда. Например, context.WithDeadline
по своей природе является асинхронным API.
Сделать код удобнее для тестирования?
Лучший подход — повысить тестопригодность нашего кода.
Вот как это может выглядеть для теста WithDeadline
:
func TestWithDeadlineAfterDeadline(t *testing.T) {
clock := fakeClock()
timeout := 1 * time.Second
deadline := clock.Now().Add(timeout)
ctx, _ := context.WithDeadlineClock(
t.Context(), deadline, clock)
clock.Advance(timeout)
context.WaitUntilIdle(ctx)
if err := ctx.Err(); err != context.DeadlineExceeded {
t.Fatalf("context not canceled after deadline")
}
}
Вместо реального времени мы используем фиктивную (fakeClock
) реализацию часов. Поддельное время избавляет от лишних задержек в тестах — нам не приходится без дела ждать. Это также снижает «флаки»-поведение: текущее время меняется только тогда, когда тест сам его двигает.
Существует множество пакетов с поддельным временем, можно и свой написать.
Чтобы использовать поддельное время, нам нужно изменить API так, чтобы он принимал «часы». В примере я добавил функцию context.WithDeadlineClock
, которая принимает дополнительный параметр часов:
ctx, _ := context.WithDeadlineClock(
t.Context(), deadline, clock)
Когда мы продвигаем время вперёд, возникает проблема. Продвижение времени — асинхронная операция: спящие горутины могут проснуться, таймеры — отправить в свои каналы, а функции таймеров — выполниться. Прежде чем проверять ожидаемое поведение системы, нам нужно дождаться завершения этой фоновой работы.
Для этого я добавил функцию context.WaitUntilIdle
, которая ждёт, пока вся фоновая активность, связанная с контекстом, завершится:
clock.Advance(timeout)
context.WaitUntilIdle(ctx)
Это простой пример, но он показывает два фундаментальных принципа написания тестируемого конкурентного кода:
Использовать поддельное время (если вы работаете со временем).
Иметь способ дождаться quiescence — что в вольном переводе означает «вся фоновая активность остановилась, система пришла в стабильное состояние».
Интересный вопрос, конечно, в том, как этого добиться. В примере я намеренно опустил детали, потому что у этого подхода есть серьёзные минусы.
Это сложно. Использовать поддельные часы не проблема, но определить момент, когда фоновая работа действительно завершилась и состояние системы можно проверять безопасно, гораздо труднее.
Код становится менее идиоматичным. Вы не можете использовать стандартные функции из пакета
time
. Нужно тщательно отслеживать всё, что происходит в фоне.Инструментировать придётся не только свой код, но и любые сторонние пакеты, которые вы используете. Если вы вызываете сторонний конкурентный код — скорее всего, ничего не получится.
И самое неприятное: встроить такой подход в существующую кодовую базу может оказаться практически невозможным.
Я пытался применить этот метод к реализации HTTP в Go. Где-то это сработало, но сервер HTTP/2 оказался для меня непреодолимым препятствием. В частности, добавить инструменты для отслеживания состояния quiescence без серьёзной переработки оказалось нереально — или, по крайней мере, выше моих возможностей.
Чудовищные хаки на уровне рантайма?
Что делать, если мы не можем сделать свой код тестопригодным?
А что, если вместо инструментирования кода мы сможем наблюдать поведение неинструментированной системы?
Программа на Go состоит из набора горутин. У этих горутин есть состояния. Нам всего лишь нужно дождаться, пока все горутины перестанут выполняться.
К сожалению, рантайм Go никак не позволяет узнать, чем заняты горутины. Или позволяет?
В пакете runtime
есть функция, которая выдаёт трассировку стека для каждой запущенной горутины вместе с её состоянием. Это текст, рассчитанный на человека, но мы могли бы его парсить. Можно ли использовать это для обнаружения состояния покоя?
Разумеется, это ужасная идея. Нет никаких гарантий, что формат таких трассировок будет стабильным со временем. Так делать не стоит.
Я так и сделал. И это сработало. Более того, сработало на удивление хорошо.
С простой реализацией поддельных часов, небольшим инструментированием, чтобы отслеживать горутины, относящиеся к тесту, и варварским использованием runtime.Stack
у меня наконец-то появился способ писать быстрые и надёжные тесты для пакета http
.
Внутренняя реализация этих тестов была ужасна, но она показала, что сама идея — полезна.
Лучшее решение
В Go конкурентность встроена изначально, но тестировать программы, которые её используют, очень сложно.
Мы оказываемся перед неприятным выбором: либо писать простой, идиоматичный код, который невозможно тестировать быстро и надёжно, либо писать тестопригодный код, но при этом усложнённый и неидиоматичный.
Мы задумались: как сделать лучше?
Как мы уже видели, для написания тестируемого конкурентного кода нужны две ключевые вещи: поддельное время и способ дождаться состояния покоя.
Нам нужен более правильный способ ожидания покоя. Мы должны иметь возможность спросить у рантайма, когда фоновые горутины закончили работу. Причём делать это нужно с ограниченной областью видимости — в рамках одного теста, чтобы другие тесты не мешали.
Также нам нужна более удобная поддержка тестирования программ с использованием поддельного времени.
Сделать реализацию fake time несложно, но код, который её использует, получается неидиоматичным.
Идиоматичный код применяет time.Timer
, но создать поддельный Timer
невозможно. Мы задали себе вопрос: а не стоит ли предоставить тестам возможность создавать поддельный таймер, где сам тест управляет моментом срабатывания?
Реализация «тестового времени» сегодня требует полностью новой версии пакета time
, которую нужно передавать во все функции, работающие со временем. Мы рассматривали идею ввести общий интерфейс для времени, наподобие того, как net.Conn
описывает сетевое соединение.
Но мы поняли, что в отличие от сетевых соединений, у времени есть только одна возможная поддельная реализация. В фейковой сети можно моделировать задержки или ошибки. Время же делает одно-единственное — идёт вперёд. Тестам нужно управлять скоростью этого хода, но таймер, установленный на 10 секунд вперёд, всегда должен сработать ровно через 10 (пусть и поддельных) секунд.
Кроме того, мы не хотим ломать всю экосистему Go. Большинство программ сегодня используют функции из пакета time
. Мы хотим, чтобы они продолжали работать не только корректно, но и оставались идиоматичными.
Так мы пришли к выводу: нам нужен способ, позволяющий тесту сказать пакету time
, что следует использовать поддельные часы — примерно так же, как это делает Go Playground. В отличие от Playground, область действия должна быть ограничена одним тестом. (Кстати, не всем очевидно, что в Go Playground используется поддельное время: на фронтенде мы превращаем фейковые задержки в реальные, но внутри — оно поддельное.)
Эксперимент с synctest
Итак, в Go 1.24 мы представили новый экспериментальный пакет testing/synctest
, призванный упростить тестирование конкурентных программ. В течение нескольких месяцев после выхода Go 1.24 мы собирали обратную связь от первых пользователей (спасибо всем, кто попробовал!). Мы внесли ряд изменений, чтобы устранить проблемы и недочёты. И теперь, в Go 1.25, пакет testing/synctest
стал частью стандартной библиотеки.
Он позволяет запускать функцию внутри так называемого «пузыря» (bubble). Внутри пузыря пакет time
использует поддельные часы, а пакет synctest
предоставляет функцию, позволяющую дождаться, когда пузырь придёт в состояние покоя.
Пакет synctest
Пакет synctest
содержит всего две функции:
package synctest
// Test executes f in a new bubble.
// Goroutines in the bubble use a fake clock.
func Test(t *testing.T, f func(*testing.T))
// Wait waits for background activity in the bubble to complete.
func Wait()
Функция Test
выполняет заданную функцию в новом пузыре.
Функция Wait
блокируется до тех пор, пока каждая горутина внутри пузыря не окажется заблокированной в ожидании другой горутины из того же пузыря. Такое состояние мы называем «устойчиво заблокированным».
Тестирование с synctest
Посмотрим, как synctest
работает на практике:
func TestWithDeadlineAfterDeadline(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
deadline := time.Now().Add(1 * time.Second)
ctx, _ := context.WithDeadline(t.Context(), deadline)
time.Sleep(time.Until(deadline))
synctest.Wait()
if err := ctx.Err(); err != context.DeadlineExceeded {
t.Fatalf("context not canceled after deadline")
}
})
}
Выглядит знакомо? Это тот самый наивный тест для context.WithDeadline
, который мы разбирали раньше. Единственное отличие — теперь тест запускается внутри пузыря с помощью synctest.Test
, а также добавлен вызов synctest.Wait
.
Такой тест быстрый и надёжный. Он выполняется практически мгновенно. Он точно проверяет ожидаемое поведение системы. И при этом не требует никаких изменений в пакете context
.
С помощью synctest
мы можем писать простой, идиоматичный код и при этом тестировать его надёжно.
Да, это очень простой пример, но это реальный тест для реального production-кода. Если бы synctest
существовал на момент написания пакета context
, писать тесты для него было бы куда легче.
Время
Время внутри пузыря ведёт себя так же, как поддельное время в Go Playground. Оно начинается с полуночи 1 января 2000 года по UTC. Если по какой-то причине нужно запустить тест в определённый момент времени, достаточно «проспать» до него:
func TestAtSpecificTime(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// 2000-01-01 00:00:00 +0000 UTC
t.Log(time.Now().In(time.UTC))
// Это не займёт 25 лет.
time.Sleep(time.Until(
time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)))
// 2025-01-01 00:00:00 +0000 UTC
t.Log(time.Now().In(time.UTC))
})
}
Время движется только тогда, когда все горутины внутри пузыря заблокированы. Можно думать о пузыре как об эмуляции бесконечно быстрого компьютера: любая вычислительная работа занимает ноль времени.
Следующий тест всегда будет выводить, что с начала выполнения прошло ровно 0 секунд поддельного времени, независимо от того, сколько прошло в реальности:
func TestExpensiveWork(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
for range 1e7 {
// тяжёлая работа
}
t.Log(time.Since(start)) // 0s
})
}
В этом же примере time.Sleep
вернётся мгновенно, не дожидаясь десяти реальных секунд. Тест всегда напечатает, что прошло ровно 10 секунд поддельного времени:
func TestSleep(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
time.Sleep(10 * time.Second)
t.Log(time.Since(start)) // 10s
})
}
Ожидание состояния покоя (quiescence)
Функция synctest.Wait
позволяет дождаться завершения фоновой активности:
func TestWait(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
done := false
go func() {
done = true
}()
// Ждём завершения горутины выше.
synctest.Wait()
t.Log(done) // true
})
}
Без вызова Wait
в этом тесте возникла бы гонка данных: одна горутина изменяет переменную done
, пока другая её читает без синхронизации. Вызов Wait
обеспечивает эту синхронизацию.
Вы, вероятно, знакомы с флагом -race
, который включает детектор гонок. Детектор знает о синхронизации, обеспечиваемой Wait
, и не жалуется на этот тест. Если бы мы забыли вызвать Wait
, детектор справедливо указал бы на гонку данных.
Важно понимать: функция synctest.Wait
обеспечивает синхронизацию, но течение времени — нет.
В следующем примере одна горутина записывает значение в done
, в то время как другая делает паузу на одну наносекунду перед чтением. Очевидно, что при использовании реальных часов вне пузыря synctest
в этом коде есть гонка данных. Внутри пузыря поддельные часы гарантируют, что горутина завершится до того, как вернётся time.Sleep
, однако детектор гонок всё равно сообщит о проблеме — так же, как если бы этот код выполнялся вне пузыря.
func TestTimeDataRace(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
done := false
go func() {
done = true // write
}()
time.Sleep(1 * time.Nanosecond)
t.Log(done) // read (unsynchronized)
})
}
Добавив вызов Wait
, мы обеспечиваем явную синхронизацию и устраняем гонку данных:
time.Sleep(1 * time.Nanosecond)
synctest.Wait() // синхронизируемся
t.Log(done) // read
Пример: io.Copy
Синхронизация, которую обеспечивает synctest.Wait
, позволяет писать более простые тесты без лишней явной синхронизации.
Рассмотрим, например, такой тест для io.Copy
:
func TestIOCopy(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
srcReader, srcWriter := io.Pipe()
defer srcWriter.Close()
var dst bytes.Buffer
go io.Copy(&dst, srcReader)
data := "1234"
srcWriter.Write([]byte("1234"))
synctest.Wait()
if got, want := dst.String(), data; got != want {
t.Errorf("Copy wrote %q, want %q", got, want)
}
})
}
Функция io.Copy
копирует данные из io.Reader
в io.Writer
. На первый взгляд, она может не показаться конкурентной, ведь её вызов блокируется до завершения копирования. Но передача данных в io.Copy
— это асинхронная операция:
Copy
вызывает методRead
у ридера;Read
возвращает часть данных;эти данные записываются в writer позже.
В этом тесте мы проверяем, что io.Copy
пишет новые данные в райтер, не дожидаясь заполнения буфера.
Разберём шаги:
Сначала создаём
io.Pipe
, который выступает источником дляio.Copy
:
srcReader, srcWriter := io.Pipe()
defer srcWriter.Close()
Запускаем
io.Copy
в отдельной горутине, чтобы копировать из конца пайпа вbytes.Buffer
:
var dst bytes.Buffer
go io.Copy(&dst, srcReader)
Записываем данные в другой конец пайпа и ждём, пока
io.Copy
их обработает:
data := "1234"
srcWriter.Write([]byte("1234"))
synctest.Wait()
Проверяем, что буфер назначения содержит нужные данные:
if got, want := dst.String(), data; got != want {
t.Errorf("Copy wrote %q, want %q", got, want)
}
Мы не используем мьютексы или другую синхронизацию для буфера назначения — synctest.Wait
гарантирует, что к нему не будет одновременного доступа.
Этот тест показывает несколько важных моментов:
Даже синхронные функции вроде
io.Copy
, которые не выполняют фоновых действий после завершения, могут вести себя асинхронно.С помощью
synctest.Wait
мы можем проверять такие поведения.И, наконец, этот тест никак не связан со временем. Многие асинхронные системы завязаны на время, но не все.
Выход из пузыря
Функция synctest.Test
дожидается завершения всех горутин внутри пузыря перед возвратом. Время перестаёт идти после того, как корневая горутина (та, что запущена Test
) завершится.
В следующем примере Test
дождётся запуска и завершения фоновой горутины, прежде чем вернуться:
func TestWaitForGoroutine(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
go func() {
// Этот код выполнится до возврата synctest.Test.
}()
})
}
В этом примере мы планируем time.AfterFunc
на будущее время. Корневая горутина пузыря завершается до наступления этого момента, поэтому AfterFunc
так и не выполняется:
func TestDoNotWaitForTimer(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
time.AfterFunc(1 * time.Nanosecond, func() {
// Этот код никогда не выполнится.
})
})
}
В следующем примере мы запускаем горутину, которая «спит». Корневая горутина завершается, и время перестаёт идти. Пузырь заходит в взаимную блокировку: Test
ждёт завершения всех горутин внутри пузыря, а «спящая» горутина ждёт продвижения времени.
func TestDeadlock(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
go func() {
// Этот sleep никогда не вернётся, и тест застынет.
time.Sleep(1 * time.Nanosecond)
}()
})
}
Взаимные блокировки
Пакет synctest
вызывает панику, когда пузырь заходит во взаимную блокировку из-за того, что каждая горутина в пузыре устойчиво заблокирована ожиданием другой горутины из того же пузыря.
--- FAIL: Test (0.00s)
--- FAIL: TestDeadlock (0.00s)
panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked]
goroutine 7 [running]:
(stacks elided for clarity)
goroutine 10 [sleep (durable), synctest bubble 1]:
time.Sleep(0x1)
/Users/dneil/src/go/src/runtime/time.go:361 +0x130
_.TestDeadlock.func1.1()
/tmp/s/main_test.go:13 +0x20
created by _.TestDeadlock.func1 in goroutine 9
/tmp/s/main_test.go:11 +0x24
FAIL _ 0.173s
FAIL
Рантайм выведет трассировки стеков для каждой горутины во взаимно заблокированном пузыре.
При печати статуса «запузыренной» горутины рантайм указывает, что горутина находится в состоянии устойчивой блокировки (durably blocked). В этом тесте видно, что спящая горутина именно так и заблокирована.
Устойчивая блокировка
Понятие «устойчивая блокировка» (durably blocking) является ключевым в synctest
.
Горутина считается устойчиво заблокированной, если она не просто заблокирована, а разблокироваться может только за счёт действий другой горутины внутри того же пузыря.
Когда все горутины в пузыре находятся в устойчивой блокировке:
synctest.Wait
возвращает управление.Если вызова
synctest.Wait
в данный момент нет, поддельное время мгновенно перемещается к следующему моменту, который разбудит какую-либо горутину.Если нет ни одной горутины, которую можно разбудить продвижением времени, пузырь заходит во взаимную блокировку, и тест завершается с ошибкой.
Важно различать: горутина может быть просто заблокирована, а может быть устойчиво заблокирована. Мы не хотим объявлять deadlock, если горутина временно ждёт события, происходящего вне пузыря.
Когда блокировка неустойчивая: ввод-вывод (файлы, пайпы, сетевые соединения и т. д.)
Главное ограничение: ввод-вывод не считается устойчивой блокировкой, включая сетевой.
Горутина, читающая из сетевого соединения, может быть заблокирована, но она разблокируется, когда придут данные.
Это справедливо и для соединения с удалённым сервисом, и для loopback-соединения, даже если и читатель, и писатель находятся внутри одного пузыря.
Когда мы записываем данные в сетевой сокет (пусть даже в локальный), данные сначала передаются ядру ОС для доставки. Между моментом возврата системного вызова write
и моментом, когда ядро уведомит вторую сторону соединения о наличии данных, есть промежуток времени. Рантайм Go не может отличить горутину, которая ждёт данные, уже находящиеся в буфере ядра, от горутины, которая ждёт данные, которые так и не придут.
Отсюда вывод: тесты сетевых программ с использованием synctest
обычно не могут работать с реальными сетевыми соединениями. Вместо этого нужно использовать их имитацию в памяти (in-memory fake).
Я не буду здесь подробно разбирать процесс создания поддельной сети, но в документации к пакету synctest
есть полный пример тестирования HTTP-клиента и сервера, взаимодействующих через такую имитацию.
Неустойчивая блокировка: системные вызовы, cgo, всё, что не Go
Системные вызовы и вызовы через cgo
не считаются устойчивой блокировкой. Мы можем рассуждать только о состоянии горутин, выполняющих Go-код.
Неустойчивая блокировка: мьютексы
Возможно, неожиданно, но мьютексы тоже не считаются устойчивой блокировкой. Это решение принято из соображений практичности: мьютексы часто защищают глобальное состояние, и горутина в пузыре может попытаться захватить мьютекс, удерживаемый кодом вне пузыря. Кроме того, мьютексы крайне чувствительны к производительности, и добавление инструментирования замедлило бы обычные программы.
Программы с мьютексами можно тестировать с помощью synctest
, но поддельные часы не будут двигаться вперёд, пока горутина ждёт освобождения мьютекса. На практике это не стало проблемой, но об этом стоит помнить.
Устойчивая блокировка: time.Sleep
Что же считается устойчивой блокировкой?
time.Sleep
— очевидный пример: время может двигаться вперёд только тогда, когда все горутины внутри пузыря устойчиво заблокированы.
Устойчивая блокировка: операции с каналами, созданными в пузыре
Отправка и получение через каналы, созданные внутри пузыря, считаются устойчивыми блокировками.
Мы различаем «запузыренные» каналы (созданные внутри пузыря) и «незапузыренные» (созданные снаружи). Это значит, что функция, использующая глобальный канал для синхронизации, например для управления доступом к кэшированному ресурсу, может безопасно вызываться внутри пузыря.
Попытка работать с «запузыренным» каналом снаружи пузыря — ошибка.
Устойчивая блокировка: sync.WaitGroup, принадлежащий пузырю
WaitGroup
также ассоциируется с пузырём.
У WaitGroup
нет конструктора, поэтому связь с пузырём устанавливается неявно при первом вызове Go
или Add
.
Так же, как и с каналами: ожидание на WaitGroup
, принадлежащем тому же пузырю, считается устойчивой блокировкой. Ожидание на WaitGroup
из другого пузыря — нет. Вызовы Go
или Add
на WaitGroup
, принадлежащем другому пузырю, приводят к ошибке.
Устойчивая блокировка: sync.Cond.Wait
Ожидание на sync.Cond
всегда считается устойчивой блокировкой. Попытка разбудить горутину, ожидающую на Cond
из другого пузыря, — ошибка.
Устойчивая блокировка: select{}
Наконец, пустой select{}
— это устойчивая блокировка. (А select
с ветками является устойчивым, если все его операции — устойчивые.)
Это полный список устойчивых блокировок. Он не такой длинный, но покрывает почти все реальные случаи.
Правило простое: горутина считается устойчиво заблокированной, если она в состоянии блокировки и может быть разблокирована только другой горутиной из того же пузыря.
Если возможна попытка разбудить «запузыренную» горутину извне пузыря, это ошибка. Например, работать с каналом пузыря снаружи — нельзя.
Изменения с 1.24 до 1.25
В Go 1.24 мы выпустили экспериментальную версию пакета synctest
. Чтобы подчеркнуть его статус, пользователям нужно было выставить флаг GOEXPERIMENT
, чтобы пакет стал доступен.
Обратная связь от первых пользователей оказалась крайне ценной: она подтвердила полезность пакета и выявила места, где API требовал доработки.
Вот основные изменения между экспериментальной версией и релизом в Go 1.25:
Замена Run на Test
В изначальной версии API пузырь создавался функцией Run
:
// Run выполняет f в новом пузыре.
func Run(f func())
Стало ясно, что нужен *testing.T
, область действия которого ограничена пузырём. Например, t.Cleanup
должен вызывать функции очистки в том же пузыре, где они зарегистрированы, а не после его завершения. Поэтому Run
был переименован в Test
, и теперь он создаёт T
, связанный с жизненным циклом пузыря.
Время останавливается при завершении корневой горутины пузыря
Изначально время продолжало идти, пока в пузыре оставались горутины, ожидающие будущих событий. Такой подход оказался сложным для понимания, особенно в случаях, когда горутина была «долгоживущей» и никогда не завершалась, например при бесконечном чтении из time.Ticker
. Теперь время останавливается, когда корневая горутина пузыря завершается. Если пузырь остаётся заблокированным в ожидании продвижения времени, возникает взаимная блокировка и паника, которую можно проанализировать.
Устранены случаи, когда «устойчивость» была мнимой
Мы уточнили определение устойчивой блокировки. В оригинальной реализации были ситуации, когда горутину можно было разблокировать извне пузыря. Например, каналы хранили информацию о том, что они созданы в пузыре, но не указывали в каком именно. В результате один пузырь мог разблокировать канал другого. В текущей реализации мы не знаем ни одного случая, когда горутину можно было бы разблокировать извне её пузыря.
Улучшенные трассировки стека
Мы улучшили вывод информации в стектрейсах. Теперь при взаимной блокировке пузыря по умолчанию печатаются только стеки горутин внутри него. В трассировке также явно указывается, какие горутины находятся в состоянии устойчивой блокировки.
Рандомизация одновременных событий
Мы улучшили обработку событий, происходящих в одно и то же время. Ранее таймеры, срабатывающие в один и тот же момент, всегда выполнялись в порядке их создания. Теперь порядок их выполнения рандомизируется.
Будущее развитие
В целом мы довольны текущим состоянием пакета synctest
.
Помимо неизбежных исправлений ошибок, мы не ожидаем серьёзных изменений в будущем. Конечно, с ростом числа пользователей может выясниться, что есть вещи, которые стоит доработать.
Одна из возможных областей работы — улучшение механизма определения устойчиво заблокированных горутин. Было бы здорово, если бы операции с мьютексами считались устойчивыми, при условии, что мьютекс, захваченный внутри пузыря, должен освобождаться там же.
Тестирование сетевого кода с synctest
сегодня требует использования поддельной сети. Функция net.Pipe
позволяет создать фейковый net.Conn
, но в стандартной библиотеке пока нет функций для создания поддельных net.Listener
или net.PacketConn
. Кроме того, net.Conn
, возвращаемый net.Pipe
, работает синхронно: каждая запись блокируется, пока данные не будут прочитаны — что не отражает реального поведения сети. Возможно, стоит добавить качественные поддельные реализации распространённых сетевых интерфейсов в стандартную библиотеку.
Русскоязычное Go сообщество
Друзья! Эту статью перевела команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!

Заключение
Вот такой пакет synctest.
Я не могу сказать, что он делает тестирование конкурентного кода простым — конкурентность никогда не бывает простой. Но он позволяет писать максимально простой конкурентный код, используя идиоматичный Go и стандартный пакет time
, а затем писать для него быстрые и надёжные тесты.
Надеюсь, вы найдёте его полезным.