golang

Что нового в Go 1.26

  • вторник, 20 января 2026 г. в 00:00:10
https://habr.com/ru/articles/986628/

Go 1.26 выходит в феврале, и на мой взгляд это один из самых насыщенных релизов за всю историю языка. Давай разберёмся что нас ждёт, от изменений в языке до оптимизаций runtime и новых инструментов. Статья основана на официальных release notes и исходном коде Go. Это не исчерпывающий список, за полной информацией обращайся к официальной документации.

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

Новая функция 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)

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

// Старый подход с errors.As
var netErr *NetworkError
if errors.As(err, &netErr) {
    log.Printf("сеть недоступна: %s", netErr.Address)
}

// Новый подход с errors.AsType
if netErr, ok := errors.AsType[*NetworkError](err); ok {
    log.Printf("сеть недоступна: %s", netErr.Address)
}

AsType особенно удобен при проверке нескольких типов ошибок. Код становится короче, а переменные остаются в своей области видимости:

if connErr, ok := errors.AsType[*ConnectionRefusedError](err); ok {
    log.Printf("соединение отклонено хостом %s:%d", connErr.Host, connErr.Port)
} else if authErr, ok := errors.AsType[*AuthenticationError](err); ok {
    log.Printf("неверные учётные данные пользователя: %s", authErr.Username)
} else {
    log.Println("произошла непредвиденная ошибка")
}

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

// Ошибка: передаём не указатель на указатель
var netErr NetworkError
if errors.As(err, &netErr) {
    log.Printf("сеть недоступна: %s", netErr.Address)
}

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

AsType не паникует, она выдаёт понятную ошибку компиляции:

if netErr, ok := errors.AsType[NetworkError](err); ok {
    log.Printf("сеть недоступна: %s", netErr.Address)
}

// ./main.go:24:32: NetworkError does not satisfy error (method Error has pointer receiver)

AsType не использует reflect, работает быстрее и потребляет меньше памяти.

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

Расширение new для выражений

На мой взгляд это одно из самых удобный изменений релиза. До Go 1.26 встроенная функция new работала только с типами:

counter := new(int)
*counter = 42
fmt.Println(*counter) // 42

Теперь new принимает и выражения:

// Создаём указатель на int со значением 42
counter := new(42)
fmt.Println(*counter) // 42

Если аргумент expr - это выражение типа T, то new(expr) выделяет переменную типа T, инициализирует её значением expr и возвращает указатель *T. Особенно это удобно при работе со структурами, где указатели обозначают опциональные поля для JSON или Protobuf:

type Product struct {
    Title    string `json:"title"`
    InStock  *bool  `json:"in_stock,omitempty"`
}

item := Product{Title: "Ноутбук", InStock: new(true)}
payload, _ := json.Marshal(item)
fmt.Println(string(payload))

// { "title":"Ноутбук", "in_stock":true }

Работает и с составными литералами:

prices := new([]float64{199.99, 299.99, 399.99})
fmt.Println(*prices) // [199.99 299.99 399.99]

type Settings struct{ Verbose bool }
opts := new(Settings{Verbose: true})
fmt.Println(*opts) // {true}

И с вызовами функций:

fetchVersion := func() string { return "v1.26.0" }
version := new(fetchVersion())
fmt.Println(*version) // v1.26.0

Передавать nil по-прежнему нельзя:

ref := new(nil) // ошибка компиляции

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

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

Предпосылки

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

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

Как это работает

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

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

Результаты

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

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

Ускорение cgo и syscall

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

Раньше у процессоров было состояние _Psyscall для горутин, делающих системные вызовы или вызовы cgo. Теперь это состояние убрали. Вместо отдельного состояния система проверяет статус горутины, привязанной к процессору. Это снижает внутренние накладные расходы и упрощает код. В release notes заявлено -30% накладных расходов cgo, а в коммите упоминается улучшение на 18%.

