golang

Большой обзор релиза Go 1.26

  • вторник, 27 января 2026 г. в 00:00:08
https://habr.com/ru/articles/988540/

Команда Go for Devs подготовила перевод большого обзора Go 1.26. Это один из самых масштабных релизов языка: серьёзные оптимизации производительности, улучшения стандартной библиотеки, новые инструменты для тестирования и логирования, а также обновлённый go fix. Разбираем, что именно изменилось и почему этот релиз важен для разработчиков.


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

Читайте дальше и смотрите сами!

Эта статья основана на официальных примечаниях к релизу от The Go Authors и исходном коде Go, распространяемом по лицензии BSD-3-Clause. Это не исчерпывающий список — за полной информацией обращайтесь к официальным примечаниям к релизу.

Я привожу ссылки на документацию (𝗗), предложения (𝗣), коммиты (𝗖𝗟) и авторов (𝗔) для описываемых возможностей. Загляните туда, чтобы узнать мотивацию, способы использования и детали реализации. Для некоторых возможностей у меня также есть отдельные гайды (𝗚).

Обработка ошибок часто опускается ради простоты. Не делайте так в продакшене ツ

new(expr)

Раньше встроенную функцию new можно было использовать только с типами:

p := new(int)
*p = 42
fmt.Println(*p)
//Результат исполнения: 42

Теперь её можно применять и к выражениям:

// Pointer to a int variable with the value 42.
p := new(42)
fmt.Println(*p)
//Результат исполнения: 42

Если аргумент expr — это выражение типа T, то new(expr) выделяет переменную типа T, инициализирует её значением expr и возвращает её адрес — значение типа *T.

Эта возможность особенно полезна, если вы используете указательные поля в структурах для представления опциональных значений, которые затем маршалите в JSON или Protobuf:

type Cat struct {
    Name string `json:"name"`
    Fed  *bool  `json:"is_fed"` // you can never be sure
}

cat := Cat{Name: "Mittens", Fed: new(true)}
data, _ := json.Marshal(cat)
fmt.Println(string(data))

Результат исполнения:

{"name":"Mittens","is_fed":true}

new можно использовать и с составными значениями:

s := new([]int{11, 12, 13})
fmt.Println(*s)

type Person struct{ name string }
p := new(Person{name: "alice"})
fmt.Println(*p)

Результат исполнения:

[11 12 13]
{alice}

А также с результатами вызова функций:

f := func() string { return "go" }
p := new(f())
fmt.Println(*p)

Результат исполнения:

go

Передача nil по-прежнему запрещена:

p := new(nil)
// compilation error

𝗗 spec • 𝗣 45624 • 𝗖𝗟 704935704737704955705157 • 𝗔 Alan Donovan

Рекурсивные ограничения типов

Обобщённые функции и типы принимают типы в качестве параметров:

// A list of values.
type List[T any] struct {}

// Reverses a slice in-place.
func Reverse[T any](s []T)

Эти параметры типов можно дополнительно ограничивать с помощью ограничений типов:

// The map key must have a comparable type.
type Map[K comparable, V any] struct {}

// S is a slice with values of a comparable type,
// or a type derived from such a slice (e.g., type MySlice []int).
func Compact[S ~[]E, E comparable](s S) S

Раньше ограничения типов не могли прямо или косвенно ссылаться обратно на обобщённый тип:

type T[P T[P]] struct{}
// compile error:
// invalid recursive type: T refers to itself

Теперь это разрешено:

type T[P T[P]] struct{}

Результат исполнения:

ok

Типичный сценарий использования — обобщённый тип, который поддерживает операции с аргументами или результатами того же типа, что и он сам:

// A value that can be compared to other values
// of the same type using the less-than operation.
type Ordered[T Ordered[T]] interface {
    Less(T) bool
}

Теперь можно создать обобщённый контейнер с упорядоченными значениями и использовать его с любым типом, реализующим Less:

// A tree stores comparable values.
type Tree[T Ordered[T]] struct {
    nodes []T
}

// netip.Addr has a Less method with the right signature,
// so it meets the requirements for Ordered[netip.Addr].
t := Tree[netip.Addr]{}
_ = t

Результат исполнения:

ok

Это делает обобщения в Go немного более выразительными.

𝗣 68162, 75883 • 𝗖𝗟 711420, 711422 • 𝗔 Robert Griesemer

Типобезопасная проверка ошибок

Новая функция errors.AsType — это обобщённая версия errors.As:

// go 1.13+
func As(err error, target any) bool
// go 1.26+
func AsType[E error](err error) (E, bool)

Она типобезопасна и проще в использовании:

// using errors.As
var target *AppError
if errors.As(err, &target) {
    fmt.Println("application error:", target)
}

Результат использования:

application error: database is down
// using errors.AsType
if target, ok := errors.AsType[*AppError](err); ok {
    fmt.Println("application error:", target)
}

Результат использования:

application error: database is down

AsType особенно удобна при проверке ошибок нескольких типов. Она делает код короче и ограничивает область видимости переменных ошибок соответствующими блоками if:

if connErr, ok := errors.AsType[*net.OpError](err); ok {
    fmt.Println("Network operation failed:", connErr.Op)
} else if dnsErr, ok := errors.AsType[*net.DNSError](err); ok {
    fmt.Println("DNS resolution failed:", dnsErr.Name)
} else {
    fmt.Println("Unknown error")
}

Результат использования:

DNS resolution failed: antonz.org

Ещё одна проблема As в том, что она использует рефлексию и может привести к панике во время выполнения при неправильном использовании (например, если передать не указатель или тип, не реализующий error):

// using errors.As
var target AppError
if errors.As(err, &target) {
    fmt.Println("application error:", target)
}

Результат использования:

panic: errors: *target must be interface or implement error

goroutine 1 [running]:
errors.As({0x4e8418, 0xc000098020}, {0x4a97a0, 0xc000098030?})
	/usr/local/go/src/errors/wrap.go:111 +0x205
main.main()
	/tmp/sandbox/main.go:25 +0x65 (exit status 2)

AsType не вызывает паники во время выполнения — вместо этого она даёт понятную ошибку на этапе компиляции:

// using errors.AsType
if target, ok := errors.AsType[AppError](err); ok {
    fmt.Println("application error:", target)
}

Результат использования:

# sandbox
./main.go:24:32: AppError does not satisfy error (method Error has pointer receiver) (exit status 1)

AsType не использует reflect, работает быстрее и делает меньше аллокаций по сравнению с As:

goos: darwin
goarch: arm64
cpu: Apple M1
BenchmarkAs-8        12606744    95.62 ns/op    40 B/op    2 allocs/op
BenchmarkAsType-8    37961869    30.26 ns/op    24 B/op    1 allocs/op

source

Поскольку AsType покрывает все возможности As, она рекомендуется в качестве прямой замены для нового кода.

𝗗 errors.AsType • 𝗣 51945 • 𝗖𝗟 707235 • 𝗔 Julien Cretel

Сборщик мусора Green Tea

Новый сборщик мусора (впервые представлен как экспериментальный в версии 1.25) спроектирован так, чтобы сделать управление памятью более эффективным на современных компьютерах с большим количеством CPU-ядер.

Мотивация

Традиционный алгоритм сборки мусора в Go работает с графом, рассматривая объекты как узлы, а указатели — как рёбра, не учитывая их физическое расположение в памяти. Сканер постоянно прыгает между удалёнными участками памяти, что приводит к частым промахам кэша.

В результате CPU тратит слишком много времени, ожидая загрузки данных из памяти. Более 35% времени, затрачиваемого на сканирование памяти, уходит впустую — процессор просто простаивает, ожидая завершения обращений к памяти. С ростом числа CPU-ядер эта проблема только усугубляется.

Реализация

Green Tea смещает фокус с процессора на память. Вместо сканирования отдельных объектов он сканирует память в виде непрерывных блоков по 8 КиБ, называемых span’ами. Алгоритм сосредоточен на малых объектах (до 512 байт), поскольку они встречаются чаще всего и их сложнее всего эффективно сканировать.

Каждый span делится на равные слоты в соответствии с назначенным ему классом размера и содержит только объекты этого класса. Например, если span относится к классу размера 32 байта, весь блок разбивается на слоты по 32 байта, и объекты размещаются непосредственно в этих слотах, начиная строго с их начала. Благодаря такому фиксированному расположению сборщик мусора может легко находить метаданные объекта с помощью простой арифметики адресов, не проверяя размер каждого найденного объекта.

