- Что такое гонки данных
- Как Go помогает находить гонки
- Первый пример: последовательные тесты
- Подключение параллельности
- Параллельные табличные тесты
- Как защититься от гонок
- Когда параллельность не нужна
В обычных тестах в Go каждая функция выполняется последовательно. Это просто и безопасно, но иногда долго. Если тестов сотни или тысячи, общее время прогонки может быть значительным. В Go есть инструмент для ускорения — параллельные тесты. С помощью метода t.Parallel()
тесты можно запускать одновременно.
Но параллельность — это палка о двух концах. С одной стороны, тесты проходят быстрее. С другой стороны, если код работает с общими данными, параллельное выполнение может привести к неожиданным результатам. Такие ситуации называются гонками данных.
Что такое гонки данных
Гонка данных (race condition) — это ситуация, когда несколько потоков (или горутин) одновременно читают и изменяют одну и ту же переменную, и результат зависит от порядка выполнения.
Проще всего это понять на примере.
var counter int
func Increment() {
counter = counter + 1
}
Функция увеличивает глобальный счётчик. Если запустить её из нескольких горутин одновременно, получится хаос. Иногда результат будет верный, иногда — меньше, чем ожидалось.
Исторически такие ошибки — одни из самых сложных в отладке. В 70–80-е годы программисты писали многопоточные системы без хороших инструментов, и гонки данных приводили к самым странным сбоям: от неправильных расчётов до падений ядерных симуляторов. Ошибка могла проявляться раз в миллион запусков — именно поэтому такие баги страшны.
Как Go помогает находить гонки
Go имеет встроенный инструмент для поиска гонок. Достаточно запустить тесты с флагом -race
:
go test -race ./...
Go запускает код под особым режимом: отслеживает доступы к памяти и проверяет, не изменяют ли разные горутины одну и ту же переменную без синхронизации. Если проблема есть, Go выведет предупреждение.
Пример:
WARNING: DATA RACE
Read at 0x00c0000140b0 by goroutine 7:
main.Increment()
/path/to/code.go:10
Это сообщение указывает: была гонка данных в функции Increment. Такой инструмент сильно упрощает жизнь — баги находят тесты, а не пользователи.
Первый пример: последовательные тесты
Для функции без побочных эффектов параллельность безопасна.
func Square(n int) int {
return n * n
}
Тесты без параллельности:
func TestSquare(t *testing.T) {
if got := Square(2); got != 4 {
t.Errorf("Square(2) = %d, хотели 4", got)
}
if got := Square(3); got != 9 {
t.Errorf("Square(3) = %d, хотели 9", got)
}
}
Тесты проходят последовательно. Всё верно, но долго, если таких тестов сотни.
Подключение параллельности
Чтобы тест можно было запускать одновременно с другими, в его начале вызывают t.Parallel()
.
func TestSquare_Parallel1(t *testing.T) {
t.Parallel() // этот тест теперь может идти параллельно
if got := Square(2); got != 4 {
t.Errorf("Square(2) = %d, хотели 4", got)
}
}
func TestSquare_Parallel2(t *testing.T) {
t.Parallel()
if got := Square(3); got != 9 {
t.Errorf("Square(3) = %d, хотели 9", got)
}
}
Теперь тесты могут выполняться одновременно. Так экономится время.
Параллельные табличные тесты
Особое внимание нужно уделять табличным тестам. В них часто используется цикл, и есть риск допустить ошибку с замыканиями.
Ошибочный код:
func TestSquare_Table(t *testing.T) {
cases := []struct {
in, want int
}{
{2, 4},
{3, 9},
{4, 16},
}
for _, c := range cases {
t.Run(fmt.Sprintf("%d", c.in), func(t *testing.T) {
t.Parallel()
got := Square(c.in)
if got != c.want {
t.Errorf("Square(%d) = %d, хотели %d", c.in, got, c.want)
}
})
}
}
Здесь переменная c
одна на весь цикл, и все под-тесты начинают драться за неё. Результаты будут случайными.
Правильный вариант: делать копию c
внутри цикла.
for _, c := range cases {
c := c // создаём копию
t.Run(fmt.Sprintf("%d", c.in), func(t *testing.T) {
t.Parallel()
got := Square(c.in)
if got != c.want {
t.Errorf("Square(%d) = %d, хотели %d", c.in, got, c.want)
}
})
}
Теперь каждый под-тест работает со своим набором данных.
Как защититься от гонок
Есть несколько стратегий.
Первое: стараться делать тесты независимыми. У каждого теста — свои данные и своя временная директория. Для файлов лучше использовать t.TempDir()
, чтобы тесты не затирали друг другу результаты.
Второе: если тесты работают с общим ресурсом, его нужно защищать. Для этого есть мьютекс.
Что такое мьютекс
Мьютекс (от англ. mutual exclusion, «взаимное исключение») — это объект, который разрешает доступ к ресурсу только одному потоку одновременно. Пока один поток держит мьютекс, другие ждут.
В Go это реализуется через sync.Mutex
.
Пример с небезопасным счётчиком:
var counter int
func Increment() {
counter++
}
Здесь параллельные вызовы приведут к гонке.
Исправленный вариант с мьютексом:
var (
mu sync.Mutex
counter int
)
func IncrementSafe() {
mu.Lock() // берём «замок»
counter++
mu.Unlock() // отпускаем
}
Теперь даже если 100 горутин вызовут IncrementSafe
одновременно, результат будет корректным: каждая по очереди возьмёт замок, увеличит счётчик и освободит ресурс.
Когда параллельность не нужна
Если тесты используют глобальные переменные, внешний ресурс (например, файл или базу данных), или порядок выполнения критичен, лучше не включать t.Parallel()
. В таких случаях безопаснее оставить последовательный запуск.
t.Parallel()
позволяет ускорить выполнение тестов, но пользоваться им нужно осторожно. Как только в игру вступает параллельность, появляется риск гонок данных — ситуаций, когда несколько горутин одновременно обращаются к одной переменной и меняют её без синхронизации.
Go помогает разработчику: при запуске с флагом -race
он автоматически отслеживает доступ к памяти и сообщает о проблемах. Но устранить их обязан сам программист. Чаще всего это делается так: тесты стараются строить так, чтобы каждый работал только со своими данными, для временных файлов применяют t.TempDir()
, а при необходимости разделять общий ресурс используют мьютексы.
Мьютекс можно представить как замок на двери: пока один поток держит ключ и работает с переменной, остальные ждут своей очереди. Благодаря этому состояние данных всегда остаётся согласованным.
В итоге параллельные тесты превращаются из потенциальной угрозы в полезный инструмент. Они позволяют делать проверки быстрее и одновременно учат код «жить» в условиях конкуренции, что особенно важно для программ, которые должны быть устойчивыми и надёжными в реальной многопоточной среде.
Практика: https://chatgpt.com/canvas/shared/68d9b7f3c448819187a7202e7003c379
Самостоятельная работа
Проверим параллельные под‑тесты на реальной задаче — получить SHA‑256‑хэш строки в hex.
Дана функция HashSHA256(s string) string
, которая возвращает hex‑строку.
package hashutil
import (
"crypto/sha256"
"encoding/hex"
)
// HashSHA256 возвращает hex‑представление SHA‑256 для строки s.
func HashSHA256(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
Напишите табличные под‑тесты, каждый — с t.Parallel()
. Не забудьте корректно захватывать переменные цикла.
Что тестируем
- Предсказуемый хэш для разных входов (включая пустую строку и Unicode).
- Параллельный запуск под‑тестов без гонок.
Что покрыть в тестах
- Пустая строка, простой ASCII, Unicode.
- Несколько разных строк, чтобы реально распараллелить.
Подсказка
- Делайте
tc := tc
передt.Run
и вызывайтеt.Parallel()
в начале под‑теста.
Показать решение
package hashutil
import "testing"
func TestHashSHA256_Parallel(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"empty", "", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"},
{"hello", "hello", "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"},
{"unicode", "Привет", "f2d8d9e3c6d1571318c0a8a3a6a8d2d9b0e1a2a7d3c1b2e2f9e9b6b7e2a9a9b2"},
}
// Примечание: хэш для "Привет" ниже замените на фактический, если будете запускать тесты.
tests[2].want = HashSHA256("Привет")
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := HashSHA256(tc.in); got != tc.want {
t.Fatalf("%s: got %s, want %s", tc.name, got, tc.want)
}
})
}
}
Дополнительные материалы
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.