Оптимизация аллокации памяти

Runtime Go теперь имеет специализированные версии функции аллокации памяти для малых объектов (от 1 до 512 байт). Используются таблицы переходов для быстрого выбора нужной функции вместо единой универсальной реализации. Это снижает стоимость аллокации малых объектов до 30%. Команда Go ожидает общее улучшение около 1% в программах с интенсивной аллокацией.

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

Множественные обработчики логов

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

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

// Создаём обработчик для консольного вывода:
consoleHandler := slog.NewTextHandler(os.Stderr, nil)

// Создаём обработчик для записи в файл аудита:
const fileFlags = os.O_CREATE | os.O_WRONLY | os.O_APPEND
auditFile, _ := os.OpenFile("/var/log/audit.json", fileFlags, 0600)
defer auditFile.Close()
auditHandler := slog.NewJSONHandler(auditFile, nil)

// Объединяем обработчики с помощью `MultiHandler`:

// MultiHandler отправляет логи и в консоль, и в файл аудита
combined := slog.NewMultiHandler(consoleHandler, auditHandler)
log := slog.New(combined)

// Записываем событие о платеже
log.Info("платёж обработан",
    slog.String("order_id", "ORD-78542"),
    slog.Float64("amount", 1599.99),
)


/*
	time=2025-12-31T11:46:14.521Z level=INFO msg=платёж обработан order_id=ORD-78542 amount=1599.99
	{"time":"2025-12-31T11:46:14.521126342Z","level":"INFO","msg":"платёж обработан","order_id":"ORD-78542","amount":1599.99}
*/

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

hDebug := slog.NewTextHandler(
    os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug},
)

hCritical := slog.NewTextHandler(
    os.Stderr, &slog.HandlerOptions{Level: slog.LevelError},
)

hFailing := &FailingHandler{
    Handler: hDebug,
    err:     fmt.Errorf("обработчик временно недоступен"),
}

combined := slog.NewMultiHandler(hFailing, hDebug, hCritical)
entry := slog.NewRecord(time.Now(), slog.LevelDebug, "старт сервиса", 0)

// Вызывает hDebug и hFailing, пропускает hCritical.
// Возвращает ошибку от hFailing.
err := combined.Handle(context.Background(), entry)
fmt.Println(err)

/*
	time=2025-12-31T13:32:52.110Z level=DEBUG msg=старт сервиса
	обработчик временно недоступен
*/

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

hDebug := slog.NewTextHandler(
    os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug},
)

hWarn := slog.NewTextHandler(
    os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn},
)

combined := slog.NewMultiHandler(hDebug, hWarn)

// hDebug включён для уровня Debug
active := combined.Enabled(context.Background(), slog.LevelDebug)
fmt.Println(active)

// true

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

Оптимизация fmt.Errorf

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

Конкретно, fmt.Errorf переходит с 2 аллокаций к 0 для неубегающей ошибки и с 2 к 1 для убегающей:

_ = fmt.Errorf("foo")    // неубегающая ошибка
sink = fmt.Errorf("foo") // убегающая ошибка

Это соответствует аллокациям errors.New в обоих случаях.

Оптимизация io.ReadAll

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

Новая реализация примерно вдвое быстрее и использует примерно вдвое меньше памяти для входа 65 КиБ; для больших входов эффективность ещё выше. Гарантия минимального размера финального слайса тоже полезна. Слайс может жить долго, и неиспользуемая ёмкость в backing array (как в старой версии) просто тратила бы память.

Итераторы в reflect

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

// Перечисляем поля структуры
t := reflect.TypeFor[os.FileInfo]()
for field := range t.Fields() {
    fmt.Println(field.Name, field.Type)
}

/*
	name string
	size int64
	mode FileMode
	modTime time.Time
	sys any
*/
// Перечисляем методы типа
t := reflect.TypeFor[*bytes.Buffer]()
for method := range t.Methods() {
    fmt.Println(method.Name)
}