Когда алгоритм находит объект, который нужно просканировать, он помечает его расположение в соответствующем span’е, но не сканирует сразу. Вместо этого он ждёт, пока в том же span’е наберётся несколько объектов, требующих сканирования. Затем, когда сборщик мусора обрабатывает этот span, он сканирует сразу несколько объектов. Это значительно быстрее, чем многократно проходить по одному и тому же участку памяти.

Чтобы эффективнее использовать CPU-ядра, GC-воркеры распределяют нагрузку между собой, воруя задачи друг у друга. У каждого воркера есть собственная локальная очередь span’ов для сканирования, и если воркер простаивает, он может забрать задачи из очередей других, более загруженных воркеров. Такой децентрализованный подход устраняет необходимость в центральном глобальном списке, предотвращает задержки и снижает конкуренцию между CPU-ядрами.

Green Tea использует векторные инструкции CPU (только на архитектуре amd64) для пакетной обработки span’ов памяти, когда объектов достаточно много.

Бенчмарки

Результаты бенчмарков различаются, но команда Go ожидает снижения накладных расходов на сборку мусора на 10–40% в реальных программах, которые активно полагаются на GC. Кроме того, при векторной реализации ожидается дополнительное снижение накладных расходов GC примерно на 10% на процессорах вроде Intel Ice Lake или AMD Zen 4 и новее.

