Интерактивный тур по Go 1.25

Выпуск Go 1.25 запланирован на август, так что сейчас самое время изучить, что нового. Официальные заметки к релизу сухие как прошлогодние сухари, поэтому я подготовил интерактивную версию с множеством примеров.

synctestjson/v2GOMAXPROCS • Новый GC • Анти-CSRF • WaitGroup.Go • FlightRecorder • os.Root • reflect.TypeAssert • T.Attr • slog.GroupAttrs • hash.Cloner

Статья основана на официальных заметках к релизу от команды Go. Они распространяются под лицензией BSD-3-Clause. Это не полный список изменений; если нужен полный — смотрите официальную документацию.

По тексту вы увидите ссылки на спецификации (𝗣) и коммиты (𝗖𝗟) для описанных фич. Посмотрите их, если хотите узнать больше о причинах и деталях изменений.

Чтобы не усложнять код, в примерах часто отсутствует обработка ошибок. Не делайте так в продакшене ツ

Поехали!

# Синтетическое время в тестах

Предположим, у нас есть функция, которая ждет значение из канала одну минуту, а затем завершает ожидание по таймауту:

// Read читает значение из входного канала и возвращает его.
// Завершается по таймауту через 60 секунд.
func Read(in chan int) (int, error) {
    select {
    case v := <-in:
        return v, nil
    case <-time.After(60 * time.Second):
        return 0, errors.New("timeout")
    }
}

Вызываем ее так:

func main() {
    ch := make(chan int)
    go func() {
        ch <- 42
    }()
    val, err := Read(ch)
    fmt.Printf("val=%v, err=%v\n", val, err)
}
val=42, err=<nil>

Как проверить ситуацию с таймаутом? Конечно, мы не хотим, чтобы тест действительно ждал 60 секунд. Мы могли бы сделать таймаут параметром функции (наверное, так и стоило бы), но допустим, что это не вариант.

Новый пакет synctest приходит на помощь! Функция synctest.Test выполняет изолированный «пузырь». Внутри пузыря функции пакета time используют искусственные часы, что позволяет тесту пройти мгновенно:

func TestReadTimeout(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        ch := make(chan int)
        _, err := Read(ch)
        if err == nil {
            t.Fatal("expected timeout error, got nil")
        }
    })
}
PASS

Начальное время в пузыре — полночь 2000-01-01 UTC. Время идет вперед, когда все горутины в пузыре заблокированы. В нашем примере, когда единственная горутина заблокирована на select в Read, часы пузыря прыгают на 60 секунд вперед, что вызывает срабатывание в селекте ветки таймаута.

Имейте в виду, что t, передаваемый во внутреннюю функцию — не совсем обычный testing.T. В частности, никогда не вызывайте на нем T.Run, T.Parallel или T.Deadline:

func TestSubtest(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        t.Run("subtest", func (t *testing.T) {
            t.Log("ok")
        })
    })
}
panic: testing: t.Run called inside synctest bubble [recovered, repanicked]

Как видите, дочерние тесты внутри пузыря запустить не получится.

Другая полезная функция — synctest.Wait. Она ждет, пока все горутины в пузыре заблокируются, а затем продолжает выполнение:

func TestWait(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        var innerStarted bool
        done := make(chan struct{})

        go func() {
            innerStarted = true
            time.Sleep(time.Second)
            close(done)
        }()

        // Ждем, пока внутренняя горутина заблокируется на time.Sleep.
        synctest.Wait()
        // Гарантируется, что здесь innerStarted равно true.
        fmt.Printf("inner started: %v\n", innerStarted)

        <-done
    })
}
inner started: true

Попробуйте закомментировать вызов Wait() и посмотрите, как изменится значение innerStarted.

Пакет testing/synctest впервые появился как экспериментальный в версии 1.24. Сейчас он считается стабильным и готов к использованию. Обратите внимание, что функция Run, которая была добавлена в 1.24, теперь устарела. Вместо нее следует использовать Test.

𝗣 67434, 73567 • 𝗖𝗟 629735, 629856, 671961

# JSON v2

Вторая версия пакета json — это большое обновление, в котором полно несовместимых изменений. Поэтому я написал отдельный пост с подробным разбором изменений и множеством интерактивных примеров.

Здесь покажу только одну из самых впечатляющих функций.

В json/v2 вы больше не ограничены одним маршалером для конкретного типа. Теперь можно создавать кастомные маршалеры и анмаршалеры «на лету» — с помощью универсальных функций MarshalToFunc и UnmarshalFromFunc.

Например, можно преобразовать логические значения (true/false) и «логические» строки (on/off) в значения или — не создав при этом ни одного типа!

Сначала создадим маршалер для логических значений:

// Преобразует значения true/false в ✓ или ✗.
boolMarshaler := json.MarshalToFunc(
    func(enc *jsontext.Encoder, val bool) error {
        if val {
            return enc.WriteToken(jsontext.String("✓"))
        }
        return enc.WriteToken(jsontext.String("✗"))
    },
)

Затем маршалер для «логических» строк:

// Преобразует строки вида "true"/"false" в ✓ или ✗.
strMarshaler := json.MarshalToFunc(
    func(enc *jsontext.Encoder, val string) error {
        if val == "on" || val == "true" {
            return enc.WriteToken(jsontext.String("✓"))
        }
        if val == "off" || val == "false" {
            return enc.WriteToken(jsontext.String("✗"))
        }
        // SkipFunc — специальная ошибка, которая инструктирует Go пропустить
        // текущий маршалер и перейти к следующему. В нашем случае
        // следующим будет стандартный маршалер для строк.
        return json.SkipFunc
    },
)

