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

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

synctestjson/v2GOMAXPROCSНовый GCАнти-CSRFWaitGroup.GoFlightRecorderos.Rootreflect.TypeAssertT.Attrslog.GroupAttrshash.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

# Новый сборщик мусора

Некоторые гоферы любят пошутить про Java и ее многочисленные сборщики мусора. Теперь будет не до смеха — в Go 1.25 появился новый сборщик мусора.

Алгоритм сборки мусора под кодовым названием Green Tea хорошо подходит для программ, которые создают много маленьких объектов и работают на современных машинах с большим количеством ядер.

Старый сборщик мусора сканирует память не по порядку, а скачет туда-сюда. Из-за этого все работает неоптимально, потому что много времени уходит на доступ к памяти. Проблема усугубляется на многоядерных системах с неоднородным доступом к памяти (так называемая NUMA-архитектура, когда у каждого процессора или группы процессоров есть своя «локальная» память).

Green Tea работает иначе. Вместо того чтобы сканировать отдельные маленькие объекты, он сканирует память большими, непрерывными блоками — спанами (spans). Каждый спан содержит много маленьких объектов одного размера. Благодаря работе с большими блоками GC может сканировать память быстрее и лучше использовать кэш процессора.

Результаты бенчмарков разнятся, но команда Go ожидает, что в реальных программах с большим количеством GC затраты на сборку мусора снизятся на 10–40%.

Я провел небольшой тест: сделал 1 000 000 операций чтения и записи в Redka (это мой клон Redis, написанный на Go). Общее время на сборку мусора было примерно одинаковым на старом и новом алгоритмах. Но Редька, вероятно, не самый удачный пример — она в основном полагается на SQLite, а действий в Go-коде там относительно немного.

Новый сборщик мусора пока экспериментальный, включается переменной окружения GOEXPERIMENT=greenteagc при сборке. Дизайн и реализация сборщика могут измениться в будущих версиях. Подробности реализации и обратная связь по работе GC — по ссылкам ниже.

𝗣 73581Feedback

# Анти-CSRF

Новый тип http.CrossOriginProtection защищает от CSRF-атак, отклоняя небезопасные кросс-доменные запросы из браузера.

Кросс-доменные запросы определяются так:

  • Проверкой по заголовку Sec-Fetch-Site.
  • Сравнением домена в заголовке Origin с доменом в заголовке Host.

Вот пример, где мы включаем CrossOriginProtection и явно разрешаем несколько дополнительных источников:

// Регистрируем пару обработчиков.
mux := http.NewServeMux()
mux.HandleFunc("GET /get", func(w http.ResponseWriter, req *http.Request) {
    io.WriteString(w, "ok\n")
})
mux.HandleFunc("POST /post", func(w http.ResponseWriter, req *http.Request) {
    io.WriteString(w, "ok\n")
})

// Настраиваем защиту от CSRF-атак.
antiCSRF := http.NewCrossOriginProtection()
antiCSRF.AddTrustedOrigin("https://example.com")
antiCSRF.AddTrustedOrigin("https://*.example.com")

// Подключаем защиту ко всем обработчикам.
srv := http.Server{
    Addr:    ":8080",
    Handler: antiCSRF.Handler(mux),
}
log.Fatal(srv.ListenAndServe())

Теперь, если браузер отправит запрос с того же домена, что и сервер, сервер его разрешит:

curl --data "ok" -H "sec-fetch-site:same-origin" localhost:8080/post
ok

Если браузер отправит кросс-доменный запрос, сервер его отклонит:

curl --data "ok" -H "sec-fetch-site:cross-site" localhost:8080/post
cross-origin request detected from Sec-Fetch-Site header

Если заголовок Origin не соответствует заголовку Host, сервер отклонит запрос:

curl --data "ok" \
  -H "origin:https://evil.com" \
  -H "host:antonz.org" \
  localhost:8080/post
cross-origin request detected, and/or browser is out of date:
Sec-Fetch-Site is missing, and Origin does not match Host

Если запрос придет из доверенного источника, сервер его разрешит:

