Как реализовать корректный polling с задержкой в Go synctest
Решения для опроса внутри synctest-пузыря в Go: почему time.NewTicker не работает и как это исправить: synctest.Wait(), time.After() или заменить тикер.
Как реализовать опрос с правильной задержкой внутри синctest-пузыря в Go?
Golang 1.24 представляет synctest, который создает изолированные среды с синтетическими часами с помощью synctest.Run(f). Это особенно полезно для тестирования производственного кода, который зависит от временных меток UTC. Однако при тестировании кода, включающего фоновые горутины, запускаемые асинхронно, мне нужно правильно ожидать их результатов внутри синctest-пузыря перед продолжением выполнения теста.
Проблема в том, что моя текущая функция опроса, использующая time.NewTicker, не интегрируется должным образом с синтетическими часами synctest. Она не ожидает между циклами опроса, как ожидалось.
Вот упрощенный пример структуры моего теста:
func TestTimingWithSynctest(t *testing.T) {
synctest.Run(func() {
// запуск другого тестового кода..
// теперь мне нужно фактически ожидать между циклами опроса внутри пузыря
polling.Run(...)
// снова запуск другого тестового кода..
})
}
А вот моя текущая реализация функции опроса:
// Package polling предоставляет утилиты для опроса.
package polling
import (
"context"
"time"
)
// Run многократно вызывает action() до тех пор, пока она не вернет true или ошибку,
// или пока не будет отменен ctx или не истечет timeout.
func Run(ctx context.Context, interval, timeout time.Duration,
action func() (bool, error)) error {
if timeout > 0 {
var cancel context.CancelFunc
// создаем дочерний контекст, объединяющий переданный ctx с timeout
// затем либо отмена извне, либо timeout отменяют опрос
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
done, err := action()
if err != nil {
return err
}
if done {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
// продолжаем цикл
}
}
}
Как можно изменить эту функцию опроса, чтобы она работала должным образом с синтетическими часами synctest, обеспечивая реальную задержку между циклами опроса?
В synctest-пузыре Go функция опроса с time.NewTicker не обеспечивает задержку между циклами, потому что синтетические часы не продвигаются — горутина с тикером не считается “durably blocked”. Чтобы реализовать опрос synctest с правильной задержкой, замените ticker на time.Sleep(interval) или <-time.After(interval), либо вызывайте synctest.Wait() после каждой итерации action(). Эти изменения гарантируют, что пузырь продвинет время только при блокировке всех горутин, делая тест детерминированным.
Содержание
- Что такое synctest и почему возникают проблемы с опросом
- Как работает синтетический час в пузыре
- Проблема time.NewTicker в synctest
- Решение 1: Использование synctest.Wait()
- Решение 2: Замена на time.Sleep или time.After
- Альтернатива: synctest.Start для долгоживущих горутин
- Источники
- Заключение
Что такое synctest и почему возникают проблемы с опросом
Synctest в Go 1.24 — это экспериментальный инструмент для тестирования конкурентного кода. Он создаёт изолированные пузыри (bubbles), где time.Now(), таймеры и тикеры работают на синтетических часах. Запускаете тест через synctest.Run(func() {...}), и внутри все горутины, рождённые в пузыре, живут по фейковому времени. Звучит круто? Но вот засада: время в пузыре прыгает вперёд только тогда, когда все горутины внутри него durably blocked — то есть надёжно заблокированы, без системных вызовов или I/O.
Вы запускаете polling.Run(...) внутри пузыря, ожидая, что между вызовами action() пройдёт реальная задержка. Но тест висит или завершается мгновенно. Почему? Потому что time.NewTicker создаёт горутину, которая крутится в select и не блокируется по-настоящему. Пузырь думает: “Эй, все горутины активны!”, и часы стоят. В официальной документации это объясняется чётко: тикер внутри пузыря ассоциируется с ним, но без полной блокировки прогресса нет.
Представьте: ваш код ждёт события, а synctest не даёт времени течь. Классическая ловушка для polling synctest.
Как работает синтетический час в пузыре
Под капотом synctest — это хитрая машина. Когда вы вызываете synctest.Run(f), запускается корневая горутина (root), которая выполняет f(). Любые спавненные внутри горутины тоже в пузыре. Синтетический час (sg.now) обновляется так:
- Проверяет ближайшее событие (таймер, тикер).
- Если все горутины заблокированы (на канале,
time.Sleep), прыгает к этому моменту. - Разблокирует нужную горутину.
Горутина не считается заблокированной, если она в select без таймаута или делает системные вызовы. В блоге Go приводят пример: простой time.Sleep работает идеально, тест пролетает за 0 секунд, симулируя секунду. Но добавьте polling с тикером — и привет, зависание.
Коротко: пузырь ждёт, пока все внутри не встанут намертво. Иначе время не тикает.
Проблема time.NewTicker в synctest
Ваша функция polling.Run — типичный случай. time.NewTicker(interval) спавнит горутину, которая в цикле шлёт тики в канал. В реальном коде это ок, но в пузыре:
- После
action()горутина входит вselect { case <-ctx.Done(): ... case <-ticker.C: }. - Она не durably blocked — висит в
select, готовая к приёму. - Пузырь видит активную горутину, не продвигает часы.
- Тикер никогда не срабатывает, тест бесконечно ждёт.
Issue в Go repo описывает это точно: “long-lived background polling goroutine… bubble will advance time indefinitely”. Тест висит. Аналогично с time.Tick() в другом issue.
Что делать? Не мучьте тикер. Есть простые фиксы.
Решение 1: Использование synctest.Wait()
Самый прямой хак — принудительно блокировать пузырь после каждой action(). Добавьте synctest.Wait() в цикл polling.Run. Эта функция ждёт, пока все горутины в пузыре кроме текущей не заблокируются.
Вот переписанная версия:
import "testing/synctest"
func Run(ctx context.Context, interval, timeout time.Duration,
action func() (bool, error)) error {
if timeout > 0 {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
}
ticker := time.NewTicker(interval) // Оставляем, но теперь оно сработает
defer ticker.Stop()
for {
done, err := action()
if err != nil {
return err
}
if done {
return nil
}
synctest.Wait() // Ключ! Блокирует до следующего события
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
// Продолжаем
}
}
}
Почему работает? synctest.Wait() заставляет пузырь прыгнуть к следующему тику, блокируя все. В документации подчёркивают: после Wait таймеры продвигаются. Тест в вашем TestTimingWithSynctest теперь ждёт реальные интервалы, но детерминировано.
Минус? synctest импортируется в прод-код — неидеально. Но для тестов ок.
Решение 2: Замена на time.Sleep или time.After
Лучше: избавьтесь от тикера совсем. time.Sleep(interval) или <-time.After(interval) гарантированно блокируют текущую горутину. Пузырь видит блокировку и тикает часами.
Упрощённый polling.Run:
func Run(ctx context.Context, interval, timeout time.Duration,
action func() (bool, error)) error {
if timeout > 0 {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
}
for {
done, err := action()
if err != nil {
return err
}
if done {
return nil
}
// Вместо тикера
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(interval):
// Задержка реализована!
}
}
}
Или с time.Sleep(interval) перед select, но time.After чище — учитывает отмену ctx. В примерах показывают: time.Sleep в пузыре симулирует время идеально. Нет лишних горутин, нет зависаний. Ваш тест пролетает быстро, но с правильными задержками.
Плюс: код чище, не зависит от synctest в проде.
Альтернатива: synctest.Start для долгоживущих горутин
Для фоновых опросов есть предложение: synctest.Start вместо Run. Оно возвращает контроллер, позволяя вручную управлять пузырём. Issue #73062 обсуждает: с Start polling-горутина блокируется на тике, время течёт.
Пример (экспериментально):
ctrl := synctest.Start(func() {
// polling.Run(...) здесь
})
defer ctrl.Finish() // Завершает пузырь
Но пока это предложение. Для стабильности — Wait или time.After.
Источники
- Testing concurrent code with testing/synctest
- synctest package - testing/synctest
- proposal: testing/synctest: create bubbles with Start rather than Run
- Testing Concurrent Code Using the Experimental ‘testing/synctest’ Package
- testing/synctest: blocks forever when use time.Tick
Заключение
Для polling synctest с задержками выбирайте time.After(interval) в цикле — просто, надёжно, без импорта synctest в прод. Если тикер нужен, добавьте synctest.Wait() после action(). Эти приёмы решают зависания, делая тесты быстрыми и предсказуемыми. Попробуйте в своём TestTimingWithSynctest — увидите разницу за секунду. А в будущем ждите synctest.Start для сложных сценариев.