К сожалению, мне не удалось найти публичные результаты бенчмарков от команды Go для последней версии Green Tea, и у меня самого не получилось сделать хороший синтетический бенчмарк. Так что в этот раз без подробностей :(

Новый сборщик мусора включён по умолчанию. Чтобы использовать старый сборщик, нужно задать GOEXPERIMENT=nogreenteagc во время сборки (ожидается, что эта возможность будет удалена в Go 1.27).

𝗣 73581 • 𝗔 Michael Knyszek

Более быстрые cgo и системные вызовы

В рантайме Go процессор (часто называемый P) — это ресурс, необходимый для выполнения кода. Чтобы поток (machine, или M) мог выполнить горутину (G), он должен сначала захватить процессор.

Процессоры переходят между разными состояниями. Они могут быть в состоянии Prunning (выполняют код), Pidle (ожидают работу) или _Pgcstop (приостановлены из-за сборки мусора).

Раньше существовало состояние _Psyscall, которое использовалось, когда горутина выполняла системный вызов или вызов cgo. Теперь это состояние удалено. Вместо отдельного состояния процессора система проверяет состояние горутины, закреплённой за процессором, чтобы определить, участвует ли она в системном вызове.

Это снижает внутренние накладные расходы рантайма и упрощает пути выполнения для cgo и системных вызовов. В примечаниях к релизу Go указано снижение накладных расходов рантайма cgo на 30%, а в коммите упоминается улучшение на 18% по показателю sec/op:

goos: linux
goarch: amd64
pkg: internal/runtime/cgobench
cpu: AMD EPYC 7B13
                   │ before.out  │             after.out              │
                   │   sec/op    │   sec/op     vs base               │
CgoCall-64           43.69n ± 1%   35.83n ± 1%  -17.99% (p=0.002 n=6)
CgoCallParallel-64   5.306n ± 1%   5.338n ± 1%        ~ (p=0.132 n=6)

Я также решил прогнать бенчмарки CgoCall локально:

goos: darwin
goarch: arm64
cpu: Apple M1
                      │ go1_25.txt  │             go1_26.txt              │
                      │   sec/op    │   sec/op     vs base                │
CgoCall-8               28.55n ± 4%   19.02n ± 2%  -33.40% (p=0.000 n=10)
CgoCallWithCallback-8   72.76n ± 5%   57.38n ± 2%  -21.14% (p=0.000 n=10)
geomean                 45.58n        33.03n       -27.53%

В любом случае и 20%, и 30% прироста выглядят весьма впечатляюще.

А вот результаты локального бенчмарка системных вызовов:

goos: darwin
goarch: arm64
cpu: Apple M1
          │ go1_25.txt  │             go1_26.txt             │
          │   sec/op    │   sec/op     vs base               │
Syscall-8   195.6n ± 4%   178.1n ± 1%  -8.95% (p=0.000 n=10)
source
func BenchmarkSyscall(b *testing.B) {
    for b.Loop() {
        _, _, _ = syscall.Syscall(syscall.SYS_GETPID, 0, 0, 0)
    }
}

Это тоже весьма неплохо.

𝗖𝗟 646198 • 𝗔 Michael Knyszek

Более быстрая аллокация памяти

В рантайме Go появились специализированные версии функции аллокации памяти для малых объектов (от 1 до 512 байт). Вместо одной универсальной реализации используются jump-таблицы, которые позволяют быстро выбрать подходящую функцию для каждого размера.

В примечаниях к релизу Go сказано, что «компилятор теперь генерирует вызовы специализированных по размеру процедур аллокации памяти». Однако, судя по коду, это не совсем так: компилятор по-прежнему генерирует вызовы универсальной функции mallocgc. Уже во время выполнения mallocgc перенаправляет эти вызовы на новые специализированные функции аллокации.

Это изменение снижает стоимость аллокации памяти для малых объектов вплоть до 30%. Команда Go ожидает, что в реальных программах с интенсивными аллокациями суммарный выигрыш составит около 1%.

Готовых бенчмарков я не нашёл, поэтому написал свой. И действительно, сравнение Go 1.25 и 1.26 показывает заметное улучшение:

goos: darwin
goarch: arm64
cpu: Apple M1
           │  go1_25.txt   │              go1_26.txt              │
           │    sec/op     │    sec/op     vs base                │
Alloc1-8      8.190n ±  6%   6.594n ± 28%  -19.48% (p=0.011 n=10)
Alloc8-8      8.648n ± 16%   7.522n ±  4%  -13.02% (p=0.000 n=10)
Alloc64-8     15.70n ± 15%   12.57n ±  4%  -19.88% (p=0.000 n=10)
Alloc128-8    56.80n ±  4%   17.56n ±  4%  -69.08% (p=0.000 n=10)
Alloc512-8    81.50n ± 10%   55.24n ±  5%  -32.23% (p=0.000 n=10)
geomean       21.99n         14.33n        -34.83%
source
var sink *byte

func benchmarkAlloc(b *testing.B, size int) {
    b.ReportAllocs()
    for b.Loop() {
        obj := make([]byte, size)
        sink = &obj[0]
    }
}

func BenchmarkAlloc1(b *testing.B)   { benchmarkAlloc(b, 1) }
func BenchmarkAlloc8(b *testing.B)   { benchmarkAlloc(b, 8) }
func BenchmarkAlloc64(b *testing.B)  { benchmarkAlloc(b, 64) }
func BenchmarkAlloc128(b *testing.B) { benchmarkAlloc(b, 128) }
func BenchmarkAlloc512(b *testing.B) { benchmarkAlloc(b, 512) }

Новая реализация включена по умолчанию. Её можно отключить, установив GOEXPERIMENT=nosizespecializedmalloc во время сборки (ожидается, что эта возможность будет удалена в Go 1.27).

𝗖𝗟 665835 • 𝗔 Michael Matloob

Векторные операции (экспериментально)

Новый пакет simd/archsimd предоставляет доступ к векторным операциям, специфичным для архитектуры (SIMD — single instruction, multiple data). Это низкоуровневый пакет, который напрямую открывает аппаратно-зависимые возможности. В текущей версии он поддерживает только платформы amd64.

Поскольку у разных архитектур CPU наборы SIMD-операций сильно отличаются, создать единый переносимый API для всех из них крайне сложно. Поэтому команда Go решила начать с низкоуровневого, архитектурно-специфичного API, чтобы дать «продвинутым пользователям» немедленный доступ к SIMD-возможностям на самой распространённой серверной платформе — amd64.

Пакет определяет векторные типы в виде структур, например Int8x16 (128-битный SIMD-вектор с шестнадцатью 8-битными целыми числами) и Float64x8 (512-битный SIMD-вектор с восемью 64-битными числами с плавающей точкой). Эти типы соответствуют аппаратным векторным регистрам. Поддерживаются векторы шириной 128, 256 и 512 бит.

Большинство операций реализованы как методы векторных типов. Обычно они напрямую отображаются на аппаратные инструкции и не имеют накладных расходов.

Чтобы дать общее представление, вот пользовательская функция, которая использует SIMD-инструкции для сложения векторов float32:

func Add(a, b []float32) []float32 {
    if len(a) != len(b) {
        panic("slices of different length")
    }

    // If AVX-512 isn't supported, fall back to scalar addition,
    // since the Float32x16.Add method needs the AVX-512 instruction set.
    if !archsimd.X86.AVX512() {
        return fallbackAdd(a, b)
    }

    res := make([]float32, len(a))
    n := len(a)
    i := 0

    // 1. SIMD loop: Process 16 elements at a time.
    for i <= n-16 {
        // Load 16 elements from a and b vectors.
        va := archsimd.LoadFloat32x16Slice(a[i : i+16])
        vb := archsimd.LoadFloat32x16Slice(b[i : i+16])

        // Add all 16 elements in a single instruction
        // and store the results in the result vector.
        vSum := va.Add(vb) // translates to VADDPS asm instruction
        vSum.StoreSlice(res[i : i+16])

        i += 16
    }

    // 2. Scalar tail: Process any remaining elements (0-15).
    for ; i < n; i++ {
        res[i] = a[i] + b[i]
    }

    return res
}

Попробуем применить её к двум векторам:

func main() {
    a := []float32{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17}
    b := []float32{17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1}
    res := Add(a, b)
    fmt.Println(res)
}

Результат исполнения:

[18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18]

Типичные операции в пакете archsimd включают:

  • Загрузка вектора из массива или slices и сохранение вектора обратно.

  • Арифметика: Add, Sub, Mul, Div, DotProduct.

  • Побитовые операции: And, Or, Not, Xor, Shift.

  • Сравнение: Equal, Greater, Less, Min, Max.

  • Преобразования: As, SaturateTo, TruncateTo.

  • Маскирование: Compress, Masked, Merge.

  • Перестановки: Permute.

Пакет использует только инструкции AVX, без SSE.

Ниже приведён простой бенчмарк сложения двух векторов (и «обычная», и SIMD-версии используют заранее выделенные slices):

goos: linux
goarch: amd64
cpu: AMD EPYC 9575F 64-Core Processor
BenchmarkAddPlain/1k-2         	 1517698	       889.9 ns/op	13808.74 MB/s
BenchmarkAddPlain/65k-2        	   23448	     52613 ns/op	14947.46 MB/s
BenchmarkAddPlain/1m-2         	    2047	   1005628 ns/op	11932.84 MB/s
BenchmarkAddSIMD/1k-2          	36594340	        33.58 ns/op	365949.74 MB/s
BenchmarkAddSIMD/65k-2         	  410742	      3199 ns/op	245838.52 MB/s
BenchmarkAddSIMD/1m-2          	   12955	     94228 ns/op	127351.33 MB/s

source

Пакет является экспериментальным и может быть включён при сборке с помощью GOEXPERIMENT=simd.

𝗗 simd/archsimd • 𝗣 73787 • 𝗖𝗟 701915, 712880, 729900, 732020 • 𝗔 Junyang Shao, Sean Liao, Tom Thorogood

Секретный режим (экспериментально)

В криптографических протоколах вроде WireGuard или TLS есть свойство, называемое forward secrecy (прямая секретность). Оно означает, что даже если злоумышленник получит доступ к долговременным секретам (например, к приватному ключу в TLS), он не должен иметь возможности расшифровать прошлые сеансы связи. Чтобы это работало, эфемерные ключи (временные ключи, используемые при согласовании сеанса) должны быть удалены из памяти сразу после завершения рукопожатия. Если нет надёжного способа очистить эту память, такие ключи могут оставаться в ней неопределённо долго. Злоумышленник, обнаруживший их позже, сможет заново вычислить ключ сеанса и расшифровать прошлый трафик, нарушив прямую секретность.

В Go управлением памятью занимается рантайм, и он не гарантирует, когда и каким образом память очищается. Чувствительные данные могут оставаться в аллокациях в куче или в кадрах стека и потенциально быть доступны через core dump’ы или атаки на память. Разработчикам часто приходится прибегать к ненадёжным «хакам» с использованием reflection, чтобы попытаться занулить внутренние буферы в криптографических библиотеках. Даже в этом случае часть данных может всё равно оставаться в памяти, до которой разработчик не может добраться или которую не может контролировать.

Решением этой проблемы от команды Go стал новый пакет runtime/secret. Он позволяет выполнять функцию в секретном режиме. После завершения функции все использованные ею регистры и стек немедленно очищаются (зануляются). Аллокации в куче, сделанные внутри функции, стираются, как только сборщик мусора решает, что они больше недостижимы.

secret.Do(func() {
    // Generate an ephemeral key and
    // use it to negotiate the session.
})

Это помогает гарантировать, что чувствительная информация не задерживается в памяти дольше необходимого, снижая риск её утечки.

Ниже приведён пример того, как secret.Do может использоваться в более-менее реалистичном сценарии. Допустим, вам нужно сгенерировать ключ сеанса, при этом сохранив эфемерный приватный ключ и общий секрет в безопасности:

// DeriveSessionKey does an ephemeral key exchange to create a session key.
func DeriveSessionKey(peerPublicKey *ecdh.PublicKey) (*ecdh.PublicKey, []byte, error) {
    var pubKey *ecdh.PublicKey
    var sessionKey []byte
    var err error

    // Use secret.Do to contain the sensitive data during the handshake.
    // The ephemeral private key and the raw shared secret will be
    // wiped out when this function finishes.
    secret.Do(func() {
        // 1. Generate an ephemeral private key.
        // This is highly sensitive; if leaked later, forward secrecy is broken.
        privKey, e := ecdh.P256().GenerateKey(rand.Reader)
        if e != nil {
            err = e
            return
        }

        // 2. Compute the shared secret (ECDH).
        // This raw secret is also highly sensitive.
        sharedSecret, e := privKey.ECDH(peerPublicKey)
        if e != nil {
            err = e
            return
        }

        // 3. Derive the final session key (e.g., using HKDF).
        // We copy the result out; the inputs (privKey, sharedSecret)
        // will be destroyed by secret.Do when they become unreachable.
        sessionKey = performHKDF(sharedSecret)
        pubKey = privKey.PublicKey()
    })

    // The session key is returned for use, but the "recipe" to recreate it
    // is destroyed. Additionally, because the session key was allocated
    // inside the secret block, the runtime will automatically zero it out
    // when the application is finished using it.
    return pubKey, sessionKey, err
}

Здесь эфемерный приватный ключ и «сырой» общий секрет — по сути «токсичные отходы»: они необходимы для получения итогового ключа сеанса, но опасны, если хранить их дольше нужного.

Если эти значения остаются в куче и злоумышленник позже получает доступ к памяти приложения (например, через core dump или уязвимость вроде Heartbleed), он может использовать эти промежуточные данные, чтобы заново вычислить ключ сеанса и расшифровать прошлые разговоры.

Оборачивая вычисления в secret.Do, мы гарантируем, что сразу после создания ключа сеанса «ингредиенты», использованные для его получения, будут безвозвратно уничтожены. Это означает, что даже если сервер будет скомпрометирован в будущем, конкретный прошедший сеанс не сможет быть раскрыт, что и обеспечивает прямую секретность.

func main() {
    // Generate a dummy peer public key.
    priv, _ := ecdh.P256().GenerateKey(nil)
    peerPubKey := priv.PublicKey()

    // Derive the session key.
    pubKey, sessionKey, err := DeriveSessionKey(peerPubKey)
    fmt.Printf("public key = %x...\n", pubKey.Bytes()[:16])
    fmt.Printf("error = %v\n", err)
    var _ = sessionKey
}

Результат исполнения:

public key = 04be6d81850aa01dd4c7791c9b010d67...
error = <nil>

Текущая реализация secret.Do поддерживает только Linux (amd64 и arm64). На неподдерживаемых платформах Do просто напрямую вызывает функцию. Кроме того, попытка запустить горутину внутри этой функции приводит к панике (это будет исправлено в Go 1.27).

Пакет runtime/secret в первую очередь предназначен для разработчиков криптографических библиотек. Большинству приложений следует использовать более высокоуровневые библиотеки, которые применяют secret.Do «под капотом».

Пакет является экспериментальным и может быть включён при сборке с помощью GOEXPERIMENT=runtimesecret.

𝗗 runtime/secret • 𝗣 21865 • 𝗖𝗟 704615 • 𝗔 Daniel Morsing

Криптография без Reader

Текущие криптографические API, такие как ecdsa.GenerateKey или rand.Prime, часто принимают io.Reader в качестве источника случайных данных:

// Generate a new ECDSA private key for the specified curve.
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
fmt.Println(key.D)

// Generate a 64-bit integer that is prime with high probability.
prim, _ := rand.Prime(rand.Reader, 64)
fmt.Println(prim)

Результат исполнения:

63459561958699859202861279970159813088587285149446501138245805690605894567784
18150677225130912587

Эти API не фиксируют конкретный способ использования случайных байт из reader’а. Любое изменение во внутренних криптографических алгоритмах может изменить последовательность или количество считываемых байт. Из-за этого, если код приложения (по ошибке) полагается на конкретную реализацию в версии Go X, он может сломаться или начать вести себя иначе в версии X+1.

Команда Go выбрала довольно смелое решение этой проблемы. Теперь большинство криптографических API просто игнорируют параметр io.Reader и всегда используют системный источник случайности (crypto/internal/sysrand.Read).

// The reader parameter is no longer used, so you can just pass nil.

// Generate a new ECDSA private key for the specified curve.
key, _ := ecdsa.GenerateKey(elliptic.P256(), nil)
fmt.Println(key.D)

// Generate a 64-bit integer that is prime with high probability.
prim, _ := rand.Prime(nil, 64)
fmt.Println(prim)

Результат исполнения:

29194021037979382293079548783664806377192214936991550411400608174450314100029
18216357171928885747

Изменение затрагивает следующие подмодули crypto:

// crypto/dsa
func GenerateKey(priv *PrivateKey, rand io.Reader) error

// crypto/ecdh
type Curve interface {
    // ...
    GenerateKey(rand io.Reader) (*PrivateKey, error)
}

// crypto/ecdsa
func GenerateKey(c elliptic.Curve, rand io.Reader) (*PrivateKey, error)
func SignASN1(rand io.Reader, priv *PrivateKey, hash []byte) ([]byte, error)
func Sign(rand io.Reader, priv *PrivateKey, hash []byte) (r, s *big.Int, err error)
func (priv *PrivateKey) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error)