Наконец, объединим маршалеры с помощью JoinMarshalers и передадим их в функцию маршалинга через опцию WithMarshalers:

// Объединяем маршалеры с помощью JoinMarshalers.
marshalers := json.JoinMarshalers(boolMarshaler, strMarshaler)

// Кодируем в JSON несколько значений.
vals := []any{true, "off", "hello"}
data, err := json.Marshal(vals, json.WithMarshalers(marshalers))
fmt.Println(string(data), err)
["✓","✗","hello"] <nil>

Здорово, правда?

В json/v2 еще много полезного: поддержка I/O-читателей и писателей, инлайнинг вложенных объектов, куча разных настроек и заметный прирост производительности. Поэтому еще раз советую посмотреть пост, посвященный изменениям во второй версии.

𝗣 63397, 71497

# GOMAXPROCS для контейнеров

Параметр рантайма GOMAXPROCS определяет максимальное количество потоков операционной системы, которые планировщик Go может использовать для одновременного выполнения горутин. Начиная с Go 1.5, по умолчанию он равен значению runtime.NumCPU, то есть количеству логических CPU на машине (точнее, это либо общее число логических CPU, либо число, разрешённое маской аффинности процессора, если оно меньше).

Например, на моем ноутбуке с 8 ядрами значение GOMAXPROCS по умолчанию тоже равно 8:

maxProcs := runtime.GOMAXPROCS(0) // возвращает текущее значение
fmt.Println("NumCPU:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", maxProcs)
NumCPU: 8
GOMAXPROCS: 8

Программы на Go часто запускаются в контейнерах, например, под управлением Docker или Kubernetes. В этих системах можно ограничить использование процессора для контейнера с помощью функции Linux, которая называется cgroups.

Cgroup (control group) в Linux позволяет объединять процессы в группы и управлять тем, сколько процессорного времени, памяти и сетевых ресурсов они могут использовать, устанавливая лимиты и приоритеты.

Например, вот как можно ограничить контейнер Docker четырьмя CPU:

docker run --cpus=4 golang:1.24-alpine go run /app/nproc.go

До версии 1.25 рантайм Go не учитывал ограничение по CPU (CPU-квоту) при установке значения GOMAXPROCS. Как бы вы ни ограничивали ресурсы процессора, GOMAXPROCS всегда устанавливался равным количеству CPU на хосте.

docker run --cpus=4 golang:1.24-alpine go run /app/nproc.go
NumCPU: 8
GOMAXPROCS: 8

Теперь рантайм Go runtime начал учитывать CPU-квоту:

docker run --cpus=4 golang:1.25rc1-alpine go run /app/nproc.go
NumCPU: 8
GOMAXPROCS: 4

Значение по умолчанию для GOMAXPROCS равно либо общему количеству CPU, либо лимиту CPU, заданному через cgroup для процесса — выбирается меньшее из этих двух значений.

Дробные значения лимита округляются в большую сторону:

docker run --cpus=2.3 golang:1.25rc1-alpine go run /app/nproc.go
NumCPU: 8
GOMAXPROCS: 3

На многоядерной машине минимальное умолчательное значение GOMAXPROCS равно 2, даже если лимит на CPU установлен меньше:

docker run --cpus=1 golang:1.25rc1-alpine go run /app/nproc.go
NumCPU: 8
GOMAXPROCS: 2

Если лимит CPU изменяется, рантайм автоматически обновляет значение GOMAXPROCS. Сейчас это происходит не чаще одного раза в секунду (реже, если приложение простаивает).

Ограничение CPU

Cgroups позволяют ограничивать использование процессора двумя способами:

  • Квота CPU — это максимальное время работы процессора, которое можно использовать за определенный период.
  • Доли CPU — это относительный приоритет использования процессора, который задается для планировщика ядра.

В Docker опции --cpus и --cpu-period/--cpu-quota задают квоту, а --cpu-shares задает доли.

В Kubernetes CPU limit задает квоту, а CPU request задает доли.

В Go GOMAXPROCS учитывает только CPU-квоту, но не доли.

Можно вручную установить GOMAXPROCS с помощью функции runtime.GOMAXPROCS. В этом случае рантайм будет использовать заданное вами значение и не будет его менять:

runtime.GOMAXPROCS(4)
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
GOMAXPROCS: 4

Установленное вручную значение GOMAXPROCS можно отменить. Чтобы вернуть значение по умолчанию, используйте новую функцию runtime.SetDefaultGOMAXPROCS:

GOMAXPROCS=2 go1.25rc1 run nproc.go
// Используем переменную окружения.
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))

// Использую ручное значение.
runtime.GOMAXPROCS(4)
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))

// Возвращаем значение по умолчанию.
runtime.SetDefaultGOMAXPROCS()
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
GOMAXPROCS: 2
GOMAXPROCS: 4
GOMAXPROCS: 8

Чтобы сохранить обратную совместимость, новое поведение GOMAXPROCS работает только если в go.mod указана версия Go 1.25 или выше. Также его можно отключить вручную с помощью настроек GODEBUG:

  • containermaxprocs=0 — игнорировать CPU-квоту.
  • updatemaxprocs=0 — не обновлять GOMAXPROCS автоматически.

𝗣 73193 • 𝗖𝗟 668638, 670497, 672277, 677037

Материал дополняется.

★ Подписывайтесь на новые заметки.