Чтобы понять, что решает 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, запускается в новой горутине и становится частью пузыря. Корневая горутина затем входит в цикл для управления временем и контроля планирования других горутин пузыря.


Иллюстрирует состояния горутин synctest


Существует две категории заблокированных горутин: внешне заблокированные и надёжно заблокированные.


Надёжно заблокированные означает, что горутина не может продолжить, пока что-то ещё не вызовет разблокировку, и это "что-то" контролируется внутри тестовой среды. Примеры включают:


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


  1. comerc Автор
    07.06.2025 17:00

    // .vscode/settings.json
    
    {
      "go.testEnvVars": {
        "GOEXPERIMENT": "synctest"
      },
      "go.testFlags": [
        "-v"
      ],
      "go.buildTags": "goexperiment.synctest"
    }
    // .vscode/launch.json
    
    {
      "version": "0.2.0",
      "configurations": [
        {
          "name": "Debug Synctest",
          "type": "go",
          "request": "launch",
          "mode": "test",
          "program": "${workspaceFolder}",
          "env": {
            "GOEXPERIMENT": "synctest"
          },
          "args": [
            "-test.run",
            ".*Synctest.*",
            "-test.v"
          ],
          "buildFlags": "-tags=goexperiment.synctest"
        },
        {
          "name": "Debug Current Synctest",
          "type": "go",
          "request": "launch",
          "mode": "test",
          "program": "${fileDirname}",
          "env": {
            "GOEXPERIMENT": "synctest"
          },
          "buildFlags": "-tags=goexperiment.synctest"
        }
      ]
    }
    // Makefile
    
    check:
    	GOEXPERIMENT=synctest go test -short -failfast -v -race -count=1 ./...
    


  1. aamonster
    07.06.2025 17:00

    Кажется, автор не понимает идею тестов.

    После устранения нестабильности тест TestSharedValue должен не всегда проходить успешно, а всегда фэйлиться. Просто потому, что в реальной жизни он может так сделать, и надо это обнаружить заранее.