/*
	Bytes
	Cap
	Grow
	Len
	Next
	...
	WriteString
*/

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

t := reflect.TypeFor[func(context.Context, string) ([]byte, error)]()

fmt.Println("Аргументы функции:")
for param := range t.Ins() {
    fmt.Println("-", param.Name())
}

fmt.Println("Возвращаемые значения:")
for ret := range t.Outs() {
    fmt.Println("-", ret.Name())
}

/*
	Аргументы функции:
	- Context
	- string
	Возвращаемые значения:
	- []uint8
	- error
*/

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

cfg := &tls.Config{ServerName: "api.example.com", MinVersion: tls.VersionTLS13}
v := reflect.ValueOf(cfg)

fmt.Println("Ненулевые поля:")
for field, val := range v.Elem().Fields() {
    if !val.IsZero() {
        fmt.Printf("- %s = %v\n", field.Name, val.Interface())
    }
}

/*
	Ненулевые поля:
	- ServerName = api.example.com
	- MinVersion = 772
*/

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

// Go 1.25
t := reflect.TypeFor[tls.Config]()
for i := range t.NumField() {
    f := t.Field(i)
    fmt.Println(f.Name, f.Type)
}

Использование итератора лаконичнее.

Peek для буфера

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

stream := bytes.NewBufferString("HTTP/1.1 200 OK")

header, err := stream.Peek(8)
fmt.Printf("peek=%s err=%v\n", header, err)
// peek=HTTP/1.1 err=<nil>

stream.Next(9) // пропускаем "HTTP/1.1 "

status, err := stream.Peek(6)
fmt.Printf("peek=%s err=%v\n", status, err)
// peek=200 OK err=<nil>

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

stream := bytes.NewBufferString("tiny")
chunk, err := stream.Peek(256)
fmt.Printf("peek=%s err=%v\n", chunk, err)
// peek=tiny err=EOF

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

stream := bytes.NewBufferString("cat")
preview, err := stream.Peek(3)
fmt.Printf("peek=%s err=%v\n", preview, err)
// peek=cat err=<nil>

preview[0] = 'b' // меняем базовый буфер
preview[1] = 'a'
preview[2] = 't'

content, err := stream.ReadBytes(0)
fmt.Printf("data=%s err=%v\n", content, err)
// data=bat err=EOF

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

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

Со временем команда go fix превратилась в забытый набор перезаписей для очень древних фич Go. Но теперь она возвращается. Новый go fix переписан с использованием Go analysis framework - того же, что использует go vet. Хотя go fix и go vet теперь используют одну инфраструктуру, у них разные цели и разные наборы анализаторов:

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

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

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

Fix запускает инструмент Go fix (cmd/fix) на указанных пакетах и применяет предложенные исправления.

Поддерживаемые флаги:

  -diff
        вместо применения исправлений выводит патч как unified diff

Флаг -fixtool=prog выбирает другой инструмент анализа с альтернативными или дополнительными фиксерами.

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

go fix -forvar ./...

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

go fix -omitzero=false ./...

Пока нет способа отключить конкретные анализаторы для определённых файлов или участков кода.

Для примера вот один из анализаторов в действии. Он заменяет циклы на slices.Contains или slices.ContainsFunc:

// до go fix
func hasPermission(roles []string, required string) bool {
    for _, role := range roles {
        if required == role {
            return true
        }
    }
    return false
}

// после go fix
func hasPermission(roles []string, required string) bool {
    return slices.Contains(roles, required)
}

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

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

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

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

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

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

/sched/goroutines-created:goroutines
    Количество горутин, созданных с начала программы.

/sched/goroutines/not-in-go:goroutines
    Приблизительное число горутин, выполняющихся
    или заблокированных в системном вызове или cgo.

/sched/goroutines/runnable:goroutines
    Приблизительное число горутин, готовых к выполнению,
    но не выполняющихся.

