Чтобы понять, что решает synctest
, мы должны сначала рассмотреть основную проблему: недетерминизм в конкурентных тестах.
func TestSharedValue(t *testing.T) {
var shared atomic.Int64
go func() {
shared.Store(1)
time.Sleep(1 * time.Microsecond)
shared.Store(2)
}()
// Проверяем общее значение через 5 микросекунд
time.Sleep(5 * time.Microsecond)
if shared.Load() != 2 {
t.Errorf("shared = %d, want 2", shared.Load())
}
}
Этот тест запускает горутину, которая изменяет общую переменную. Она устанавливает shared
в 1, спит 1 микросекунду, а затем устанавливает её в 2.
Тем временем основная функция теста ждёт 5 микросекунд перед проверкой того, достигло ли shared
значения 2. На первый взгляд кажется, что этот тест должен всегда проходить. В конце концов, 5 микросекунд должно быть достаточно времени для завершения выполнения горутины.
Однако...
Однако многократный запуск теста с помощью:
go test -run TestSharedValue -count=1000
покажет, что тест иногда падает. Вы можете увидеть такие выводы:
shared = 0, want 2
или
shared = 1, want 2
Это происходит потому, что тест нестабилен. Иногда горутина не завершается к моменту выполнения проверки или даже не запускается. Результат зависит от системного планировщика и того, как быстро горутина подхватывается средой выполнения.
Точность time.Sleep
и поведение планировщика могут сильно варьироваться. Такие факторы, как различия в операционных системах и нагрузка на систему, могут влиять на тайминги. Это делает любую стратегию синхронизации, основанную исключительно на sleep, ненадёжной.
Хотя этот пример использует задержки в микросекундах для демонстрации, проблемы реального мира часто включают задержки на уровне миллисекунд или секунд, особенно при высокой нагрузке.
Реальные системы, затронутые этим типом нестабильности, включают фоновую очистку, логику повторных попыток, удаление кэша по времени, мониторинг heartbeat, выбор лидера в распределённых средах и т.д.
Подобные тесты зависят от тайминга и также могут быть времязатратными. Представьте, если бы пришлось ждать 5 секунд вместо всего лишь 5 микросекунд.
Что такое synctest?
synctest
— это новая функция, представленная в Go 1.24. Она обеспечивает детерминированное тестирование конкурентного кода, запуская горутины в контролируемых, изолированных средах.
Рассмотрим этот пример, который не использует synctest
:
func TestTimingWithoutSynctest(t *testing.T) {
start := time.Now().UTC()
time.Sleep(5 * time.Second)
t.Log(time.Since(start))
}
Когда вы запускаете этот тест с помощью:
go test . -v
Вы увидите, что вывод никогда не будет точно 5s
. Вместо этого он может выглядеть как 5.329s
, 5.394s
или 5.456s
. Эти вариации возникают из-за задержек в системном планировании и разрешении времени.
С synctest
время полностью контролируется. Продолжительность становится постоянной, и вывод всегда будет показывать 5s
.
Чтобы использовать synctest
, оберните логику вашего теста в функцию и передайте её в synctest.Run()
:
import "testing/synctest"
func TestTimingWithSynctest(t *testing.T) {
synctest.Run(func() {
start := time.Now().UTC()
time.Sleep(5 * time.Second)
t.Log(time.Since(start))
})
}
Затем запустите тест с флагом GOEXPERIMENT=synctest
:
GOEXPERIMENT=synctest go test -run TestTimingWithSynctest -v
Пример вывода:
=== RUN TestTimingWithSynctest
main_test.go:8: 5s
--- PASS: TestTimingWithSynctest (0.00s)
PASS
Обратите внимание, что time.Sleep
внутри synctest
возвращается немедленно. Тест фактически не ждёт 5 секунд. Это делает тесты намного быстрее, оставаясь при этом точными.
Теперь, когда мы знаем, что synctest
манипулирует временем для создания детерминированного поведения, мы можем использовать его для исправления ранее нестабильного теста. Просто оберните тело теста с помощью synctest.Run
:
func TestSharedValue(t *testing.T) {
synctest.Run(func() {
var shared atomic.Int64
go func() {
shared.Store(1)
time.Sleep(1 * time.Microsecond)
shared.Store(2)
}()
// Проверяем общее значение через 5 микросекунд
time.Sleep(5 * time.Microsecond)
if shared.Load() != 2 {
t.Errorf("shared = %d, want 2", shared.Load())
}
})
}
С этим изменением тест будет проходить каждый раз. Но как это исправляет проблему того, что планировщик среды выполнения Go не подхватывает горутину для запуска?
Причина в том, что время контролируется. 5 микросекунд симулируются, а не являются реальными. Когда код выполняется, время фактически заморожено, и synctest
управляет его продвижением. Другими словами, логика не полагается на реальное время, а вместо этого зависит от детерминированного порядка выполнения.
Механизм ожидания
В дополнение к синтетическому времени, synctest
также предоставляет мощный примитив синхронизации: функцию synctest.Wait
.
Когда вы вызываете synctest.Wait()
, она блокируется до тех пор, пока все остальные горутины (в той же группе synctest
) либо не завершатся, либо не будут надёжно заблокированы. Наиболее распространённое использование Wait()
— запустить фоновые горутины, затем приостановиться до тех пор, пока они не достигнут стабильной точки, прежде чем делать утверждения.
Вот пример, где Wait()
гарантирует, что callback afterFunc
был вызван:
synctest.Run(func() {
ctx, cancel := context.WithCancel(context.Background())
afterFuncCalled := false
context.AfterFunc(ctx, func() {
afterFuncCalled = true
})
// Отменяем контекст и ждём завершения AfterFunc
cancel()
synctest.Wait()
// Теперь мы можем безопасно проверить, что callback был вызван
fmt.Printf("after context is canceled: afterFuncCalled=%v\n", afterFuncCalled)
})
Когда мы вызываем cancel()
, функция, переданная в context.AfterFunc
, выполняется в отдельной горутине. Без координации мы не можем быть уверены, когда эта горутина будет запланирована или когда она завершится.
Поскольку synctest
отслеживает все горутины в тестовом пузыре, он знает их точное состояние. Когда Wait()
возвращается, он гарантирует, что все остальные горутины либо завершены, либо заблокированы. Это позволяет вам делать надёжные и детерминированные утверждения о состоянии программы.
Как работает synctest
synctest
работает, создавая изолированные среды, называемые "пузырями". Пузырь — это набор горутин, которые выполняются в контролируемой и независимой среде, отделённой от нормального выполнения программы.
Когда вы вызываете synctest.Run(f)
, среда выполнения Go создаёт новый пузырь выполнения. Этот пузырь имеет несколько уникальных характеристик, которые отличают его от обычного поведения Go:
1. Синтетическое время
Каждый пузырь имеет свои собственные синтетические часы. Это синтетическое время начинается в полночь UTC 1 января 2000 года (эпоха 946684800000000000):
func TestTimingWithSynctest(t *testing.T) {
synctest.Run(func() {
t.Log(time.Now().UTC())
})
}
// Вывод:
// 2000-01-01 00:00:00 +0000 UTC
Внутри пузыря время не движется вперёд в реальном времени. Вместо этого Go приостанавливает время и наблюдает, что делают горутины. Если какая-либо горутина всё ещё активна (не заблокирована), синтетическое время остаётся замороженным:
func TestTimingWithSynctest(t *testing.T) {
synctest.Run(func() {
t.Log(time.Now().UnixNano())
var now int64
for range 10000000 {
now = time.Now().UnixNano()
}
t.Log(now)
})
}
// Вывод:
// 946684800000000000
// 946684800000000000
Время продвигается только тогда, когда все горутины в пузыре заблокированы. Это означает, что они ждут операций, таких как time.Sleep
, получение из канала, мьютексы или другие блокирующие вызовы.
В пузыре synctest
время продвигается только для запуска запланированных событий. Это даёт тесту полный контроль над временем выполнения и порядком.
Например, если горутина спит 5 секунд, и все остальные также заблокированы, Go мгновенно переместит синтетическое время вперёд на 5 секунд. Это позволяет горутине возобновиться немедленно, не дожидаясь прохождения реального времени.
2. Координация горутин
Когда вызывается synctest.Run(f)
, текущая горутина становится корнем пузыря. Эта корневая горутина управляет синтетическим временем и координирует выполнение всех других горутин внутри пузыря.
Функция f
, переданная в synctest.Run
, запускается в новой горутине и становится частью пузыря. Корневая горутина затем входит в цикл для управления временем и контроля планирования других горутин пузыря.
Существует две категории заблокированных горутин: внешне заблокированные и надёжно заблокированные.
Надёжно заблокированные означает, что горутина не может продолжить, пока что-то ещё не вызовет разблокировку, и это "что-то" контролируется внутри тестовой среды. Примеры включают:
time.Sleep()
sync.Cond.Wait()
sync.WaitGroup.Wait()
- Операции на
nil
каналах - Операторы
select
, где все случаи включают каналы внутри пузыря - Отправка и получение на каналах, созданных внутри пузыря
Горутины не являются надёжно заблокированными, если они ждут событий вне пузыря. К ним относятся:
- Системные вызовы, такие как файловый или сетевой ввод-вывод
- Обработка внешних событий (например, чтение из сокета)
- Операции с каналами на каналах, которые были созданы вне пузыря
С точки зрения synctest
, горутины, заблокированные на внешних событиях, считаются выполняющимися, поскольку их прогресс зависит от состояния реального мира.
Поэтому, если у вас есть горутина, которая навсегда внешне заблокирована, и другая горутина, которая надёжно заблокирована на чём-то вроде time.Sleep(5 * time.Microsecond)
, sleep никогда не завершится. Поскольку внешняя блокировка не позволяет системе достичь полностью заблокированного состояния, синтетическое время не будет продвигаться, и надёжно заблокированная горутина останется приостановленной.
Когда нет выполняющихся горутин и все активные надёжно заблокированы, synctest
переходит к пробуждению горутины, ожидающей на synctest.Wait()
, или продолжает выполнение корневой горутины. Логика принятия решения выглядит так:
func (sg *synctestGroup) maybeWakeLocked() *g {
if sg.running > 0 || sg.active > 0 {
return nil
}
sg.active++
if gp := sg.waiter; gp != nil {
return gp
}
return sg.root
}
Роль корневой горутины в этот момент — найти следующее запланированное событие таймера. Это может быть вызвано функциями, такими как time.Sleep
, time.Timer
, time.Ticker
или time.AfterFunc
. Все они создают таймеры внутренне.
Как только корень находит следующее событие, он устанавливает синтетическое время на этот момент (sg.now = next
), затем паркует себя и ждёт, пока планировщик тестов возобновит горутину, которая теперь должна выполняться.
Помните, что synctest
в первую очередь предназначен для тестирования времени и корректности логики синхронизации, а не для точной симуляции поведения времени в реальном мире. При неправильном использовании он может скрыть ошибки, которые появились бы в реальных условиях.
И в качестве заключительного замечания, эта статья была написана, пока synctest
всё ещё экспериментален. Некоторые детали могут измениться со временем, но основные концепции, как ожидается, останутся прежними.
Комментарии (2)
aamonster
07.06.2025 17:00Кажется, автор не понимает идею тестов.
После устранения нестабильности тест TestSharedValue должен не всегда проходить успешно, а всегда фэйлиться. Просто потому, что в реальной жизни он может так сделать, и надо это обнаружить заранее.
comerc Автор