// crypto/rand
func Prime(rand io.Reader, bits int) (*big.Int, error)

// crypto/rsa
func GenerateKey(random io.Reader, bits int) (*PrivateKey, error)
func GenerateMultiPrimeKey(random io.Reader, nprimes int, bits int) (*PrivateKey, error)
func EncryptPKCS1v15(random io.Reader, pub *PublicKey, msg []byte) ([]byte, error)

ed25519.GenerateKey(rand) по-прежнему использует переданный reader, если он задан. Но если rand равен nil, используется внутренний безопасный источник случайных байт вместо crypto/rand.Reader (который может быть переопределён).

Для поддержки детерминированного тестирования появился новый пакет testing/cryptotest с единственной функцией SetGlobalRandom. Она устанавливает глобальный детерминированный источник криптографической случайности на время выполнения теста:

func Test(t *testing.T) {
    cryptotest.SetGlobalRandom(t, 42)

    // All test runs will generate the same numbers.
    p1, _ := rand.Prime(nil, 32)
    p2, _ := rand.Prime(nil, 32)
    p3, _ := rand.Prime(nil, 32)

    got := [3]int64{p1.Int64(), p2.Int64(), p3.Int64()}
    want := [3]int64{3713413729, 3540452603, 4293217813}
    if got != want {
        t.Errorf("got %v, want %v", got, want)
    }
}

Результат исполнения:

PASS
ok  	sandbox	0.002s