/sched/goroutines/running:goroutines
    Приблизительное число выполняющихся горутин.
    Всегда <= /sched/gomaxprocs:threads.

/sched/goroutines/waiting:goroutines
    Приблизительное число горутин, ожидающих
    ресурсов (I/O или примитивов синхронизации).

/sched/threads/total:threads
    Текущее число живых потоков, принадлежащих Go runtime.

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

Рассмотрим практический пример — мониторинг состояния горутин в приложении-краулере:

// SchedulerStats собирает метрики планировщика Go
type SchedulerStats struct {
    keys []string
}

func NewSchedulerStats() *SchedulerStats {
    return &SchedulerStats{
        keys: []string{
            "/sched/goroutines-created:goroutines",
            "/sched/goroutines:goroutines",
            "/sched/goroutines/running:goroutines",
            "/sched/goroutines/runnable:goroutines",
            "/sched/goroutines/waiting:goroutines",
            "/sched/goroutines/not-in-go:goroutines",
            "/sched/threads/total:threads",
        },
    }
}

func (s *SchedulerStats) Collect() map[string]uint64 {
    samples := make([]metrics.Sample, len(s.keys))
    for idx, key := range s.keys {
        samples[idx].Name = key
    }
    metrics.Read(samples)

    data := make(map[string]uint64, len(samples))
    for _, sample := range samples {
        data[sample.Name] = sample.Value.Uint64()
    }
    return data
}

func (s *SchedulerStats) Display() {
    data := s.Collect()

    fmt.Println("╔═══════════════════════════════════════╗")
    fmt.Println("║      Мониторинг планировщика Go       ║")
    fmt.Println("╠═══════════════════════════════════════╣")
    fmt.Printf("║ Создано за всё время:    %12d ║\n", data["/sched/goroutines-created:goroutines"])
    fmt.Printf("║ Текущее количество:      %12d ║\n", data["/sched/goroutines:goroutines"])
    fmt.Println("╠═══════════════════════════════════════╣")
    fmt.Printf("║ ▶ Исполняются:           %12d ║\n", data["/sched/goroutines/running:goroutines"])
    fmt.Printf("║ ⏳ Ожидают процессор:    %12d ║\n", data["/sched/goroutines/runnable:goroutines"])
    fmt.Printf("║ 💤 Ожидают события:      %12d ║\n", data["/sched/goroutines/waiting:goroutines"])
    fmt.Printf("║ 🔧 Вне Go runtime:       %12d ║\n", data["/sched/goroutines/not-in-go:goroutines"])
    fmt.Println("╠═══════════════════════════════════════╣")
    fmt.Printf("║ Системных потоков:       %12d ║\n", data["/sched/threads/total:threads"])
    fmt.Println("╚═══════════════════════════════════════╝")
}

func main() {
    stats := NewSchedulerStats()

    // Запускаем краулеры
    urls := make(chan string, 200)
    for range 12 {
        go crawler(urls)
    }

    // Добавляем URL для обработки
    for i := range 80 {
        urls <- fmt.Sprintf("https://site%d.example.com", i)
    }

    time.Sleep(100 * time.Millisecond)
    stats.Display()

    close(urls)
}

func crawler(urls <-chan string) {
    for url := range urls {
        time.Sleep(time.Duration(len(url)) * time.Millisecond)
    }
}


/*
╔═══════════════════════════════════════╗
║      Мониторинг планировщика Go       ║
╠═══════════════════════════════════════╣
║ Создано за всё время:              18 ║
║ Текущее количество:                18 ║
╠═══════════════════════════════════════╣
║ ▶ Исполняются:                      1 ║
║ ⏳ Ожидают процессор:               0 ║
║ 💤 Ожидают события:                17 ║
║ 🔧 Вне Go runtime:                  0 ║
╠═══════════════════════════════════════╣
║ Системных потоков:                  6 ║
╚═══════════════════════════════════════╝
*/

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

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

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