curl --data "ok" \
  -H "origin:https://example.com" \
  -H "host:antonz.org" \
  localhost:8080/post
ok

Сервер всегда разрешает методы GET, HEAD и OPTIONS, поскольку они безопасны:

curl -H "origin:https://evil.com" localhost:8080/get
ok

Сервер всегда разрешает запросы без заголовков Sec-Fetch-Site или Origin (такой запрос не придет из браузера):

curl --data "ok" localhost:8080/post
ok

𝗣 73626 • 𝗖𝗟 674936, 680396

# Go в группе ожидания

Все знают, как дождаться выполнения горутины с помощью группы ожидания:

var wg sync.WaitGroup

wg.Add(1)
go func() {
    defer wg.Done()
    fmt.Println("go is awesome")
}()

wg.Add(1)
go func() {
    defer wg.Done()
    fmt.Println("cats are cute")
}()

wg.Wait()
fmt.Println("done")
cats are cute
go is awesome
done

Новый метод WaitGroup.Go сам увеличивает счетчик группы, запускает функцию в горутине, и уменьшает счетчик, когда функция завершилась. Теперь пример выше можно переписать без использования wg.Add() и wg.Done():

var wg sync.WaitGroup

wg.Go(func() {
    fmt.Println("go is awesome")
})

wg.Go(func() {
    fmt.Println("cats are cute")
})

wg.Wait()
fmt.Println("done")
cats are cute
go is awesome
done

Реализация именно такая, как вы думаете:

// https://github.com/golang/go/blob/master/src/sync/waitgroup.go
func (wg *WaitGroup) Go(f func()) {
    wg.Add(1)
    go func() {
        defer wg.Done()
        f()
    }()
}

Забавно, что команде Go понадобилось 13 лет, чтобы добавить логичную обертку для Add+Done. Ну да ладно, лучше поздно, чем никогда!

𝗣 63796 • 𝗖𝗟 662635

# Flight recorder

Flight recording — это способ трассировки, который собирает данные о выполнении программы (например, вызовы функций и выделение памяти) со скользящим окном по времени или размеру трейса. Это помогает компактно записывать важные моменты в работе программы, даже если заранее неизвестно, когда они произойдут.

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

Вот пример использования.

Сначала настраиваем скользящее окно:

// Сохранять как минимум 5 последних секунд трассировки,
// с размером буфера не более 3 МБ.
// Это только рекомендации, а не строгие ограничения.
cfg := trace.FlightRecorderConfig{
    MinAge:   5 * time.Second,
    MaxBytes: 3 << 20, // 3MB
}

Затем создаем трассировщик и запускаем его:

// Создаем и запускаем трассировщик.
rec := trace.NewFlightRecorder(cfg)
rec.Start()
defer rec.Stop()

И пишем обычный код приложения:

// Имитируем какую-то работу.
done := make(chan struct{})
go func() {
    defer close(done)
    const n = 1 << 20
    var s []int
    for range n {
        s = append(s, rand.IntN(n))
    }
    fmt.Printf("done filling slice of %d elements\n", len(s))
}()
<-done

Наконец, сохраняем трассировку в файл, когда происходит важное событие:

// Сохраняем снепшот трассировки в файл.
file, _ := os.Create("/tmp/trace.out")
defer file.Close()
n, _ := rec.WriteTo(file)
fmt.Printf("wrote %dB to trace file\n", n)
done filling slice of 1048576 elements
wrote 8441B to trace file

Посмотреть трассировку в браузере можно командой go tool:

go1.25rc1 tool trace /tmp/trace.out

𝗣 63185 • 𝗖𝗟 673116

# Больше Root-методов

Тип os.Root ограничивает работу с файловой системой конкретным каталогом. Теперь он поддерживает несколько новых методов, аналогичных функциям пакета os.

Chmod меняет права доступа к файлу:

root, _ := os.OpenRoot("data")
root.Chmod("01.txt", 0600)

finfo, _ := root.Stat("01.txt")
fmt.Println(finfo.Mode().Perm())
-rw-------

Chown меняет идентификатор пользователя (uid) и группы (gid) файла:

root, _ := os.OpenRoot("data")
root.Chown("01.txt", 1000, 1000)

finfo, _ := root.Stat("01.txt")
stat := finfo.Sys().(*syscall.Stat_t)
fmt.Printf("uid=%d, gid=%d\n", stat.Uid, stat.Gid)
uid=1000, gid=1000

Chtimes меняет время последнего доступа и изменения файла:

root, _ := os.OpenRoot("data")
mtime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
atime := time.Now()
root.Chtimes("01.txt", atime, mtime)

finfo, _ := root.Stat("01.txt")
fmt.Println(finfo.ModTime())
2020-01-01 00:00:00 +0000 UTC

Link создает жесткую ссылку на файл:

root, _ := os.OpenRoot("data")
root.Link("01.txt", "hardlink.txt")

finfo, _ := root.Stat("hardlink.txt")
fmt.Println(finfo.Name())
hardlink.txt

MkdirAll создает новый каталог и все родительские каталоги при необходимости:

const dname = "path/to/secret"

root, _ := os.OpenRoot("data")
root.MkdirAll(dname, 0750)

finfo, _ := root.Stat(dname)
fmt.Println(dname, finfo.Mode())
path/to/secret drwxr-x---

RemoveAll удаляет файл или каталог со всем содержимым:

root, _ := os.OpenRoot("data")
root.RemoveAll("01.txt")

finfo, err := root.Stat("01.txt")
fmt.Println(finfo, err)
<nil> statat 01.txt: no such file or directory

Rename переименовывает (перемещает) файл или каталог:

const oldname = "01.txt"
const newname = "go.txt"

root, _ := os.OpenRoot("data")
root.Rename(oldname, newname)

_, err := root.Stat(oldname)
fmt.Println(err)

finfo, _ := root.Stat(newname)
fmt.Println(finfo.Name())
statat 01.txt: no such file or directory
go.txt

Symlink создает символическую ссылку на файл. Readlink возвращает путь, на который указывает эта ссылка:

const lname = "symlink.txt"

root, _ := os.OpenRoot("data")
root.Symlink("01.txt", lname)

lpath, _ := root.Readlink(lname)
fmt.Println(lname, "->", lpath)
symlink.txt -> 01.txt

WriteFile записывает данные в файл, создавая его при необходимости. ReadFile читает файл и возвращает его содержимое:

const fname = "go.txt"

root, _ := os.OpenRoot("data")
root.WriteFile(fname, []byte("go is awesome"), 0644)

content, _ := root.ReadFile(fname)
fmt.Printf("%s: %s\n", fname, content)
go.txt: go is awesome

Теперь, когда os.Root содержит все основные операции, вам вряд ли понадобятся файловые функции пакета os. Это делает работу с файлами намного безопаснее.

Кстати о файловых системах. os.DirFS() (файловая система с корнем в указанной папке) и os.Root.FS() (файловая система для дерева файлов в корне) теперь реализуют новый интерфейс fs.ReadLinkFS. В этом интерфейсе два метода — ReadLink и Lstat.

ReadLink возвращает путь, на который указывает символическая ссылка:

const lname = "symlink.txt"

root, _ := os.OpenRoot("data")
root.Symlink("01.txt", lname)

fsys := root.FS().(fs.ReadLinkFS)
lpath, _ := fsys.ReadLink(lname)
fmt.Println(lname, "->", lpath)
symlink.txt -> 01.txt

Удивляет, конечно, разница в написании os.Root.Readlink и fs.ReadLinkFS.ReadLink.

Lstat возвращает информацию о файле или символической ссылке:

fsys := os.DirFS("data").(fs.ReadLinkFS)
finfo, _ := fsys.Lstat("01.txt")

fmt.Printf("name:  %s\n", finfo.Name())
fmt.Printf("size:  %dB\n", finfo.Size())
fmt.Printf("mode:  %s\n", finfo.Mode())
fmt.Printf("mtime: %s\n", finfo.ModTime().Format(time.DateOnly))
name:  01.txt
size:  11B
mode:  -rw-r--r--
mtime: 2025-06-22

