История
Тестовый фреймворк Go представил t.Parallel(), чтобы решить проблему увеличения продолжительности CI-процессов в крупных кодовых базах. До широкого внедрения многопроцессорных процессоров тесты по умолчанию выполнялись последовательно. С увеличением числа тестов до тысяч, чисто последовательное выполнение стало узким местом, однако неограниченный параллелизм мог исчерпать ресурсы процессов, такие как дескрипторы файлов или соединения с базами данных. Целью разработки было предоставить встроенную модель конкуренции с возможностью выбора, которая бы учитывала глобальное ограничение без необходимости вручную организовывать пул рабочих процессов или сложную синхронизацию для каждого тестового набора.
Проблема
Когда разработчик вызывает t.Parallel(), тест должен дать понять исполнителю, что он может выполняться одновременно с другими тестами. Однако фреймворк должен накладывать строгий лимит на конкуренцию (по умолчанию равный GOMAXPROCS, но настраиваемый через флаг -parallel), чтобы предотвратить истощение ресурсов. Проблема усложняется с вложенными подтестами: родительский тест может несколько раз вызывать t.Run, и каждый подтест может независимо вызывать t.Parallel(). Решение должно предотвратить освобождение родителем своего рабочего слота до завершения всех его наследников, в то же время обеспечивая, что глубоко вложенные параллельные подтесты правильно получают слоты из одного и того же глобального пула без взаимных блокировок родителя или превышения лимита.
Решение
Пакет testing использует семафор, реализованный как буферизованный канал пустых структур (chan struct{}), размер которого соответствует значению флага -parallel. Этот канал разделяется между всеми тестами в пакете. Каждый экземпляр T удерживает ссылку на этот parallel канал и внутренний signal канал для координации с родителем.
Когда вызывается t.Parallel():
signal канал, разблокируя вызов родительского t.Run, чтобы родитель мог продолжить или завершиться, пока подтест выполняется параллельно.parallel, получая слот для выполнения.parallel, как только функция теста возвращается и выполняются все хуки t.Cleanup.Для иерархий t.Run блокирует родительскую горутину, используя sync.WaitGroup, пока подтест полностью не завершится, даже если подтест выполняется параллельно. Это гарантирует, что родитель удерживает свой слот (или ждет), пока все дерево подтестов не завершится, предотвращая превышение глобального лимита из-за всплеска глубоко вложенных параллельных тестов.
// Концептуальная модель внутренних структур пакета тестирования type T struct { parallel chan struct{} // Разделяемый семафор signal chan struct{} // Сигналы родителю о том, что Parallel() был вызван parent *T wg sync.WaitGroup // Ожидает завершения подтестов } func (t *T) Parallel() { // Освобождение родителя для продолжения close(t.signal) // Получение слота из глобального пула t.parallel <- struct{}{} // Очистка освобождает слот, когда тест завершен t.Cleanup(func() { <-t.parallel }) } func (t *T) Run(name string, f func(t *T)) bool { t.wg.Add(1) sub := &T{parallel: t.parallel, signal: make(chan struct{})} go func() { defer t.wg.Done() f(sub) }() <-sub.signal // Ждать начала подтеста или вызова Parallel t.wg.Wait() // Ждать завершения return !sub.Failed() }
Контекст
Команда платформы поддерживала монорепозиторий, содержащий 2,000 интеграционных тестов для архитектуры микросервисов. Каждый тест поднимал эфемерные Docker контейнеры для Postgres и Redis. Выполнение тестов последовательно занимало 45 минут, что делало быстрый фидбэк невозможным. Однако выполнение go test -parallel 100 привело к исчерпанию лимита max_user_namespaces ядра, что приводило к сбою хоста и повреждению кэша сборки.
Проблема
Команде нужно было ограничить интенсивные тесты контейнеров до пяти параллельных экземпляров, чтобы учитывать ограничения ядра, при этом позволяя чистым модульным тестам выполняться с -parallel 32 для максимальной пропускной способности. Стандартный пакет тестирования Go принимает только одно глобальное значение -parallel за вызов, не предлагая встроенного способа применять различные лимиты к разным категориям тестов в одном запуске.
Рассмотренные решения
Внешняя оркестрация с Bazel.
Предложено было мигрировать на Bazel, так как он поддерживает шардирование тестов и тегирование ресурсов (например, tags = ["resources:postgres:1"]). Это позволило бы планировщику ограничить параллельные тесты базы данных. Однако это потребовало бы переписывания всей системы сборки и потери простоты go test. Кривая обучения была крутой, а локальные рабочие процессы разработки изменились бы кардинально, замедляя разработчиков, незнакомых с языком запросов Bazel.
Ручной семафор в тестовых наборах.
Разработчики рассматривали возможность добавления переменной уровня пакета var dbSem = make(chan struct{}, 5) и заставить каждый интеграционный тест вручную захватывать его в начале. Это дало бы детальный контроль, но потребовало значительного количества кода и риск взаимной блокировки, если тест вызывал панику, удерживая семафор. Это также фрагментировало модель конкуренции: некоторые тесты учитывали флаг -parallel, а другие учитывали пользовательский семафор, что усложняло отладку и приводило к несоответствующему учету ресурсов.
Разделение тегов сборки с этапами CI.
Команда выбрала разделение тестов, используя тегирование сборки. Они добавили //go:build integration ко всем контейнеризированным тестам и оставили модульные тесты неотмеченными. CI пайплайн сначала запускал go test -short -parallel 32 ./... для модульных тестов, а затем отдельно запускал go test -tags=integration -parallel 5 ./.... Это использовало существующие функции инструментария Go без изменения логики тестов. Недостатком было потеря параллелизма между пакетами между модульными и интеграционными тестами; этапы запускались последовательно. Однако, поскольку модульные тесты завершались за три минуты, общее время (3м + 20м) было приемлемым и стабильным.
Выбранное решение и результат
Они выбрали разделение тегов сборки. Это потребовало минимальных изменений в коде — только добавление тегов в заголовки файлов — и естественно использовало семафор стандартного пакета testing без пользовательской синхронизации. CI стал стабильным, ограничения ядра были соблюдены, и разработчики все еще могли запускать go test -tags=integration -parallel 4 локально для отладки. Общее время CI сократилось с 45 минут до 23 минут, и сбои хоста полностью прекратились.
Почему вызов t.Parallel() после создания горутины иногда приводит к тому, что эта горутина ведет лог в неправильный вывод теста или вызывает панику?
Когда вызывается t.Parallel(), текущая горутина теста блокируется на семафоре, а родительский исполняющий тест продолжает с следующим тестом. Однако созданная горутина наследует экземпляр T. Если основная функция теста завершится, пока горутина все еще выполняется, тестовый пакет помечает T как завершенный и закрывает его выходные буферы. Последующие вызовы к t.Log или t.Error из брошенной горутины могут вызвать панику с текстом "Log in goroutine after TestX has completed". Правильный подход — синхронизировать завершение горутины с использованием sync.WaitGroup или убедиться, что t.Cleanup ожидает его, поскольку t.Parallel() не ожидает автоматически отключенные горутины; он только координирует жизненный цикл функции теста с исполнителем.
Как пакет тестирования предотвращает освобождение родительским тестом своего слота параллелизма до завершения всех его подтестов—некоторые из которых также могут вызывать t.Parallel()?
Структура T встраивает sync.WaitGroup. Когда вызывается t.Run, чтобы создать подтест, родитель вызывает t.wg.Add(1) перед запуском горутины подтеста, и подтест вызывает t.wg.Done() в отложенной функции по завершении. Критически, когда подтест сам вызывает t.Parallel(), он немедленно уменьшает родительскую WaitGroup (разрешая родителю потенциально завершить свое собственное тело функции), но общее завершение родительского теста—а значит, и освобождение его семафорного токена—блокируется финальным t.wg.Wait() в цепочке очистки. Это создает древовидное ожидание, при котором корневой параллельный тест удерживает слот до тех пор, пока все поддерево последовательных и параллельных подтестов не завершится, обеспечивая, что лимит -parallel точно отражает количество активных тестовых деревьев, а не просто активных горутин.
Почему может произойти паника при вызове t.Setenv, если он был вызван после t.Parallel() и что это говорит о модели изоляции параллельных тестов в Go?
t.Setenv вызывает панику, когда его вызывают после t.Parallel(), потому что переменные окружения являются глобальным состоянием процесса. Параллельные тесты выполняются одновременно в одном процессе; если один тест изменил PATH, в то время как другой считывает его, результатом будет гонка данных и недетерминированное поведение. Чтобы предотвратить это, пакет тестирования Go помечает окружение как "замороженное", как только тест становится параллельным, и любая попытка изменить его через t.Setenv или os.Setenv вызывает панику. Это показывает, что параллельные тесты спроектированы для конкуренции в пределах одного адресного пространства, но предполагают неизменяемое общее состояние или явную синхронизацию. Кандидаты часто упускают, что t.Parallel() подразумевает строгое соглашение "никаких изменений глобального состояния процесса", что требует использования t.Cleanup, чтобы восстановить состояние только если тест не параллелен, или проектирования тестов таким образом, чтобы избежать глобального состояния полностью.