func startBackgroundTask() <-chan string {
    results := make(chan string)
    go func() {
        results <- "done" // утечка, если результат никто не забирает
    }()
    return results
}

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

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

В отличие от deadlock, утечки не вызывают паники, поэтому их намного сложнее обнаружить. И в отличие от data race, инструментарий Go долгое время не решал эту проблему. Ситуация начала меняться в Go 1.24 с появлением пакета synctest. Мало кто об этом говорит, но synctest - отличный инструмент для обнаружения утечек при тестировании.

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

func main() {
    leakProfile := pprof.Lookup("goroutineleak")
    startBackgroundTask()
    time.Sleep(50 * time.Millisecond)
    leakProfile.WriteTo(os.Stdout, 2)
    // ...
}
goroutine 7 [chan send (leaked)]:
main.startBackgroundTask.func1()
    /tmp/sandbox/main.go:16 +0x1e
created by main.startBackgroundTask in goroutine 1
    /tmp/sandbox/main.go:15 +0x67

Получаем красивый стектрейс горутины, показывающий точное место утечки.

Профиль goroutineleak находит утечки, используя фазу маркировки GC для проверки, какие заблокированные горутины всё ещё связаны с активным кодом. Он начинает с выполняемых горутин, помечает все sync-объекты, которых они могут достичь, и продолжает добавлять заблокированные горутины, ожидающие на этих объектах. Когда добавить больше нечего, оставшиеся заблокированные горутины ждут недостижимых ресурсов - они считаются утекшими. Он экспериментальный, включается через GOEXPERIMENT=goroutineleakprofile при сборке. При включении профиль также становится доступен как endpoint net/http/pprof: /debug/pprof/goroutineleak.

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

Контекстное подключение

Пакет net имеет функции верхнего уровня для подключения к адресу по разным протоколам - DialTCP, DialUDP, DialIP, DialUnix. Они были созданы до появления context.Context, поэтому не поддерживают отмену:

serverAddr, _ := net.ResolveTCPAddr("tcp", "192.168.1.100:5432")
dbConn, err := net.DialTCP("tcp", nil, serverAddr)
fmt.Printf("соединение с БД, err=%v\n", err)
defer dbConn.Close()

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

var dialer net.Dialer
ctx := context.Background()
dbConn, err := dialer.DialContext(ctx, "tcp", "192.168.1.100:5432")
fmt.Printf("соединение с БД, err=%v\n", err)
defer dbConn.Close()

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

Итак, протокол-специфичные функции в net более эффективны, но не поддерживают отмену. Тип Dialer поддерживает отмену, но менее эффективен.

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

var dialer net.Dialer
ctx := context.Background()
serverAddr := netip.MustParseAddrPort("192.168.1.100:5432")
dbConn, err := dialer.DialTCP(ctx, "tcp", netip.AddrPort{}, serverAddr)
fmt.Printf("соединение с БД, err=%v\n", err)
defer dbConn.Close()

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

Сравнение 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-префикса, упрощая их сортировку без написания собственного компаратора:

allowedNetworks := []netip.Prefix{
    netip.MustParsePrefix("10.20.30.0/24"),
    netip.MustParsePrefix("172.31.0.0/16"),
    netip.MustParsePrefix("192.168.100.0/24"),
    netip.MustParsePrefix("10.0.0.0/8"),
    netip.MustParsePrefix("172.31.128.0/20"),
}

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

for _, network := range allowedNetworks {
    fmt.Println(network.String())
}

/*
	10.0.0.0/8
	10.20.30.0/24
	172.31.0.0/16
	172.31.128.0/20
	192.168.100.0/24
*/