Вот и все по пакету os!

𝗣 49580, 67002, 73126 • 𝗖𝗟 645718, 648295, 649515, 649536, 658995, 659416, 659757, 660635, 661595, 674116, 674315, 676135

# Рефлексивное приведение типа

Чтобы преобразовать reflect.Value обратно в нужный тип, обычно используют метод Value.Interface() вместе с приведением типа:

alice := &Person{"Alice", 25}

// Взяли рефлексивное Value...
aliceVal := reflect.ValueOf(alice).Elem()
// ...и привели обратно к типу Person.
person, _ := aliceVal.Interface().(Person)

fmt.Printf("Name: %s, Age: %d\n", person.Name, person.Age)
Name: Alice, Age: 25

Теперь вместо этого можно использовать новую дженерик-функцию reflect.TypeAssert:

alice := &Person{"Alice", 25}

// Взяли рефлексивное Value...
aliceVal := reflect.ValueOf(alice).Elem()
// ...и привели обратно к типу Person.
person, _ := reflect.TypeAssert[Person](aliceVal)

fmt.Printf("Name: %s, Age: %d\n", person.Name, person.Age)
Name: Alice, Age: 25

Такой способ более идиоматичен и не тратит лишнюю память, потому что значение не упаковывается в интерфейс.

𝗣 62121 • 𝗖𝗟 648056

# Атрибуты и вывод тестов

Новый метод T.Attr добавляет дополнительную информацию к тесту. Например, ссылку на задачу, описание тест-кейса или что-то еще, что поможет анализировать результаты тестов:

func TestAttrs(t *testing.T) {
    t.Attr("issue", "demo-1234")
    t.Attr("description", "Testing for the impossible")

    if 21*2 != 42 {
        t.Fatal("What in the world happened to math?")
    }
}
=== RUN   TestAttrs
=== ATTR  TestAttrs issue demo-1234
=== ATTR  TestAttrs description Testing for the impossible
--- PASS: TestAttrs (0.00s)

Атрибуты особенно полезны в JSON-формате, если вы отправляете результаты тестов в CI или другую систему для автоматической обработки:

go1.25rc1 test -json -run=.
...
{
    "Time":"2025-06-25T20:34:16.831401+00:00",
    "Action":"attr",
    "Package":"sandbox",
    "Test":"TestAttrs",
    "Key":"issue",
    "Value":"demo-1234"
}
...
{
    "Time":"2025-06-25T20:34:16.831415+00:00",
    "Action":"attr",
    "Package":"sandbox",
    "Test":"TestAttrs",
    "Key":"description",
    "Value":"Testing for the impossible"
}
...

Вывод тестов отформатирован для удобства чтения.

Такой же метод Attr есть в testing.B и testing.F.

𝗣 43936 • 𝗖𝗟 662437

Новый метод T.Output предоставляет доступ к потоку вывода (io.Writer), который использует тест. Удобно, если вы хотите отправлять логи приложения прямо в лог теста — так их проще читать или автоматически анализировать:

func TestLog(t *testing.T) {
    t.Log("test message 1")
    t.Log("test message 2")
    appLog := slog.New(slog.NewTextHandler(t.Output(), nil))
    appLog.Info("app message")
}
=== RUN   TestLog
    main_test.go:12: test message 1
    main_test.go:13: test message 2
    time=2025-06-25T16:14:34.085Z level=INFO msg="app message"
--- PASS: TestLog (0.00s)

Такой же метод Output есть в testing.B и testing.F.

𝗣 59928 • 𝗖𝗟 672395, 677875

И последнее: функция testing.AllocsPerRun теперь вызывает панику, если тесты выполняются параллельно.

Сравните поведение версии 1.24:

// go 1.24
func TestAllocs(t *testing.T) {
    t.Parallel()
    allocs := testing.AllocsPerRun(100, func() {
        var s []int
        // Do some allocations.
        for i := range 1024 {
            s = append(s, i)
        }
    })
    t.Log("Allocations per run:", allocs)
}
=== RUN   TestAllocs
=== PAUSE TestAllocs
=== CONT  TestAllocs
    main_test.go:21: Allocations per run: 12