SetGlobalRandom влияет на crypto/rand и все неявные источники криптографической случайности в пакетах crypto/*:

func Test(t *testing.T) {
    cryptotest.SetGlobalRandom(t, 42)

    t.Run("rand.Read", func(t *testing.T) {
        var got [4]byte
        rand.Read(got[:])
        want := [4]byte{34, 48, 31, 184}
        if got != want {
            t.Errorf("got %v, want %v", got, want)
        }
    })

    t.Run("rand.Int", func(t *testing.T) {
        got, _ := rand.Int(rand.Reader, big.NewInt(10000))
        const want = 6185
        if got.Int64() != want {
            t.Errorf("got %v, want %v", got.Int64(), want)
        }
    })
}

Результат исполнения:

PASS

Чтобы временно вернуть старое поведение с учётом переданного reader’а, можно установить GODEBUG=cryptocustomrand=1 (эта возможность будет удалена в одном из будущих релизов).

𝗗 testing/cryptotest • 𝗣 70942 • 𝗖𝗟 724480 • 𝗔 Filippo Valsorda, qiulaidongfeng

Гибридное шифрование с открытым ключом

Новый пакет crypto/hpke реализует Hybrid Public Key Encryption (HPKE) в соответствии с RFC 9180.

HPKE — относительно новый стандарт IETF для гибридного шифрования. Традиционные методы шифрования с открытым ключом, такие как RSA, медленны и подходят только для небольших объёмов данных. HPKE решает эту проблему, комбинируя два типа шифрования: асимметричную криптографию (публичные/приватные ключи) для безопасного установления общего секрета и быстрое симметричное шифрование для защиты самих данных. Это позволяет надёжно и эффективно шифровать большие файлы или сообщения, сохраняя при этом преимущества систем с открытым ключом.

«Асимметричная» часть HPKE (называемая механизмом инкапсуляции ключей — Key Encapsulation Mechanism, KEM) может использовать как традиционные алгоритмы, например на эллиптических кривых, так и новые постквантовые алгоритмы, такие как ML-KEM. ML-KEM спроектирован так, чтобы оставаться безопасным даже перед лицом будущих квантовых компьютеров, способных взломать классическую криптографию.

Я не буду делать вид, что являюсь экспертом в криптографии, поэтому ниже приведён пример прямо из документации стандартной библиотеки Go. В нём используется ML-KEM-X25519 для асимметричной криптографии (классический X25519 в сочетании с ML-KEM), AES-256 для симметричного шифрования и SHA-256 в качестве хеш-функции для ключей:

// Encrypt a single message from a sender to a recipient using the one-shot API.
kem, kdf, aead := hpke.MLKEM768X25519(), hpke.HKDFSHA256(), hpke.AES256GCM()

// Recipient side
var (
    recipientPrivateKey hpke.PrivateKey
    publicKeyBytes      []byte
)
{
    k, err := kem.GenerateKey()
    if err != nil {
        panic(err)
    }
    recipientPrivateKey = k
    publicKeyBytes = k.PublicKey().Bytes()
}

// Sender side
var ciphertext []byte
{
    publicKey, err := kem.NewPublicKey(publicKeyBytes)
    if err != nil {
        panic(err)
    }

    message := []byte("secret message")
    ct, err := hpke.Seal(publicKey, kdf, aead, []byte("public"), message)
    if err != nil {
        panic(err)
    }

    ciphertext = ct
}

// Recipient side
{
    plaintext, err := hpke.Open(recipientPrivateKey, kdf, aead, []byte("public"), ciphertext)
    if err != nil {
        panic(err)
    }
    fmt.Printf("Decrypted: %s\n", plaintext)
}

Результат исполнения:

Decrypted: secret message

Как отмечает Филиппо Вальсорда — криптограф, отвечающий за пакеты crypto в Go, — HPKE на сегодняшний день является правильным способом реализации шифрования с открытым ключом.

𝗗 crypto/hpke • 𝗣 75300 • 𝗔 Filippo Valsorda

Профиль утечек горутин (экспериментально)

Утечка возникает, когда одна или несколько горутин навсегда блокируются на примитивах синхронизации, таких как каналы, в то время как другие горутины продолжают работать и программа в целом остаётся функциональной. Вот простой пример:

func leak() <-chan int {
    out := make(chan int)
    go func() {
        out <- 42 // leaks if nobody reads from out
    }()
    return out
}

Если вызвать leak и не читать из выходного канала, внутренняя горутина навсегда останется заблокированной на отправке в канал:

func main() {
    leak()
    // ...
}

Результат исполнения:

Ok

В отличие от дедлоков, утечки не приводят к панике, поэтому их гораздо сложнее обнаружить. Кроме того, в отличие от гонок данных, инструменты Go долгое время никак с ними не помогали.

Ситуация начала меняться в Go 1.24 с появлением пакета synctest. О нём говорят не так часто, но synctest — отличный инструмент для выявления утечек во время тестирования.

В Go 1.26 добавлен новый экспериментальный профиль goroutineleak, предназначенный для обнаружения утёкших горутин в продакшене. Вот как его можно использовать в приведённом выше примере:

Результат исполнения:

goroutine 1 [running]:
runtime/pprof.writeGoroutineStacks({0x524b28, 0x3e53168fa040})
	/sandbox/sdk/go1.26rc1/src/runtime/pprof/pprof.go:820 +0x6b
runtime/pprof.writeGoroutineLeak({0x524b28, 0x3e53168fa040}, 0x2)
	/sandbox/sdk/go1.26rc1/src/runtime/pprof/pprof.go:807 +0xa8
runtime/pprof.(*Profile).WriteTo(0x2faf080?, {0x524b28?, 0x3e53168fa040?}, 0x5fe648?)
	/sandbox/sdk/go1.26rc1/src/runtime/pprof/pprof.go:409 +0x149
main.main()
	/tmp/sandbox/main.go:24 +0x50

goroutine 7 [chan send (leaked)]:
main.leak.func1()
	/tmp/sandbox/main.go:16 +0x1e
created by main.leak in goroutine 1
	/tmp/sandbox/main.go:15 +0x67

Как видно, мы получаем наглядный stack trace горутины, который точно показывает место, где возникает утечка.

Профиль goroutineleak находит утечки, используя фазу маркировки сборщика мусора, чтобы определить, какие заблокированные горутины всё ещё связаны с активным кодом. Он начинает с исполняемых горутин, помечает все объекты синхронизации, до которых они могут дотянуться, и затем добавляет все заблокированные горутины, ожидающие эти объекты. Когда больше ничего добавить нельзя, все оставшиеся заблокированные горутины считаются утёкшими — они ждут ресурсы, до которых невозможно добраться из выполняемого кода.

Расскажи подробнее

Вот суть:

  1. Собрать живые горутины. В качестве корней берём текущие активные горутины (готовые к выполнению или выполняющиеся). Заблокированные горутины пока игнорируем.

  2. Пометить достижимую память. Обходим указатели от корней и выясняем, какие объекты синхронизации (например, каналы или wait group’ы) достижимы из этих корней.

  3. «Воскресить» заблокированные горутины. Проверяем все горутины, которые сейчас заблокированы. Если заблокированная горутина ждёт ресурс синхронизации, который только что был помечен как достижимый, — добавляем эту горутину в корни.

  4. Итерация. Повторяем шаги 2 и 3, пока не перестанут появляться новые горутины, заблокированные на достижимых объектах.

  5. Отчёт об утечках. Все горутины, которые остаются в заблокированном состоянии, ждут ресурсы, к которым никакая активная часть программы больше не может добраться. Их считают утёкшими.

Для ещё больших подробностей см. статью Saioc и соавт.

Если хотите увидеть, как goroutineleaksynctest) выявляют типичные утечки, которые часто встречаются в продакшене, загляните в мою статью про утечки горутин.

Профиль goroutineleak является экспериментальным и может быть включён при сборке с помощью GOEXPERIMENT=goroutineleakprofile. Включение эксперимента также делает профиль доступным как endpoint net/http/pprof: /debug/pprof/goroutineleak.

По словам авторов, реализация уже готова к использованию в продакшене. Экспериментальным профиль помечен лишь для того, чтобы собрать обратную связь по API, в частности — по поводу оформления его в отдельный профиль.

𝗗 runtime/pprof • 𝗚 Detecting leaks • 𝗣 74609, 75280 • 𝗖𝗟 688335 • 𝗔 Vlad Saioc

Метрики горутин

Новые метрики в пакете runtime/metrics дают более подробное представление о планировании горутин:

  • Общее число горутин с момента старта программы.

  • Число горутин в каждом состоянии.

  • Число активных потоков.

Полный список:

/sched/goroutines-created:goroutines
    Count of goroutines created since program start.

/sched/goroutines/not-in-go:goroutines
    Approximate count of goroutines running
    or blocked in a system call or cgo call.

/sched/goroutines/runnable:goroutines
    Approximate count of goroutines ready to execute,
    but not executing.

/sched/goroutines/running:goroutines
    Approximate count of goroutines executing.
    Always less than or equal to /sched/gomaxprocs:threads.

/sched/goroutines/waiting:goroutines
    Approximate count of goroutines waiting
    on a resource (I/O or sync primitives).

/sched/threads/total:threads
    The current count of live threads
    that are owned by the Go runtime.

Метрики по состояниям горутин можно напрямую связать с типичными продакшен-проблемами. Например, растущее значение waiting может указывать на проблему с конкуренцией за блокировки. Высокое not-in-go означает, что горутины застряли в системных вызовах или cgo. Растущая очередь runnable намекает, что CPU не успевают справляться с нагрузкой.

Новые значения метрик читаются обычной функцией metrics.Read:

func main() {
    go work() // omitted for brevity
    time.Sleep(100 * time.Millisecond)

    fmt.Println("Goroutine metrics:")
    printMetric("/sched/goroutines-created:goroutines", "Created")
    printMetric("/sched/goroutines:goroutines", "Live")
    printMetric("/sched/goroutines/not-in-go:goroutines", "Syscall/CGO")
    printMetric("/sched/goroutines/runnable:goroutines", "Runnable")
    printMetric("/sched/goroutines/running:goroutines", "Running")
    printMetric("/sched/goroutines/waiting:goroutines", "Waiting")

    fmt.Println("Thread metrics:")
    printMetric("/sched/gomaxprocs:threads", "Max")
    printMetric("/sched/threads/total:threads", "Live")
}

func printMetric(name string, descr string) {
    sample := []metrics.Sample{{Name: name}}
    metrics.Read(sample)
    // Assuming a uint64 value; don't do this in production.
    // Instead, check sample[0].Value.Kind and handle accordingly.
    fmt.Printf("  %s: %v\n", descr, sample[0].Value.Uint64())
}

Результат исполнения:

Goroutine metrics:
  Created: 57
  Live: 21
  Syscall/CGO: 0
  Runnable: 0
  Running: 1
  Waiting: 20
Thread metrics:
  Max: 2
  Live: 4

Числа по состояниям (not-in-go + runnable + running + waiting) не обязаны складываться в общее число живых горутин (/sched/goroutines:goroutines, доступно начиная с Go 1.16).

Все новые метрики используют счётчики типа uint64.

𝗗 runtime/metrics • 𝗣 15490 • 𝗖𝗟 690397, 690398, 690399 • 𝗔 Michael Knyszek

Отражающие итераторы

Новые методы Type.Fields и Type.Methods в пакете reflect возвращают итераторы по полям и методам типа:

// List the fields of a struct type.
typ := reflect.TypeFor[http.Client]()
for f := range typ.Fields() {
    fmt.Println(f.Name, f.Type)
}

Результат исполнения:

Transport http.RoundTripper
CheckRedirect func(*http.Request, []*http.Request) error
Jar http.CookieJar
Timeout time.Duration
// List the methods of a struct type.
typ := reflect.TypeFor[*http.Client]()
for m := range typ.Methods() {
    fmt.Println(m.Name, m.Type)
}

Результат исполнения:

CloseIdleConnections func(*http.Client)
Do func(*http.Client, *http.Request) (*http.Response, error)
Get func(*http.Client, string) (*http.Response, error)
Head func(*http.Client, string) (*http.Response, error)
Post func(*http.Client, string, string, io.Reader) (*http.Response, error)
PostForm func(*http.Client, string, url.Values) (*http.Response, error)

Новые методы Type.Ins и Type.Outs возвращают итераторы по входным и выходным параметрам типа функции:

typ := reflect.TypeFor[filepath.WalkFunc]()

fmt.Println("Inputs:")
for par := range typ.Ins() {
    fmt.Println("-", par.Name())
}

fmt.Println("Outputs:")
for par := range typ.Outs() {
    fmt.Println("-", par.Name())
}

Результат исполнения:

Inputs:
- string
- FileInfo
- error
Outputs:
- error

Новые методы Value.Fields и Value.Methods возвращают итераторы по полям и методам значения. Каждая итерация отдаёт как информацию о типе (StructField или Method), так и само значение:

client := &http.Client{}
val := reflect.ValueOf(client)

fmt.Println("Fields:")
for f, v := range val.Elem().Fields() {
    fmt.Printf("- name=%s kind=%s\n", f.Name, v.Kind())
}

fmt.Println("Methods:")
for m, v := range val.Methods() {
    fmt.Printf("- name=%s kind=%s\n", m.Name, v.Kind())
}

Результат исполнения:

Fields:
- name=Transport kind=interface
- name=CheckRedirect kind=func
- name=Jar kind=interface
- name=Timeout kind=int64
Methods:
- name=CloseIdleConnections kind=func
- name=Do kind=func
- name=Get kind=func
- name=Head kind=func
- name=Post kind=func
- name=PostForm kind=func

Ранее всю эту информацию можно было получить с помощью for-range цикла и методов NumX (именно это итераторы и делают внутри):

// go 1.25
typ := reflect.TypeFor[http.Client]()
for i := range typ.NumField() {
    field := typ.Field(i)
    fmt.Println(field.Name, field.Type)
}

Результат исполнения:

Transport http.RoundTripper
CheckRedirect func(*http.Request, []*http.Request) error
Jar http.CookieJar
Timeout time.Duration

Использование итераторов получается более лаконичным. Надеюсь, это оправдывает увеличение поверхности API.

𝗗 reflect • 𝗣 66631 • 𝗖𝗟 707356 • 𝗔 Quentin Quaadgras

Заглянуть в буфер

Новый метод Buffer.Peek в пакете bytes возвращает следующие N байт из буфера, не сдвигая его позицию:

buf := bytes.NewBufferString("I love bytes")

sample, err := buf.Peek(1)
fmt.Printf("peek=%s err=%v\n", sample, err)

buf.Next(2)

sample, err = buf.Peek(4)
fmt.Printf("peek=%s err=%v\n", sample, err)

Результат исполнения:

peek=I err=<nil>
peek=love err=<nil>

Если Peek возвращает меньше чем N байт, он также возвращает io.EOF:

buf := bytes.NewBufferString("hello")
sample, err := buf.Peek(10)
fmt.Printf("peek=%s err=%v\n", sample, err)

Результат исполнения:

peek=hello err=EOF

Срез, возвращаемый Peek, указывает напрямую на содержимое буфера и остаётся валидным, пока буфер не изменится. Поэтому, если изменить срез сразу, это повлияет на последующие чтения:

buf := bytes.NewBufferString("car")
sample, err := buf.Peek(3)
fmt.Printf("peek=%s err=%v\n", sample, err)

sample[2] = 't' // changes the underlying buffer

data, err := buf.ReadBytes(0)
fmt.Printf("data=%s err=%v\n", data, err)

Результат исполнения:

peek=car err=<nil>
data=cat err=EOF

Срез, возвращаемый Peek, валиден только до следующего вызова метода чтения или записи.

𝗗 Buffer.Peek • 𝗣 73794 • 𝗖𝗟 674415 • 𝗔 Ilia Choly

Дескриптор процесса

После запуска процесса в Go можно получить его идентификатор:

attr := &os.ProcAttr{Files: []*os.File{os.Stdin, os.Stdout, os.Stderr}}
proc, _ := os.StartProcess("/bin/echo", []string{"echo", "hello"}, attr)
defer proc.Wait()

fmt.Println("pid =", proc.Pid)

Результат исполнения:

pid = 41
hello

Внутри тип os.Process использует не PID (который всего лишь целое число), а дескриптор процесса — если операционная система это поддерживает. В частности, в Linux используется pidfd — файловый дескриптор, указывающий на процесс. Использование дескриптора вместо PID гарантирует, что методы Process всегда работают с одним и тем же процессом ОС, а не с другим процессом, которому просто достался тот же идентификатор.

Ранее доступ к дескриптору процесса получить было нельзя. Теперь это возможно благодаря новому методу Process.WithHandle:

func (p *Process) WithHandle(f func(handle uintptr)) error

WithHandle вызывает переданную функцию и передаёт ей дескриптор процесса в качестве аргумента:

attr := &os.ProcAttr{Files: []*os.File{os.Stdin, os.Stdout, os.Stderr}}
proc, _ := os.StartProcess("/bin/echo", []string{"echo", "hello"}, attr)
defer proc.Wait()

fmt.Println("pid =", proc.Pid)
proc.WithHandle(func(handle uintptr) {
    fmt.Println("handle =", handle)
})

Результат исполнения:

pid = 49
handle = 6
hello

Гарантируется, что дескриптор будет указывать на процесс до момента возврата из callback-функции, даже если процесс уже завершился. Именно поэтому доступ к нему реализован через callback, а не через поле или метод Process.Handle.

WithHandle поддерживается только в Linux 5.4+ и Windows. В других операционных системах callback не выполняется, а метод возвращает ошибку os.ErrNoHandle.

𝗗 Process.WithHandle • 𝗣 70352 • 𝗖𝗟 699615 • 𝗔 Kir Kolyshkin

Сигнал как причина

signal.NotifyContext возвращает контекст, который отменяется при получении любого из указанных сигналов. Ранее отменённый контекст в качестве причины всегда содержал стандартное значение «context canceled»:

// go 1.25

// The context will be canceled on SIGINT signal.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

// Send SIGINT to self.
p, _ := os.FindProcess(os.Getpid())
_ = p.Signal(syscall.SIGINT)

// Wait for SIGINT.
<-ctx.Done()
fmt.Println("err =", ctx.Err())
fmt.Println("cause =", context.Cause(ctx))

Результат исполнения:

err = context canceled
cause = context canceled

Теперь причина отмены контекста точно указывает, какой именно сигнал был получен:

// go 1.26

// The context will be canceled on SIGINT signal.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

// Send SIGINT to self.
p, _ := os.FindProcess(os.Getpid())
_ = p.Signal(syscall.SIGINT)

// Wait for SIGINT.
<-ctx.Done()
fmt.Println("err =", ctx.Err())
fmt.Println("cause =", context.Cause(ctx))

Результат исполнения:

err = context canceled
cause = interrupt signal received

Возвращаемый тип signal.signalError основан на string, поэтому он не содержит фактическое значение os.Signal — только его строковое представление.

𝗗 signal.NotifyContext • 𝗣 60756 • 𝗖𝗟 721700 • 𝗔 Filippo Valsorda

Сравнение IP-подсетей

Префикс IP-адреса представляет IP-подсеть. Обычно такие префиксы записываются в нотации CIDR:

10.0.0.0/16
127.0.0.0/8
169.254.0.0/16
203.0.113.0/24

В Go IP-префикс представлен типом netip.Prefix.

Новый метод Prefix.Compare позволяет сравнивать два IP-префикса, упрощая их сортировку без необходимости писать собственный код сравнения:

prefixes := []netip.Prefix{
    netip.MustParsePrefix("10.1.0.0/16"),
    netip.MustParsePrefix("203.0.113.0/24"),
    netip.MustParsePrefix("10.0.0.0/16"),
    netip.MustParsePrefix("169.254.0.0/16"),
    netip.MustParsePrefix("203.0.113.0/8"),
}

slices.SortFunc(prefixes, netip.Prefix.Compare)

for _, p := range prefixes {
    fmt.Println(p.String())
}

Результат исполнения:

10.0.0.0/16
10.1.0.0/16
169.254.0.0/16
203.0.113.0/8
203.0.113.0/24

Compare упорядочивает два префикса следующим образом:

  • Сначала по валидности (невалидные перед валидными).

  • Затем по семейству адресов (IPv4 перед IPv6).
    10.0.0.0/8 < ::/8

  • Затем по замаскированному IP-адресу (сетевому адресу).
    10.0.0.0/16 < 10.1.0.0/16

  • Затем по длине префикса.
    10.0.0.0/8 < 10.0.0.0/16

  • Затем по незамаскированному адресу (исходному IP).
    10.0.0.0/8 < 10.0.0.1/8

Этот порядок соответствует порядку в Python netaddr.IPNetwork и стандартной конвенции IANA (Internet Assigned Numbers Authority).

𝗗 Prefix.Compare • 𝗣 61642 • 𝗖𝗟 700355 • 𝗔 database64128

Контекстно-зависимый dial

В пакете net есть функции верхнего уровня для подключения к адресу по разным сетям (протоколам) — DialTCP, DialUDP, DialIP и DialUnix. Они появились ещё до появления context.Context, поэтому отмену (cancellation) не поддерживают:

raddr, _ := net.ResolveTCPAddr("tcp", "127.0.0.1:12345")
conn, err := net.DialTCP("tcp", nil, raddr)
fmt.Printf("connected, err=%v\n", err)
defer conn.Close()

Результат исполнения:

connected, err=<nil>

Также есть тип net.Dialer с универсальным методом DialContext. Он поддерживает отмену и подходит для подключения к любой из известных сетей:

var d net.Dialer
ctx := context.Background()
conn, err := d.DialContext(ctx, "tcp", "127.0.0.1:12345")
fmt.Printf("connected, err=%v\n", err)
defer conn.Close()

Результат исполнения:

connected, err=<nil>

Однако DialContext немного менее эффективен, чем специализированные функции вроде net.DialTCP — из-за дополнительного оверхеда на разрешение адреса и диспетчеризацию по типу сети.

В итоге получалось так: специализированные функции в net работают эффективнее, но отмену не поддерживают. Тип Dialer отмену поддерживает, но работает менее эффективно. Команда Go решила устранить это противоречие.

Новые контекстно-зависимые методы Dialer (DialTCP, DialUDP, DialIP и DialUnix) совмещают эффективность существующих специализированных функций net с возможностями отмены из Dialer.DialContext:

var d net.Dialer
ctx := context.Background()
raddr := netip.MustParseAddrPort("127.0.0.1:12345")
conn, err := d.DialTCP(ctx, "tcp", netip.AddrPort{}, raddr)
fmt.Printf("connected, err=%v\n", err)
defer conn.Close()

Результат исполнения:

connected, err=<nil>

Не сказал бы, что иметь три разных способа сделать dial — это очень удобно, но такова цена обратной совместимости.

𝗗 net.Dialer • 𝗣 49097 • 𝗖𝗟 490975 • 𝗔 Michael Fraenkel

Поддельный example.com

Сертификат по умолчанию у httptest.Server уже содержит example.com в DNSNames (списке хостнеймов или доменных имён, которые сертификат уполномочен защищать). Из-за этого Server.Client не доверяет ответам настоящего example.com:

// go 1.25
func Test(t *testing.T) {
    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("hello"))
    })
    srv := httptest.NewTLSServer(handler)
    defer srv.Close()

    _, err := srv.Client().Get("https://example.com")
    if err != nil {
        t.Fatal(err)
    }
}
--- FAIL: Test (0.29s)
    main_test.go:19: Get "https://example.com":
    tls: failed to verify certificate:
    x509: certificate signed by unknown authority

Чтобы исправить эту проблему, HTTP-клиент, который возвращает httptest.Server.Client, теперь перенаправляет запросы к example.com и его поддоменам на тестовый сервер:

// go 1.26
func Test(t *testing.T) {
    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("hello"))
    })
    srv := httptest.NewTLSServer(handler)
    defer srv.Close()

    resp, err := srv.Client().Get("https://example.com")
    if err != nil {
        t.Fatal(err)
    }

    body, _ := io.ReadAll(resp.Body)
    resp.Body.Close()

    if string(body) != "hello" {
        t.Errorf("Unexpected response body: %s", body)
    }
}

Результат исполнения:

PASS
ok  	sandbox	0.034s

𝗗 Server.Client • 𝗖𝗟 666855 • 𝗔 Sean Liao

Оптимизированный fmt.Errorf

Часто отмечают, что использование fmt.Errorf("x") для простых строк приводит к большему числу выделений памяти, чем errors.New("x"). Из-за этого иногда предлагают заменять fmt.Errorf на errors.New там, где форматирование не требуется.

Команда Go с этим не согласна. Вот цитата Рассa Кокса:

Использовать fmt.Errorf("foo") — совершенно нормально, особенно в программе, где все ошибки создаются через fmt.Errorf. Необходимость постоянно переключаться между двумя функциями в зависимости от аргумента — это лишний когнитивный шум.

С выходом новой версии Go этот спор, наконец, должен быть закрыт. Для неформатированных строк fmt.Errorf теперь делает меньше выделений памяти и в целом соответствует errors.New.

В частности, fmt.Errorf переходит:

  • с 2 выделений к 0 — для неубегающей (non-escaping) ошибки;

  • с 2 выделений к 1 — для убегающей (escaping) ошибки.

_ = fmt.Errorf("foo")    // non-escaping error
sink = fmt.Errorf("foo") // escaping error

Это полностью совпадает с количеством выделений у errors.New в обоих случаях.

Разница в стоимости по CPU теперь тоже значительно меньше. Раньше она составляла примерно 64 нс против 21 нс для fmt.Errorf и errors.New соответственно (для убегающих ошибок), теперь — около 25 нс против 21 нс.

Узнать больше

Вот «до и после» бенчмарки для изменения в fmt.Errorf. Неубегающий случай называется local, а убегающий — sink. Если это просто строка ошибки, вариант называется no-args. Если ошибка включает форматирование, это int-arg.

Секунды на операцию:

goos: linux
goarch: amd64
pkg: fmt
cpu: AMD EPYC 7B13
                         │    old.txt    │        new.txt        │
                         │      sec/op   │   sec/op     vs base  │
Errorf/no-arsg/local-16     63.76n ± 1%     4.874n ± 0%  -92.36% (n=120)
Errorf/no-args/sink-16      64.25n ± 1%     25.81n ± 0%  -59.83% (n=120)
Errorf/int-arg/local-16     90.86n ± 1%     90.97n ± 1%        ~ (p=0.713 n=120)
Errorf/int-arg/sink-16      91.81n ± 1%     91.10n ± 1%   -0.76% (p=0.036 n=120)

Байты на операцию:

                     │    old.txt    │        new.txt       │
                         │       B/op    │    B/op     vs base  │
Errorf/no-args/local-16      19.00 ± 0%      0.00 ± 0%  -100.00% (n=120)
Errorf/no-args/sink-16       19.00 ± 0%     16.00 ± 0%   -15.79% (n=120)
Errorf/int-arg/local-16      24.00 ± 0%     24.00 ± 0%         ~ (p=1.000 n=120)
Errorf/int-arg/sink-16       24.00 ± 0%     24.00 ± 0%         ~ (p=1.000 n=120)

Выделения на операцию:

       │    old.txt    │        new.txt       │
                         │    allocs/op  │  allocs/op   vs base │
Errorf/no-args/local-16      2.000 ± 0%     0.000 ± 0%  -100.00% (n=120)
Errorf/no-args/sink-16       2.000 ± 0%     1.000 ± 0%   -50.00% (n=120)
Errorf/int-arg/local-16      2.000 ± 0%     2.000 ± 0%         ~ (p=1.000 n=120)
Errorf/int-arg/sink-16       2.000 ± 0%     2.000 ± 0%         ~ (p=1.000 n=120)

source

Если вам интересны детали, настоятельно рекомендую прочитать CL — он написан образцово.

𝗗 fmt.Errorf • 𝗖𝗟 708836 • 𝗔 thepudds

Оптимизированный io.ReadAll

Ранее io.ReadAll выделял много промежуточной памяти по мере увеличения результирующего среза до размера входных данных. Теперь он использует промежуточные срезы с экспоненциально растущей ёмкостью, а в конце копирует данные в финальный срез точно нужного размера.

Новая реализация примерно в два раза быстрее и использует примерно вдвое меньше памяти для входа размером 65 КиБ; на больших объёмах она ещё эффективнее. Ниже приведены геометрические средние результаты сравнения старой и новой версий для разных размеров входных данных:

              │     old     │      new       vs base    │
          sec/op           132.2µ        66.32µ     -49.83%
            B/op          645.4Ki       324.6Ki     -49.70%
  final-capacity           178.3k        151.3k     -15.10%
    excess-ratio            1.216         1.033     -15.10%

Полные результаты бенчмарков можно посмотреть в коммите. К сожалению, автор не приложил исходный код бенчмарков.

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

Как и в случае с оптимизацией fmt.Errorf, рекомендую прочитать CL — он очень качественный. Обе эти доработки сделаны thepudds, а его описания изменений — мечта любого ревьюера.

𝗗 io.ReadAll • 𝗖𝗟 722500 • 𝗔 thepudds

Несколько обработчиков логов

Пакет log/slog, появившийся в версии 1.21, предлагает надёжное, готовое к продакшену решение для логирования. С момента его выхода многие проекты перешли на него с сторонних логгеров. Однако в нём не хватало одной важной возможности: отправки лог-записей сразу в несколько обработчиков, например в stdout и в файл.

Новый тип MultiHandler решает эту задачу. Он реализует стандартный интерфейс Handler и вызывает все настроенные обработчики.

Например, можно создать обработчик логов, пишущий в stdout:

stdoutHandler := slog.NewTextHandler(os.Stdout, nil)

И другой обработчик, пишущий в файл:

const flags = os.O_CREATE | os.O_WRONLY | os.O_APPEND
file, _ := os.OpenFile("/tmp/app.log", flags, 0644)
defer file.Close()
fileHandler := slog.NewJSONHandler(file, nil)

Затем объединить их с помощью MultiHandler:

// MultiHandler that writes to both stdout and app.log.
multiHandler := slog.NewMultiHandler(stdoutHandler, fileHandler)
logger := slog.New(multiHandler)

// Log a sample message.
logger.Info("login",
    slog.String("name", "whoami"),
    slog.Int("id", 42),
)

Результат исполнения:

time=2026-01-24T12:36:17.268Z level=INFO msg=login name=whoami id=42
{"time":"2026-01-24T12:36:17.268342532Z","level":"INFO","msg":"login","name":"whoami","id":42}

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

Когда MultiHandler получает лог-запись, он последовательно передаёт её каждому включённому обработчику. Если какой-либо обработчик возвращает ошибку, MultiHandler не останавливается; вместо этого он объединяет все ошибки с помощью errors.Join:

hInfo := slog.NewTextHandler(
    os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo},
)
hErrorsOnly := slog.NewTextHandler(
    os.Stdout, &slog.HandlerOptions{Level: slog.LevelError},
)
hBroken := &BrokenHandler{
    Handler: hInfo,
    err:     fmt.Errorf("broken handler"),
}

handler := slog.NewMultiHandler(hBroken, hInfo, hErrorsOnly)
rec := slog.NewRecord(time.Now(), slog.LevelInfo, "hello", 0)

// Calls hInfo and hBroken, skips hErrorsOnly.
// Returns an error from hBroken.
err := handler.Handle(context.Background(), rec)
fmt.Println(err)

Результат исполнения:

time=2025-12-31T13:32:52.110Z level=INFO msg=hello
broken handler

Метод Enabled сообщает, включён ли хотя бы один из настроенных обработчиков:

hInfo := slog.NewTextHandler(
    os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo},
)
hErrors := slog.NewTextHandler(
    os.Stdout, &slog.HandlerOptions{Level: slog.LevelError},
)
handler := slog.NewMultiHandler(hInfo, hErrors)

// hInfo is enabled.
enabled := handler.Enabled(context.Background(), slog.LevelInfo)
fmt.Println(enabled)

Результат исполнения:

true

Остальные методы — WithAttr и WithGroup — вызывают соответствующие методы у каждого из включённых обработчиков.

𝗗 slog.MultiHandler • 𝗣 65954 • 𝗖𝗟 692237 • 𝗔 Jes Cok

Артефакты тестов

Артефакты тестов — это файлы, создаваемые тестами или бенчмарками: логи выполнения, дампы памяти, отчёты анализа и т. п. Они важны для отладки сбоев в удалённых средах (например, в CI), где разработчик не может пошагово пройтись по коду вручную.

Ранее тестовый фреймворк Go и связанные инструменты не поддерживали артефакты тестов. Теперь — поддерживают.

Новые методы T.ArtifactDir, B.ArtifactDir и F.ArtifactDir возвращают каталог, в который можно записывать выходные файлы теста:

func TestFunc(t *testing.T) {
    dir := t.ArtifactDir()
    logFile := filepath.Join(dir, "app.log")
    content := []byte("Loading user_id=123...\nERROR: Connection failed\n")
    os.WriteFile(logFile, content, 0644)
    t.Log("Saved app.log")
}

Если запускать go test с флагом -artifacts, этот каталог будет находиться внутри каталога вывода (заданного через -outputdir или, по умолчанию, текущего каталога):

go1.26rc1 test -v -artifacts -outputdir=/tmp/output

Результат исполнения:

=== RUN   TestFunc
=== ARTIFACTS TestFunc /tmp/output/_artifacts/531391050
    main_test.go:18: Saved app.log
--- PASS: TestFunc (0.00s)
PASS
ok  	sandbox	0.003s

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

Если флаг -artifacts не используется, артефакты сохраняются во временном каталоге, который удаляется после завершения теста.

Каждый тест или сабтест в рамках пакета получает собственный уникальный каталог артефактов. Вывод сабтестов не хранится внутри каталога родительского теста — все каталоги артефактов для одного пакета создаются на одном уровне:

func TestFunc(t *testing.T) {
    t.ArtifactDir()
    t.Run("subtest 1", func(t *testing.T) {
        t.ArtifactDir()
    })
    t.Run("subtest 2", func(t *testing.T) {
        t.ArtifactDir()
    })
}

Результат исполнения:

=== ARTIFACTS TestFunc /tmp/output/_artifacts/2603191535
=== RUN   TestFunc/subtest_1
=== ARTIFACTS TestFunc/subtest_1 /tmp/output/_artifacts/4263447024
=== RUN   TestFunc/subtest_2
=== ARTIFACTS TestFunc/subtest_2 /tmp/output/_artifacts/986981954
--- PASS: TestFunc (0.00s)
    --- PASS: TestFunc/subtest_1 (0.00s)
    --- PASS: TestFunc/subtest_2 (0.00s)
PASS
ok  	sandbox	0.002s

Обычно путь к каталогу артефактов выглядит так:

<output dir>/_artifacts/<test package>/<test name>/<random>

Но если этот путь нельзя безопасно преобразовать в локальный путь файловой системы (что почему-то всегда происходит у меня), используется более простой вариант:

<output dir>/_artifacts/<random>

(именно это и происходит в примерах выше).

Повторные вызовы ArtifactDir в рамках одного и того же теста или сабтеста возвращают один и тот же каталог.

𝗗 T.ArtifactDir • 𝗣 71287 • 𝗖𝗟 696399 • 𝗔 Damien Neil

Обновлённый go fix

За прошедшие годы команда go fix превратилась в грустный, заброшенный набор переписываний для очень древних возможностей Go. Но теперь она возвращается.

Новый go fix реализован заново на базе фреймворка Go analysis — того же самого, который использует go vet.

Хотя go fix и go vet теперь используют одну и ту же инфраструктуру, цели у них разные, и наборы анализаторов тоже отличаются:

  • Vet предназначен для поиска проблем. Его анализаторы описывают реальные ошибки, но не всегда предлагают исправления, и такие исправления не всегда безопасно применять.

  • Fix в основном предназначен для модернизации кода с использованием более новых возможностей языка и стандартной библиотеки. Его анализаторы всегда предлагают безопасные для применения исправления, но они не обязательно указывают на проблемы в коде.

usage: go fix [build flags] [-fixtool prog] [fix flags] [packages]

Fix runs the Go fix tool (cmd/fix) on the named packages
and applies suggested fixes.

It supports these flags:

  -diff
        instead of applying each fix, print the patch as a unified diff

The -fixtool=prog flag selects a different analysis tool with
alternative or additional fixers.

По умолчанию go fix запускает полный набор анализаторов (на данный момент их больше 20). Чтобы выбрать конкретные анализаторы, используйте флаг -NAME для каждого из них или -NAME=false, чтобы запустить все анализаторы, кроме отключённых.

Например, здесь включён только анализатор forvar:

go fix -forvar .

А здесь включены все анализаторы, кроме omitzero:

go fix -omitzero=false .

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

Чтобы дать представление о работе анализаторов go fix, вот один из них в действии. Он заменяет циклы на slices.Contains или slices.ContainsFunc:

// before go fix
func find(s []int, x int) bool {
    for _, v := range s {
        if x == v {
            return true
        }
    }
    return false
}
// after go fix
func find(s []int, x int) bool {
    return slices.Contains(s, x)
}

Если интересно, загляните в отдельный пост в блоге — там приведён полный список анализаторов с примерами.

𝗗 cmd/fix • 𝗚 go fix • 𝗣 71859 • 𝗔 Alan Donovan

Заключение

Go 1.26 — это по-настоящему большой релиз. Самый крупный из всех, что я видел, и на то есть причины:

  • В нём появилось множество полезных обновлений, таких как улучшенный новый builtin, типобезопасная проверка ошибок и детектор утечек горутин.

  • Также добавлено много оптимизаций производительности: новый сборщик мусора, более быстрые cgo и аллокации памяти, а также оптимизированные fmt.Errorf и io.ReadAll.

  • Помимо этого, появились улучшения качества жизни — несколько обработчиков логов, артефакты тестов и обновлённый инструмент go fix.

  • Наконец, добавлены два специализированных экспериментальных пакета: один с поддержкой SIMD, другой — с защищённым режимом для прямой секретности.

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

Возможно, вам интересно, что происходит с пакетом json/v2, который был представлен как экспериментальный в версии 1.25. Он по-прежнему остаётся экспериментальным и доступен при включённом флаге GOEXPERIMENT=jsonv2.

Русскоязычное Go сообщество

Друзья! Эту статью перевела команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!