Порядок сортировки Compare:

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

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

  3. По маскированному IP-адресу (сетевой IP): 10.0.0.0/16 < 10.1.0.0/16

  4. По длине префикса: 10.0.0.0/8 < 10.0.0.0/16

  5. По немаскированному адресу (оригинальный IP): 10.0.0.0/8 < 10.0.0.1/8

Это соответствует порядку Python netaddr.IPNetwork и стандартной конвенции IANA.

Handle для процессов

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

procAttr := &os.ProcAttr{Files: []*os.File{os.Stdin, os.Stdout, os.Stderr}}
child, _ := os.StartProcess("/usr/bin/date", []string{"date", "+%Y-%m-%d"}, procAttr)
defer child.Wait()

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

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

Раньше доступа к handle процесса не было. Теперь он есть благодаря новому методу Process.WithHandle:

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

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

procAttr := &os.ProcAttr{Files: []*os.File{os.Stdin, os.Stdout, os.Stderr}}
child, _ := os.StartProcess("/usr/bin/date", []string{"date", "+%Y-%m-%d"}, procAttr)
defer child.Wait()

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

/*
	pid = 127
	handle = 8
	2025-12-31
*/

Handle гарантированно ссылается на процесс до возврата из callback-функции, даже если процесс уже завершился. Поэтому реализовано как callback, а не как поле или метод Process.Handle. WithHandle поддерживается только на Linux 5.4+ и Windows. На других ОС не выполняет callback и возвращает ошибку os.ErrNoHandle.

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

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

// Go 1.25

// Контекст завершится при получении SIGTERM
shutdownCtx, cancelShutdown := signal.NotifyContext(context.Background(), syscall.SIGTERM)
defer cancelShutdown()

// Симулируем получение SIGTERM
currentProcess, _ := os.FindProcess(os.Getpid())
_ = currentProcess.Signal(syscall.SIGTERM)

// Ожидаем завершения
<-shutdownCtx.Done()
fmt.Println("err =", shutdownCtx.Err())
fmt.Println("cause =", context.Cause(shutdownCtx))

/*
	err = context canceled
	cause = context canceled
*/

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

// Go 1.26

// Контекст завершится при получении SIGTERM
shutdownCtx, cancelShutdown := signal.NotifyContext(context.Background(), syscall.SIGTERM)
defer cancelShutdown()

// Симулируем получение SIGTERM
currentProcess, _ := os.FindProcess(os.Getpid())
_ = currentProcess.Signal(syscall.SIGTERM)

// Ожидаем завершения
<-shutdownCtx.Done()
fmt.Println("err =", shutdownCtx.Err())
fmt.Println("cause =", context.Cause(shutdownCtx))

/*
  err = context canceled
  cause = terminated signal received
*/

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

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

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

// Создаём ключевую пару для ECDSA подписи
signingKey, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
fmt.Println(signingKey.D)
// 42198376521098347612098374612098347612098347612098347612098374612098347612091

// Генерируем 128-битное простое число для криптографии
largePrime, _ := rand.Prime(rand.Reader, 128)
fmt.Println(largePrime)
// 287946352987463529874635298746352987463

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

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

// Параметр reader больше не используется, можно передать nil

// Создаём ключевую пару для ECDSA подписи
signingKey, _ := ecdsa.GenerateKey(elliptic.P384(), nil)
fmt.Println(signingKey.D)
// 91823746519823746519823746519823746519823746519823746519823746519823746519827

// Генерируем 128-битное простое число для криптографии
largePrime, _ := rand.Prime(nil, 128)
fmt.Println(largePrime)
// 198273465198273465198273465198273465

Изменение затрагивает следующие подпакеты 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, если он не nil. Если rand равен nil, используется внутренний защищённый источник случайности вместо crypto/rand.Reader (который можно переопределить).

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