--- PASS: TestAllocs (0.00s)

И 1.25:

// go 1.25
func TestAllocs(t *testing.T) {
    t.Parallel()
    allocs := testing.AllocsPerRun(100, func() {
        var s []int
        // Do some allocations.
        for i := range 1024 {
            s = append(s, i)
        }
    })
    t.Log("Allocations per run:", allocs)
}
=== RUN   TestAllocs
=== PAUSE TestAllocs
=== CONT  TestAllocs
--- FAIL: TestAllocs (0.00s)
panic: testing: AllocsPerRun called during parallel test [recovered, repanicked]

Дело в том, что результат AllocsPerRun не гарантирован, если параллельно идут несколько тестов. Поэтому и добавили новое поведение с паникой — оно должно помочь ловить такие баги.

𝗣 70464 • 𝗖𝗟 630137

# Группировка атрибутов в логах

При структурном логировании связанные атрибуты часто объединяют под одним ключом:

logger.Info("deposit",
    slog.Bool("ok", true),
    slog.Group("amount",
        slog.Int("value", 1000),
        slog.String("currency", "USD"),
    ),
)
msg=deposit ok=true amount.value=1000 amount.currency=USD

Это работает, но только если не пытаться сначала собрать атрибуты, а потом формировать группу:

attrs := []slog.Attr{
    slog.Int("value", 1000),
    slog.String("currency", "USD"),
}
logger.Info("deposit",
	slog.Bool("ok", true),
	slog.Group("amount", attrs...),
)
cannot use attrs (variable of type []slog.Attr)
as []any value in argument to slog.Group
(exit status 1)

slog.Group ожидает срез значений типа any, поэтому он не принимает срез slog.Attr.

Новая функция slog.GroupAttrs решает эту проблему, создавая группу из переданных slog.Attr:

attrs := []slog.Attr{
    slog.Int("value", 1000),
    slog.String("currency", "USD"),
}
logger.Info("deposit",
    slog.Bool("ok", true),
    slog.GroupAttrs("amount", attrs...),
)
msg=deposit ok=true amount.value=1000 amount.currency=USD

Мелочь, но может пригодиться.

𝗣 66365 • 𝗖𝗟 672915

# Клон хеша

Новый интерфейс hash.Cloner позволяет хеш-функции возвращать копию текущего состояния:

// https://github.com/golang/go/blob/master/src/hash/hash.go
type Cloner interface {
    Hash
    Clone() (Cloner, error)
}

Теперь все стандартные реализации hash.Hash поддерживают функцию Clone: MD5, SHA-1, SHA-3, FNV-1, CRC-64 и другие.

h1 := sha3.New256()
h1.Write([]byte("hello"))

clone, _ := h1.Clone()
h2 := clone.(*sha3.SHA3)

// состояние h2 такое же, как h1, поэтому после
// записи одинаковых данных получится одинаковый хеш.
h1.Write([]byte("world"))
h2.Write([]byte("world"))

fmt.Printf("h1: %x\n", h1.Sum(nil))
fmt.Printf("h2: %x\n", h2.Sum(nil))
fmt.Printf("h1 == h2: %t\n", reflect.DeepEqual(h1, h2))
h1: 92dad9443e4dd6d70a7f11872101ebff87e21798e4fbb26fa4bf590eb440e71b
h2: 92dad9443e4dd6d70a7f11872101ebff87e21798e4fbb26fa4bf590eb440e71b
h1 == h2: true

𝗣 69521 • 𝗖𝗟 675197

# Заключение

Go 1.25 облегчает тестирование многозадачного кода, вводит новый экспериментальный пакет для работы с JSON, и улучшает работу рантайма благодаря новой реализации GOMAXPROCS и сборщика мусора. Также появился компактный трассировщик, современная защита от CSRF, долгожданный метод Go для групп ожидания и еще несколько улучшений.

В целом, отличный релиз!

P.S. Хотите лучше разобраться в Go? Участвуйте в курсе по многозадачности

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