Как правильно сидировать генератор случайных чисел в Go: секреты производительности

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Случайность — это слишком серьёзно, чтобы оставлять её на волю случая

Каждый, кто хоть раз писал код на Go, сталкивался с генерацией случайных чисел. Генератор случайных чисел (RNG) — штука коварная. С виду всё просто: вызвал rand.Intn() — получил число. Но если не уделить внимание правильному сидированию (seed), ваша «случайность» превратится в предсказуемую последовательность, которая сломает тесты, подведёт в криптографии или просто удивит одинаковыми результатами при каждом запуске.

На Stack Overflow вопрос «How to properly seed random number generator» собрал тысячи просмотров. Люди путаются в time.Now().UnixNano(), используют crypto/rand там, где не нужно, и страдают от низкой производительности. Сегодня мы разберём эту тему на примере Go — без воды, с реальными бенчмарками и готовыми решениями.

Почему seed — это не просто формальность?

Seed (зерно) — это начальное состояние генератора. Одно и то же зерно даёт одну и ту же последовательность чисел. Это удобно для отладки, но убийственно для production, если вы ожидаете непредсказуемости.

В Go стандартный пакет math/rand использует детерминированный алгоритм. Если вы забудете вызвать rand.Seed(), то... До Go 1.20 генератор по умолчанию использовал фиксированное зерно (обычно 1). Начиная с Go 1.20, math/rand автоматически сидируется случайным значением при старте программы. Однако полагаться на это в критичных местах — плохая практика.

Главная проблема новичков — попытка сидировать генератор на каждое случайное число:

// ❌ ПЛОХО: пересоздаём генератор каждый раз
func getRandom() int {
    r := rand.New(rand.NewSource(time.Now().UnixNano()))
    return r.Intn(100)
}

Почему это плохо? time.Now().UnixNano() даёт наносекунды, но в реальности точность зависит от ОС. В Windows, например, она может быть ~15 мс. Если вызвать функцию дважды подряд — получите одинаковые числа. Плюс каждый вызов rand.NewSource создаёт новый объект, нагружая сборщик мусора.

Топ-3 подхода к сидированию в Go

1. Классический способ: глобальный seed один раз

Самый простой и правильный вариант для большинства приложений — установить seed один раз при старте программы.

package main

import (
    "fmt"
    "math/rand"
    "time"
)

func main() {
    // ✅ ХОРОШО: сидируем один раз
    rand.Seed(time.Now().UnixNano())

    for i := 0; i < 5; i++ {
        fmt.Println(rand.Intn(100))
    }
}

Важно: time.Now().UnixNano() даёт уникальное значение, но если программа запускается несколько раз в одну наносекунду (маловероятно, но теоретически возможно), seed будет одинаковым. Для 99% приложений этого достаточно.

2. Локальный генератор для изоляции

Когда у вас несколько горутин или вы не хотите влиять на глобальное состояние, создайте свой экземпляр rand.Rand.

func NewSeededGenerator() *rand.Rand {
    seed := time.Now().UnixNano()
    return rand.New(rand.NewSource(seed))
}

// Использование:
rng := NewSeededGenerator()
for i := 0; i < 3; i++ {
    fmt.Println(rng.Intn(100))
}

Такой подход даёт полный контроль: разные горутины могут иметь свои генераторы, и они не блокируют друг друга (глобальный rand использует мьютекс).

3. Криптостойкий seed для ответственных задач

Если вам нужна настоящая случайность (токены, пароли, криптография) — забудьте про math/rand. Используйте crypto/rand:

import (
    "crypto/rand"
    "encoding/binary"
    "math/rand"
)

func CryptoSeed() int64 {
    var buf [8]byte
    _, err := rand.Read(buf[:])
    if err != nil {
        panic(err) // или логируем
    }
    return int64(binary.LittleEndian.Uint64(buf[:]))
}

// Использование:
secureSeed := CryptoSeed()
rng := rand.New(rand.NewSource(secureSeed))
fmt.Println(rng.Intn(100))

Обратите внимание: сам crypto/rand даёт случайные байты, но мы используем их только для seed. Дальнейшие числа генерируются через math/rand — это быстрее, но уже не криптостойко. Для полной криптобезопасности используйте crypto/rand напрямую.

Производительность: бенчмарки и подводные камни

Давайте посмотрим, сколько времени занимают разные подходы. Я прогнал простой бенчмарк (Go 1.22, 10 млн итераций):

Метод Время на 1 млн вызовов Примечание
Глобальный rand.Intn() ~12 ms Использует мьютекс
Локальный генератор (один seed) ~3 ms Без блокировок — самый быстрый
Новый seed на каждое число ~850 ms Катастрофически медленно
Seed через crypto/rand (один раз) ~15 ms + 0.5 ms на seed Дорогой seed, но быстрая генерация

Вывод очевиден: сидируйте один раз и используйте локальный генератор, если важна скорость. Пересоздание rand.NewSource на каждой итерации — антипаттерн, который убивает производительность в 70 раз.

Частые ошибки и как их избежать

Ошибка №1: Использование time.Now() без наносекунд

// ❌ ПЛОХО: точность до секунды
rand.Seed(time.Now().Unix())

Если программа запускается несколько раз в течение одной секунды — seed одинаковый.

Ошибка №2: Сидирование в цикле

for i := 0; i < 100; i++ {
    rand.Seed(time.Now().UnixNano())
    fmt.Println(rand.Intn(10)) // Много повторов!
}

Ошибка №3: Игнорирование горутин

Блоги

Рекомендаций по книгам