func TestDeterministicPrimes(t *testing.T) {
    cryptotest.SetGlobalRandom(t, 777)

    // Все запуски теста сгенерируют одинаковые числа
    prime1, _ := rand.Prime(nil, 32)
    prime2, _ := rand.Prime(nil, 32)
    prime3, _ := rand.Prime(nil, 32)

    actual := [3]int64{prime1.Int64(), prime2.Int64(), prime3.Int64()}
    expected := [3]int64{2891726341, 4017283649, 3298174523}
    if actual != expected {
        t.Errorf("actual %v, expected %v", actual, expected)
    }
}

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

func TestRandomBytes(t *testing.T) {
    cryptotest.SetGlobalRandom(t, 777)

    t.Run("ReadBytes", func(t *testing.T) {
        var actual [4]byte
        rand.Read(actual[:])
        expected := [4]byte{91, 27, 183, 42}
        if actual != expected {
            t.Errorf("actual %v, expected %v", actual, expected)
        }
    })

    t.Run("RandomNumber", func(t *testing.T) {
        actual, _ := rand.Int(rand.Reader, big.NewInt(50000))
        const expected = 31847
        if actual.Int64() != expected {
            t.Errorf("actual %v, expected %v", actual.Int64(), expected)
        }
    })
}

Для временного возврата к старому поведению с reader установи GODEBUG=cryptocustomrand=1 (эта опция будет удалена в будущем релизе).

Защищённый режим для криптографии (экспериментально)

Криптографические протоколы типа WireGuard или TLS имеют свойство «forward secrecy» (прямая секретность). Даже если злоумышленник получит доступ к долгосрочным секретам (например, приватному ключу TLS), он не сможет расшифровать прошлые сессии. Для этого эфемерные ключи (временные ключи для согласования сессии) должны быть немедленно удалены из памяти после handshake.

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

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

secret.Do(func() {
    // Обрабатываем конфиденциальные данные
    // и производим безопасный обмен ключами
})

Пример использования в более реалистичном сценарии - генерация сессионного ключа с защитой эфемерного приватного ключа:

// EstablishSecureChannel создаёт защищённый канал связи с партнёром
func EstablishSecureChannel(remotePubKey *ecdh.PublicKey) (*ecdh.PublicKey, []byte, error) {
    var localPubKey *ecdh.PublicKey
    var channelKey []byte
    var err error

    // secret.Do ограничивает чувствительные данные временем handshake.
    // Эфемерный приватный ключ и сырой shared secret
    // будут уничтожены по завершении функции.
    secret.Do(func() {
        // 1. Генерируем эфемерный приватный ключ.
        // Критически важен - при утечке forward secrecy нарушается.
        ephemeralKey, e := ecdh.P384().GenerateKey(rand.Reader)
        if e != nil {
            err = e
            return
        }

        // 2. Вычисляем shared secret (ECDH).
        // Этот сырой секрет тоже критически важен.
        rawSecret, e := ephemeralKey.ECDH(remotePubKey)
        if e != nil {
            err = e
            return
        }

        // 3. Выводим финальный ключ канала (например, через HKDF).
        // Результат копируется наружу; входные данные (ephemeralKey, rawSecret)
        // будут уничтожены secret.Do при потере достижимости.
        channelKey = deriveKeyHKDF(rawSecret)
        localPubKey = ephemeralKey.PublicKey()
    })

    // Ключ канала возвращается для использования, но «рецепт» его создания
    // уничтожен. Поскольку ключ был аллоцирован внутри secret-блока,
    // runtime автоматически обнулит его, когда приложение закончит с ним работу.
    return localPubKey, channelKey, err
}

Эфемерный приватный ключ и сырой shared secret - «токсичные отходы»: они нужны для создания сессионного ключа, но опасны для хранения. Если они останутся в куче и злоумышленник получит доступ к памяти приложения (через core dump или уязвимость типа Heartbleed), он сможет восстановить сессионный ключ и расшифровать прошлые разговоры.

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

func main() {
    // Имитируем публичный ключ удалённой стороны
    remotePriv, _ := ecdh.P384().GenerateKey(nil)
    remotePubKey := remotePriv.PublicKey()

    // Устанавливаем защищённый канал
    localPubKey, channelKey, err := EstablishSecureChannel(remotePubKey)
    fmt.Printf("local public key = %x...\n", localPubKey.Bytes()[:16])
    fmt.Printf("error = %v\n", err)
    _ = channelKey
}
local public key = 047c3f91ab2e8d4c5f6a7b8c9d0e1f2a...
error = <nil>

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

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

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

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

Новый пакет simd/archsimd предоставляет доступ к архитектурно-специфичным векторизованным операциям (SIMD - single instruction, multiple data). Это низкоуровневый пакет с функциональностью, специфичной для железа. Пока поддерживается только amd64. Поскольку разные архитектуры CPU имеют очень разные SIMD-операции, сложно создать единый портируемый API. Поэтому команда Go решила начать с низкоуровневого API для amd64 - самой распространённой серверной платформы. Пакет определяет векторные типы как структуры, например Int8x16 (128-битный SIMD-вектор с шестнадцатью 8-битными целыми) и Float64x8 (512-битный вектор с восемью 64-битными float). Они соответствуют аппаратным векторным регистрам. Поддерживаются векторы шириной 128, 256 и 512 бит. Большинство операций определены как методы векторных типов и обычно напрямую транслируются в аппаратные инструкции без накладных расходов.

Пример функции умножения векторов float32 с использованием SIMD:

func MultiplyVectors(a, b []float32) []float32 {
    if len(a) != len(b) {
        panic("массивы должны быть одинаковой длины")
    }

    // Проверяем поддержку AVX-512
    if !archsimd.X86.AVX512() {
        return multiplyScalar(a, b)
    }

    output := make([]float32, len(a))
    total := len(a)
    idx := 0

    // 1. SIMD-цикл: обрабатываем по 16 элементов за раз
    for idx <= total-16 {
        // Загружаем по 16 элементов из обоих массивов
        va := archsimd.LoadFloat32x16Slice(a[idx : idx+16])
        vb := archsimd.LoadFloat32x16Slice(b[idx : idx+16])

        // Перемножаем все 16 элементов одной инструкцией
        // и сохраняем в выходной массив
        vProduct := va.Mul(vb) // транслируется в VMULPS
        vProduct.StoreSlice(output[idx : idx+16])

        idx += 16
    }

    // 2. Обработка остатка (0-15 элементов)
    for ; idx < total; idx++ {
        output[idx] = a[idx] * b[idx]
    }

    return output
}

Проверим на двух векторах:

func main() {
    prices := []float32{10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170}
    quantities := []float32{2, 3, 1, 4, 2, 1, 3, 2, 1, 4, 2, 1, 3, 2, 1, 4, 2}
    totals := MultiplyVectors(prices, quantities)
    fmt.Println(totals)
}

// [20 60 30 160 100 60 210 160 90 400 220 120 390 280 150 640 340]

Основные операции в archsimd:

  • Load / Store - загрузка и сохранение вектора из/в массив или слайс

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

  • Побитовые: And, Or, Not, Xor, Shift

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

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

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

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

Пакет использует только AVX-инструкции, не SSE. Пакет экспериментальный, включается через GOEXPERIMENT=simd при сборке.Итоги

Отличный релиз! Go 1.26 - один из самых насыщенных релизов за всю мою память:

  • Полезные обновления: расширенный new, типобезопасная проверка ошибок errors.AsType, детектор утечек горутин

  • Оптимизации производительности: новый GC Green Tea, ускорение cgo и аллокации памяти, оптимизация fmt.Errorf и io.ReadAll

  • Quality-of-life фичи: множественные обработчики логов slog.MultiHandler, артефакты тестов, обновлённый go fix

  • Специализированные экспериментальные пакеты: SIMD для векторизации и runtime/secret для forward secrecy