Go 1.27: что нового — подробный разбор для Go-разработчиков

Go 1.27 вышел в августе 2026 года и стал самым заметным релизом языка со времён дженериков в Go 1.18. В нём появились дженерик-методы, новый encoding/json/v2, пакет uuid в стандартной библиотеке, постквантовые подписи ML-DSA и стабильный профиль утечек горутин.

Это практический разбор для бэкенд-разработчиков: что изменилось, как это выглядит в коде, где можно споткнуться при обновлении и в каком порядке мигрировать продакшен-сервисы. Примеры написаны с нуля и проверены по официальным release notes и документации пакетов на pkg.go.dev.

Содержание

Коротко: что нового в Go 1.27

ОбластьИзменениеЧто сделать команде
ЯзыкМетоды с собственными параметрами типаПересмотреть хелперы-функции, которые просились в методы
ЯзыкПродвинутые поля в составных литералахПрогнать go fix (модернайзер embedlit)
ЯзыкВывод типов для дженерик-функций во всех контекстахУбрать явную инстанциацию F[int] там, где она лишняя
stdlibencoding/json/v2 и encoding/json/jsontextПрогнать тесты сериализации; v1 теперь работает на движке v2
stdlibПакет uuid (v4 и v7)Спланировать уход с github.com/google/uuid
stdlibcrypto/mldsa, ML-DSA в x509 и TLS 1.3Оценить размер сертификатов и рукопожатия
stdlibstrings.CutLast, bytes.CutLast, url.URL.Clone, big.Int.DivideЗаменить самописные утилиты
net/httpАвтодренаж Response.Body, приоритеты HTTP/2 (RFC 9218)Проверить настройки пула соединений
РантаймАллокации до 80 байт дешевле до 30%Ничего, бесплатный прирост
РантаймПрофиль goroutineleak вышел из экспериментаДобавить в тесты и мониторинг
ИнструментыНовые модернайзеры go fix, go doc pkg@version, stdversion в go testОбновить CI
ПлатформыmacOS 13 Ventura — минимумПроверить сборочные агенты
ЭкспериментПереносимый пакет simd (GOEXPERIMENT=simd)Попробовать на горячих циклах, не в проде

Изменения языка

Дженерик-методы

Теперь метод может объявить свои параметры типа, а не только пользоваться параметрами ресивера. До 1.27 любой «метод с дженериком» приходилось писать свободной функцией и передавать ресивер первым аргументом. Самый частый случай в бэкенде — типизированный HTTP-клиент:

type Client struct {
    base string
    hc   *http.Client
}

// Раньше: func GetJSON[T any](c *Client, ctx context.Context, path string) (T, error)
func (c *Client) GetJSON[T any](ctx context.Context, path string) (T, error) {
    var out T
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
    if err != nil {
        return out, err
    }
    resp, err := c.hc.Do(req)
    if err != nil {
        return out, err
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return out, fmt.Errorf("GET %s: %s", path, resp.Status)
    }
    err = json.UnmarshalRead(resp.Body, &out) // encoding/json/v2
    return out, err
}

user, err := api.GetJSON[User](ctx, "/users/42")

Второй сценарий — цепочки преобразований, где тип меняется на каждом шаге. Раньше метод Map на List[T] мог вернуть только List[T], теперь — любой List[U]:

type Result[T any] struct {
    val T
    err error
}

func Ok[T any](v T) Result[T] { return Result[T]{val: v} }

func (r Result[T]) Then[U any](f func(T) (U, error)) Result[U] {
    if r.err != nil {
        return Result[U]{err: r.err}
    }
    v, err := f(r.val)
    return Result[U]{val: v, err: err}
}

port := Ok(os.Getenv("PORT")).
    Then(func(s string) (string, error) { return strings.TrimSpace(s), nil }).
    Then(strconv.Atoi) // Result[int]

В самой стандартной библиотеке первым таким методом стал (*rand.Rand).N[Int intType](n Int) Int в math/rand/v2 — аналог функции rand.N, но для собственного генератора. Удобно для воспроизводимых тестов с фиксированным seed:

r := rand.New(rand.NewPCG(1, 2))
backoff := r.N(500 * time.Millisecond) // time.Duration, без приведений

Главное ограничение: методы интерфейсов не могут иметь параметров типа, а дженерик-метод не реализует метод интерфейса. Иначе рантайму пришлось бы инстанцировать код во время выполнения при вызове через интерфейс. Поэтому дженерик-методы — инструмент для конкретных типов (клиенты, репозитории, билдеры), а не способ проектировать абстракции. Если вам нужен мок такого клиента, интерфейс по-прежнему строится на недженерик-методе уровнем ниже (например, Do(*http.Request)).

Продвинутый пример: типобезопасный кэш с singleflight

Классическая проблема общего кэша — map[string]any и приведения типов по всему коду. С дженерик-методом один нетипизированный кэш отдаёт значения нужного типа, а singleflight гарантирует, что при «промахе» сотни одновременных запросов вызовут загрузку ровно один раз (защита от cache stampede):

import "golang.org/x/sync/singleflight"

type entry struct {
    val     any
    expires time.Time
}

type Cache struct {
    mu    sync.RWMutex
    items map[string]entry
    group singleflight.Group
}

func NewCache() *Cache { return &Cache{items: make(map[string]entry)} }

func (c *Cache) GetOrLoad[V any](ctx context.Context, key string, ttl time.Duration,
    load func(context.Context) (V, error)) (V, error) {

    var zero V
    c.mu.RLock()
    e, ok := c.items[key]
    c.mu.RUnlock()
    if ok && time.Now().Before(e.expires) {
        if v, ok := e.val.(V); ok {
            return v, nil
        }
    }

    res, err, _ := c.group.Do(key, func() (any, error) {
        v, err := load(ctx)
        if err != nil {
            return nil, err
        }
        c.mu.Lock()
        c.items[key] = entry{val: v, expires: time.Now().Add(ttl)}
        c.mu.Unlock()
        return v, nil
    })
    if err != nil {
        return zero, err
    }
    v, ok := res.(V)
    if !ok {
        return zero, fmt.Errorf("cache: ключ %q хранит %T, запрошен %T", key, res, zero)
    }
    return v, nil
}

// Один кэш на сервис, разные типы значений — V выводится из функции загрузки:
user, err := cache.GetOrLoad(ctx, "user:42", time.Minute,
    func(ctx context.Context) (*User, error) { return repo.UserByID(ctx, 42) })

plan, err := cache.GetOrLoad(ctx, "plan:pro", time.Hour,
    func(ctx context.Context) (Plan, error) { return billing.Plan(ctx, "pro") })

Подводный камень: singleflight выполняет загрузку с контекстом первого вызывающего. Если его запрос отменят, ошибку получат все ожидающие. В продакшене загрузку стоит запускать с context.WithoutCancel(ctx) и собственным таймаутом.

Продвинутый пример: ленивые пайплайны поверх iter.Seq

До 1.27 метод Map не мог менять тип элемента, поэтому «стримы» в Go писали вложенными вызовами функций. Теперь цепочка читается слева направо, а благодаря итераторам из Go 1.23 она ленивая: Take(100) остановит чтение источника после сотого элемента, даже если это курсор БД на миллион строк.

type Stream[T any] struct{ seq iter.Seq[T] }

func From[T any](seq iter.Seq[T]) Stream[T] { return Stream[T]{seq} }

func (s Stream[T]) Seq() iter.Seq[T] { return s.seq }

func (s Stream[T]) Filter(keep func(T) bool) Stream[T] {
    return Stream[T]{func(yield func(T) bool) {
        for v := range s.seq {
            if keep(v) && !yield(v) {
                return
            }
        }
    }}
}

func (s Stream[T]) Map[U any](f func(T) U) Stream[U] {
    return Stream[U]{func(yield func(U) bool) {
        for v := range s.seq {
            if !yield(f(v)) {
                return
            }
        }
    }}
}

func (s Stream[T]) Take(n int) Stream[T] {
    return Stream[T]{func(yield func(T) bool) {
        if n <= 0 {
            return
        }
        i := 0
        for v := range s.seq {
            if !yield(v) {
                return
            }
            if i++; i == n {
                return
            }
        }
    }}
}

emails := slices.Collect(
    From(slices.Values(users)).
        Filter(func(u User) bool { return u.Active }).
        Map(func(u User) string { return strings.ToLower(u.Email) }).
        Take(100).
        Seq(),
)

Продвинутые поля в литералах структур

Ключом в литерале структуры теперь может быть любой допустимый селектор поля, в том числе поле встроенной структуры. Это убирает шум в моделях с общей «базой» — типичный паттерн для ORM и DTO:

type Model struct {
    ID        int64
    CreatedAt time.Time
}

type Order struct {
    Model
    Total int64
}

// Go 1.26:
o := Order{Model: Model{ID: 1, CreatedAt: now}, Total: 990}

// Go 1.27:
o := Order{ID: 1, CreatedAt: now, Total: 990}

Мигрировать вручную не нужно: модернайзер embedlit в go fix перепишет литералы сам (для модулей с go 1.27 в go.mod).

Вывод типов для дженерик-функций

Раньше компилятор выводил тип при присваивании дженерик-функции переменной, но не везде. Теперь правило единое: если функция присваивается или приводится к известному функциональному типу, параметры выводятся автоматически — в полях структур, элементах map и слайсов, отправке в канал:

func Trim[S ~string](s S) S { return S(strings.TrimSpace(string(s))) }

type Field struct {
    Name      string
    Normalize func(string) string
}

fields := []Field{
    {Name: "email", Normalize: Trim}, // раньше: Trim[string]
}
validators := map[string]func(string) string{
    "login": Trim,
}

Стандартная библиотека

encoding/json/v2: строгий и быстрый JSON

После года жизни под GOEXPERIMENT=jsonv2 новый пакет стал частью стандартной библиотеки. Архитектура двухуровневая: encoding/json/jsontext отвечает за синтаксис (токены, Encoder, Decoder), а encoding/json/v2 — за отображение на типы Go. Старый encoding/json теперь работает поверх движка v2, но сохраняет семантику v1. По данным команды Go, десериализация стала заметно быстрее, сериализация — на уровне v1.

Главное удобство — потоковые функции без промежуточного []byte и опции вместо методов на декодере:

import (
    "encoding/json/jsontext"
    json "encoding/json/v2"
)

func createUser(w http.ResponseWriter, r *http.Request) {
    var in CreateUserRequest
    if err := json.UnmarshalRead(r.Body, &in); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    out := svc.Create(r.Context(), in)
    w.Header().Set("Content-Type", "application/json")
    json.MarshalWrite(w, out, jsontext.WithIndent("  "))
}

Что изменилось по умолчанию и как вернуть старое поведение точечно:

Поведениеencoding/json (v1)encoding/json/v2Опция для старого поведения
Невалидный UTF-8Заменяется на U+FFFDОшибкаjsontext.AllowInvalidUTF8(true)
Дубликаты ключейПобеждает последнийОшибкаjsontext.AllowDuplicateNames(true)
Имена полейБез учёта регистраС учётом регистраjson.MatchCaseInsensitiveNames(true)
nil-слайсnull[]json.FormatNilSliceAsNull(true)

Практический совет: строгие умолчания v2 — скорее плюс для безопасности. Дубликаты ключей и регистронезависимое сопоставление в v1 позволяли подсунуть прокси и сервису разные значения одного поля ({"role":"user","Role":"admin"}). Новые эндпоинты пишите на v2, а старые переводите только вместе с контрактными тестами: мобильный клиент, который ждёт null вместо [], сломается молча. Из API перед релизом убрали опции тега format и unknown, а inline переименовали в embed — если вы пробовали экспериментальную версию, проверьте теги. Вернуть старую реализацию v1 целиком можно через GOEXPERIMENT=nojsonv2, но этот флаг временный.

Продвинутый пример: потоковый импорт гигабайтного JSON

Связка jsontext.Decoder и json.UnmarshalDecode позволяет разбирать огромный массив по одному элементу, не держа его в памяти целиком. Опция RejectUnknownMembers делает разбор строгим: опечатка в имени поля у партнёра станет ошибкой, а не тихо потерянными данными.

func ImportEvents(r io.Reader, handle func(Event) error) error {
    dec := jsontext.NewDecoder(r)

    tok, err := dec.ReadToken()
    if err != nil {
        return err
    }
    if tok.Kind() != '[' {
        return fmt.Errorf("ожидался JSON-массив, получено %v", tok.Kind())
    }

    for i := 0; dec.PeekKind() != ']'; i++ {
        var ev Event
        if err := json.UnmarshalDecode(dec, &ev, json.RejectUnknownMembers(true)); err != nil {
            return fmt.Errorf("событие #%d: %w", i, err)
        }
        if err := handle(ev); err != nil {
            return fmt.Errorf("событие #%d: %w", i, err)
        }
    }
    _, err = dec.ReadToken() // закрывающая ']'
    return err
}

Продвинутый пример: кастомная сериализация без обёрток

В v2 тип может писать себя прямо в энкодер через MarshalJSONTo — без промежуточного []byte, как было с MarshalJSON. А для чужих типов, которые нельзя изменить, формат задаётся на стороне вызова через WithMarshalers:

// Секреты никогда не попадут в логи и ответы API в открытом виде.
type Secret string

func (Secret) MarshalJSONTo(enc *jsontext.Encoder) error {
    return enc.WriteToken(jsontext.String("***"))
}

type DBConfig struct {
    DSN      string        `json:"dsn"`
    Password Secret        `json:"password"`
    Timeout  time.Duration `json:"timeout"`
}

// time.Duration — чужой тип; выводим его как "1m30s" только в этом вызове.
durationAsText := json.MarshalToFunc(func(enc *jsontext.Encoder, d time.Duration) error {
    return enc.WriteToken(jsontext.String(d.String()))
})

json.MarshalWrite(os.Stdout, cfg,
    json.WithMarshalers(durationAsText),
    jsontext.WithIndent("  "),
)
// {"dsn": "...", "password": "***", "timeout": "1m30s"}

Пакет uuid

github.com/google/uuid — одна из самых импортируемых зависимостей в экосистеме, и теперь её можно убрать. Стандартный пакет uuid намеренно минимален: тип UUID [16]byte, генераторы New() (v4), NewV4(), NewV7(), разбор Parse/MustParse, константы Nil()/Max(), методы String, Compare, MarshalText, UnmarshalText, AppendText.

import "uuid"

type Event struct {
    ID      uuid.UUID `json:"id"` // сериализуется строкой через MarshalText
    Payload []byte    `json:"payload"`
}

// Первичный ключ в Postgres: v7 монотонно растёт со временем,
// поэтому вставки не фрагментируют B-tree индекс.
e := Event{ID: uuid.NewV7()}

id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
    http.Error(w, "bad id", http.StatusBadRequest)
    return
}

Какой вариант брать: v7 — для первичных ключей в одноузловой РСУБД и сортируемых журналов; v4 — для публичных идентификаторов (не раскрывает время создания) и распределённых БД вроде Spanner или CockroachDB, где монотонные ключи создают горячие диапазоны. Извлечения версии и времени, а также v1/v3/v5 в пакете нет — если они нужны, оставайтесь на сторонней библиотеке. Оба типа основаны на [16]byte, поэтому миграция постепенная: uuid.UUID(googleID) — обычное преобразование типов.

Продвинутый пример: uuid в database/sql и keyset-пагинация

Стандартный uuid.UUID не реализует sql.Scanner и driver.Valuer, поэтому для database/sql удобна тонкая обёртка. Она понимает и текстовый формат (Postgres uuid), и бинарный (BINARY(16) в MySQL):

type DBID uuid.UUID

func (id *DBID) Scan(src any) error {
    switch v := src.(type) {
    case string:
        u, err := uuid.Parse(v)
        *id = DBID(u)
        return err
    case []byte:
        if len(v) == 16 {
            copy(id[:], v)
            return nil
        }
        u, err := uuid.Parse(string(v))
        *id = DBID(u)
        return err
    default:
        return fmt.Errorf("DBID: неподдерживаемый тип %T", src)
    }
}

func (id DBID) Value() (driver.Value, error) { return uuid.UUID(id).String(), nil }

Так как v7 растёт со временем, он сам по себе служит курсором для keyset-пагинации: WHERE id > $1 ORDER BY id LIMIT 100 работает по индексу и не деградирует на глубоких страницах, в отличие от OFFSET. В памяти такие идентификаторы сортируются выражением метода:

slices.SortFunc(ids, uuid.UUID.Compare) // хронологический порядок для v7

crypto/mldsa: постквантовые подписи

Пакет реализует ML-DSA из стандарта FIPS 204 — пару к ML-KEM, который появился в Go 1.24. Подписи ML-DSA поддерживаются в crypto/x509 и в TLS 1.3 (схемы tls.MLDSA44, tls.MLDSA65, tls.MLDSA87).

priv, err := mldsa.GenerateKey(mldsa.MLDSA44())
if err != nil {
    return err
}

opts := &mldsa.Options{Context: "release-manifest/v1"} // разделяет назначения подписей
sig, err := priv.Sign(nil, manifest, opts)
if err != nil {
    return err
}

if err := mldsa.Verify(priv.PublicKey(), manifest, sig, opts); err != nil {
    return fmt.Errorf("подпись невалидна: %w", err)
}

Цена — размер: у ML-DSA-44 публичный ключ 1312 байт и подпись 2420 байт против 32 и 64 байт у Ed25519. Для внутреннего mTLS с длинными цепочками сертификатов это заметные килобайты на каждом рукопожатии. Разумная стратегия на 2026 год: начать с подписей долгоживущих артефактов (релизы, прошивки, манифесты), а TLS переводить после замеров. В crypto/tls заодно появился обмен ключами MLKEM1024, а Config.Rand объявлен устаревшим — для детерминированных тестов теперь есть testing/cryptotest.SetGlobalRandom.

Продвинутый пример: TLS-сервер с ML-DSA-сертификатом

Для стенда или внутреннего mTLS можно выпустить сертификат на ML-DSA-ключе и сразу поднять HTTPS-сервер с полностью постквантовым рукопожатием: подпись ML-DSA плюс гибридный обмен ключами X25519MLKEM768. mldsa.PrivateKey реализует crypto.Signer, поэтому подходит для x509.CreateCertificate без адаптеров.

priv, err := mldsa.GenerateKey(mldsa.MLDSA65())
if err != nil {
    log.Fatal(err)
}

tmpl := &x509.Certificate{
    SerialNumber: big.NewInt(1),
    Subject:      pkix.Name{CommonName: "payments.internal"},
    DNSNames:     []string{"payments.internal"},
    NotBefore:    time.Now(),
    NotAfter:     time.Now().Add(90 * 24 * time.Hour),
    KeyUsage:     x509.KeyUsageDigitalSignature,
    ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}

// Самоподписанный сертификат — только для стенда; в проде подписывает ваш CA.
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, priv.PublicKey(), priv)
if err != nil {
    log.Fatal(err)
}

srv := &http.Server{
    Addr:    ":8443",
    Handler: mux,
    TLSConfig: &tls.Config{
        MinVersion:       tls.VersionTLS13, // ML-DSA работает только в TLS 1.3
        Certificates:     []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: priv}},
        CurvePreferences: []tls.CurveID{tls.X25519MLKEM768},
    },
}
log.Fatal(srv.ListenAndServeTLS("", ""))

Перед переводом прода замерьте рост рукопожатия: ключ и подпись ML-DSA-65 заметно больше, чем у ML-DSA-44, а клиенты без поддержки ML-DSA соединиться не смогут. Для публичных сервисов пока безопаснее держать классический сертификат.

Мелкие, но полезные API

APIЧто делаетГде пригодится
strings.CutLast, bytes.CutLastДелит по последнему вхождению разделителяhost, port, ok := strings.CutLast(addr, ":"), расширения файлов
url.URL.Clone, url.Values.CloneГлубокая копияMiddleware, которые правят URL без гонок данных
big.Int.DivideЧастное и остаток с режимом округления (Trunc, Floor, Round, Ceil)Денежные расчёты
hash/maphash.Hasher, ComparableHasherКонтракт хеширования для пользовательских коллекцийСвои hash-map и кэши на дженериках
database/sql.ConvertAssign, driver.RowsColumnScannerДрайверы сканируют напрямую в целевые переменныеМеньше аллокаций при Rows.Scan (когда драйвер поддержит)
testing/synctest.Sleeptime.Sleep + synctest.Wait в одном вызовеТесты таймаутов и ретраев
httptest.NewTestServerHTTP-сервер на in-memory сети для synctestДетерминированные интеграционные тесты
unicodeОбновлён с Unicode 15 до 17Валидация имён и эмодзи

Продвинутый пример: детерминированные тесты ретраев на виртуальном времени

testing/synctest запускает тест в «пузыре» с фейковыми часами: time.Sleep, таймеры и time.After срабатывают мгновенно, как только все горутины пузыря заблокированы. Новый synctest.Sleep продвигает время и дожидается, пока горутины отработают. Тест экспоненциального бэкоффа на 3 секунды выполняется за микросекунды и никогда не флапает:

func Retry(ctx context.Context, attempts int, base time.Duration, op func() error) error {
    var err error
    for i := range attempts {
        if err = op(); err == nil || i == attempts-1 {
            return err
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(base << i): // 1s, 2s, 4s...
        }
    }
    return err
}

func TestRetryBackoff(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        var calls atomic.Int32
        check := func(want int32) {
            t.Helper()
            if got := calls.Load(); got != want {
                t.Fatalf("вызовов %d, ожидалось %d", got, want)
            }
        }

        done := make(chan error, 1)
        go func() {
            done <- Retry(t.Context(), 3, time.Second, func() error {
                calls.Add(1)
                return errors.New("temporary")
            })
        }()

        synctest.Wait()
        check(1)
        synctest.Sleep(time.Second) // первая пауза бэкоффа
        check(2)
        synctest.Sleep(2 * time.Second) // вторая, удвоенная
        check(3)
        if err := <-done; err == nil {
            t.Fatal("ожидалась ошибка после исчерпания попыток")
        }
    })
}

net/http

Два изменения напрямую влияют на продакшен. Первое: при Close() у Response.Body в HTTP/1 клиент сам дочитывает непрочитанный остаток (в консервативном лимите), чтобы соединение вернулось в пул. Идиома io.Copy(io.Discard, resp.Body) перед Close больше не обязательна. Второе: HTTP/2-сервер учитывает приоритеты клиента по RFC 9218; старое round-robin поведение включается Server.DisableClientPriority = true. Также появилось поле Server.MaxHeaderValueCount — защита от запросов с тысячами повторяющихся заголовков.

Рантайм и производительность

Профиль утечек горутин

Утечка горутины — самая частая причина медленного роста памяти в Go-сервисах: горутина ждёт канал, в который уже никто не напишет. В Go 1.26 для этого появился экспериментальный профиль, в 1.27 он доступен всем: pprof.Lookup("goroutineleak") и эндпоинт /debug/pprof/goroutineleak.

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

Классическая утечка и тест, который её ловит:

// При таймауте воркер навсегда застревает на ch <- v:
// читатель уже ушёл, а канал небуферизованный.
func fetchWithTimeout(ctx context.Context, f func() string) (string, error) {
    ch := make(chan string) // исправление: make(chan string, 1)
    go func() { ch <- f() }()
    select {
    case v := <-ch:
        return v, nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

func TestNoGoroutineLeaks(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
    defer cancel()
    fetchWithTimeout(ctx, func() string { time.Sleep(50 * time.Millisecond); return "ok" })

    time.Sleep(100 * time.Millisecond) // даём воркеру дойти до блокировки
    p := pprof.Lookup("goroutineleak")
    var buf bytes.Buffer
    p.WriteTo(&buf, 1) // стеки утёкших горутин
    if n := p.Count(); n > 0 {
        t.Fatalf("утекло горутин: %d\n%s", n, buf.String())
    }
}

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

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

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

func TestMain(m *testing.M) {
    code := m.Run()
    if code == 0 {
        if err := checkLeaks(); err != nil {
            fmt.Fprintln(os.Stderr, err)
            code = 1
        }
    }
    os.Exit(code)
}

func checkLeaks() error {
    p := pprof.Lookup("goroutineleak")
    var buf bytes.Buffer
    if err := p.WriteTo(&buf, 1); err != nil {
        return err
    }
    if n := p.Count(); n > 0 {
        return fmt.Errorf("утекло горутин: %d\n%s", n, buf.String())
    }
    return nil
}

В продакшене профиль снимается фоновым воркером и экспортируется как метрика Prometheus. Если подсистемы запускают горутины внутри pprof.Do, метки наследуются дочерними горутинами — по стекам из /debug/pprof/goroutineleak сразу видно, чья это утечка:

var leakedGoroutines = promauto.NewGauge(prometheus.GaugeOpts{
    Name: "go_goroutines_leaked",
    Help: "Горутины, заблокированные навсегда (профиль goroutineleak).",
})

func WatchLeaks(ctx context.Context, every time.Duration) {
    p := pprof.Lookup("goroutineleak")
    t := time.NewTicker(every)
    defer t.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-t.C:
            if err := p.WriteTo(io.Discard, 0); err == nil {
                leakedGoroutines.Set(float64(p.Count()))
            }
        }
    }
}

// Запуск подсистемы с метками — они попадут и в профиль утечек, и в трейсбеки.
pprof.Do(ctx, pprof.Labels("subsystem", "billing-sync"), func(ctx context.Context) {
    go syncLoop(ctx)
})
go WatchLeaks(ctx, 5*time.Minute)

Быстрые маленькие аллокации

Компилятор теперь вызывает специализированные по размеру функции выделения памяти. Аллокации меньше 80 байт стали дешевле до 30%, а программы с интенсивным выделением памяти в целом ускорились примерно на 1%. Плата — около 60 КБ к размеру бинарника. Если собираете для встраиваемых устройств или WASM, где важен каждый килобайт, оптимизацию можно отключить через GOEXPERIMENT=nosizespecializedmalloc (флаг планируют убрать в Go 1.28).

Метки в трейсбеках

Для модулей с go 1.27 метки горутин из runtime/pprof попадают в заголовок трейсбека при панике. Если вы помечаете горутины через pprof.Do(ctx, pprof.Labels("handler", "checkout"), ...), по логу падения сразу видно, какой хендлер упал. Не кладите в метки персональные данные: теперь они окажутся в логах. Отключение — GODEBUG=tracebacklabels=0.

Экспериментальный переносимый SIMD

Под GOEXPERIMENT=simd появился пакет simd с векторными типами вроде Int8s и Float32s, не привязанными к длине вектора. Он работает на всех архитектурах и использует аппаратные инструкции, где они есть. Низкоуровневый simd/archsimd получил поддержку arm64 Neon и WebAssembly SIMD по 128 бит. API нестабилен: используйте его для экспериментов с горячими циклами (хеши, парсеры, векторный поиск) и не выносите в публичные библиотеки.

Инструменты: go fix, go doc, go test, компилятор

go fix. После перезапуска в Go 1.26 команда стала главным инструментом модернизации кода. В 1.27 добавились модернайзеры atomictypes (перевод atomic.AddInt64(&x, 1) на типы вроде atomic.Int64), embedlit (упрощение литералов со встроенными полями), slicesbackward и unsafefuncs. Анализатор waitgroup переименован в waitgroupgo, fmtappendf удалён. Рекомендуемый порядок — отдельным коммитом после поднятия версии в go.mod:

go mod edit -go=1.27
go fix -diff ./...   # посмотреть изменения
go fix ./...         # применить
go test ./...

go doc. Понимает версии: go doc example.com/pkg@v1.2.3 показывает документацию конкретного релиза без добавления зависимости. Флаг -ex выводит список исполняемых примеров, а имя примера печатает его исходник — удобно сравнить API двух версий перед апдейтом.

go test. Теперь по умолчанию запускает vet-проверку stdversion: она сообщает о символах стандартной библиотеки, которые новее версии из go.mod. Если библиотека заявляет go 1.25, но зовёт strings.CutLast, тесты упадут. В go test -json появилось поле OutputType — пригодится авторам CI-репортеров.

go mod tidy. Для модулей с go 1.27 сам склеивает разрозненные блоки require в два: прямые и косвенные зависимости. Ожидайте дифф в go.mod при первом запуске.

go tool trace. -http=:6060 теперь слушает только localhost, как и pprof. Для доступа извне (например, из контейнера) укажите -http=0.0.0.0:6060 явно.

Компилятор и линкер. Имена замыканий стали короче и не зависят от инлайнинга — профили читать проще, но тесты, сравнивающие имена символов вроде main.handler.func1, могут потребовать правки. Относительные пути в //line теперь считаются от каталога файла (важно для кодогенераторов). Линкер получил флаги -macos и -macsdk, а инструменты compile, link, asm, cgo понимают response-файлы @file — это снимает проблему длины командной строки в Bazel и на Windows.

Совместимость и ломающие изменения

Обещание совместимости Go 1 сохраняется, но в 1.27 удалена целая пачка старых настроек GODEBUG. Если в go.mod (godebug) или в //go:debug стоит старое значение удалённой настройки, сборка упадёт. Значение, равное финальному умолчанию, просто игнорируется.

Что изменилосьКого затронетКак проверить
Удалён asynctimerchan: каналы time.Timer/Ticker всегда синхронныеКод с проверкой len(t.C) и старыми обходами Stop+drainПоиск по коду asynctimerchan и len(t.C)
Удалены tlsrsakex, tls3des, tls10server, tlsunsafeekm, x509keypairleafИнтеграции со старыми TLS-клиентами (платёжные терминалы, легаси-банки)Поиск по go.mod, Dockerfile, манифестам k8s
Удалён gotypesalias: go/types всегда создаёт AliasАвторы линтеров и кодогенераторовТесты анализаторов
encoding/json работает на движке v2, текст ошибок другойТесты, сравнивающие err.Error() со строкойПерейти на errors.As и проверку типа
compress/flate сжимает быстрее, но байты вывода отличаютсяGolden-файлы gzip/zip/PNG, контентные хеши и ETagСравнивать распакованное содержимое
net.UnixConn возвращает io.EOF без обёртки *net.OpErrorКод с ассертом на *net.OpErrorerrors.Is(err, io.EOF)
Недочитанный Response.Body дочитывается при CloseКлиенты, закрывающие большие ответы раноЛатентность Close, метрики пула соединений
macOS 13 Ventura — минимум, Bazaar (bzr) больше не поддерживаетсяСтарые сборочные агенты, модули на LaunchpadИнвентаризация CI-раннеров

Отдельно стоит отметить crypto/x509.SystemCertPool: на Windows и macOS он теперь учитывает переменные SSL_CERT_FILE и SSL_CERT_DIR. Если они выставлены в окружении разработчика (например, корпоративным прокси), верификация перейдёт на Go-верификатор. Вернуть системный — GODEBUG=x509sslcertoverrideplatform=0.

Чек-лист миграции на Go 1.27

Выкатывайте в два этапа: сначала новый тулчейн со старой строкой go в go.mod, затем — поднятие версии языка. Так проблемы рантайма отделяются от смены семантики, которая включается только для go 1.27.

  1. Обновить образы сборки и toolchain до go1.27.x, строку go пока не трогать.
  2. Найти удалённые настройки GODEBUG в go.mod, //go:debug, Dockerfile и манифестах.
  3. Прогнать go test -race ./... и бенчмарки, сравнить через benchstat.
  4. Проверить JSON-контракты: golden-файлы ответов, тексты ошибок декодирования.
  5. Проверить golden-файлы gzip/zip/PNG и ETag, завязанные на байты сжатия.
  6. Выкатить на canary, следить за p99, RSS и пулом HTTP-соединений.
  7. Запустить go mod edit -go=1.27, затем go mod tidy и go fix ./... отдельными коммитами.
  8. Добавить профиль goroutineleak в интеграционные тесты и мониторинг.
  9. Запланировать замену github.com/google/uuid и самописных CutLast.
  10. Новые эндпоинты писать на encoding/json/v2.

Если вы автор публичной библиотеки, не спешите поднимать go в go.mod: Go 1.26 остаётся поддерживаемой до выхода Go 1.28 (ожидается в феврале 2027), и дженерик-методы в API отрежут часть пользователей.

Собираем всё вместе: мини-сервис заказов на Go 1.27

Ниже — каркас продакшен-сервиса, в котором новые возможности работают вместе: дженерик-метод для чтения запросов, строгий encoding/json/v2, идентификаторы UUIDv7, роутинг по методу и пути из Go 1.22, отдельный pprof-сервер с профилем утечек и graceful shutdown.

package main

import (
    "context"
    json "encoding/json/v2"
    "errors"
    "log/slog"
    "net/http"
    _ "net/http/pprof" // /debug/pprof/*, включая goroutineleak
    "os"
    "os/signal"
    "sync"
    "syscall"
    "time"
    "uuid"
)

type Item struct {
    SKU string `json:"sku"`
    Qty int    `json:"qty"`
}

type CreateOrderRequest struct {
    Items []Item `json:"items"`
}

type Order struct {
    ID        uuid.UUID `json:"id"`
    CreatedAt time.Time `json:"created_at"`
    Items     []Item    `json:"items"`
}

var ErrNotFound = errors.New("not found")

type Store struct {
    mu     sync.RWMutex
    orders map[uuid.UUID]Order // uuid.UUID — массив [16]byte, годится как ключ map
}

func (s *Store) Save(o Order) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.orders[o.ID] = o
}

func (s *Store) Get(id uuid.UUID) (Order, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    o, ok := s.orders[id]
    if !ok {
        return Order{}, ErrNotFound
    }
    return o, nil
}

type API struct{ store *Store }

// Дженерик-метод: один хелпер на все типы запросов, с лимитом тела и строгим разбором.
func (a *API) readJSON[T any](w http.ResponseWriter, r *http.Request) (T, error) {
    var v T
    body := http.MaxBytesReader(w, r.Body, 1<<20) // 1 МБ
    err := json.UnmarshalRead(body, &v, json.RejectUnknownMembers(true))
    return v, err
}

func (a *API) writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    if err := json.MarshalWrite(w, v); err != nil {
        slog.Error("write response", "err", err)
    }
}

func (a *API) fail(w http.ResponseWriter, status int, msg string) {
    a.writeJSON(w, status, map[string]string{"error": msg})
}

func (a *API) createOrder(w http.ResponseWriter, r *http.Request) {
    in, err := a.readJSON[CreateOrderRequest](w, r)
    if err != nil {
        a.fail(w, http.StatusBadRequest, err.Error())
        return
    }
    if len(in.Items) == 0 {
        a.fail(w, http.StatusUnprocessableEntity, "items must not be empty")
        return
    }
    o := Order{ID: uuid.NewV7(), CreatedAt: time.Now().UTC(), Items: in.Items}
    a.store.Save(o)
    w.Header().Set("Location", "/orders/"+o.ID.String())
    a.writeJSON(w, http.StatusCreated, o)
}

func (a *API) getOrder(w http.ResponseWriter, r *http.Request) {
    id, err := uuid.Parse(r.PathValue("id"))
    if err != nil {
        a.fail(w, http.StatusBadRequest, "invalid order id")
        return
    }
    o, err := a.store.Get(id)
    if errors.Is(err, ErrNotFound) {
        a.fail(w, http.StatusNotFound, "order not found")
        return
    }
    a.writeJSON(w, http.StatusOK, o)
}

func main() {
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    api := &API{store: &Store{orders: make(map[uuid.UUID]Order)}}
    mux := http.NewServeMux()
    mux.HandleFunc("POST /orders", api.createOrder)
    mux.HandleFunc("GET /orders/{id}", api.getOrder)

    srv := &http.Server{
        Addr:              ":8080",
        Handler:           mux,
        ReadHeaderTimeout: 5 * time.Second,
        IdleTimeout:       60 * time.Second,
    }

    // pprof — только на localhost, наружу не публикуем.
    go func() {
        if err := http.ListenAndServe("127.0.0.1:6060", nil); err != nil {
            slog.Error("pprof server", "err", err)
        }
    }()

    go func() {
        <-ctx.Done()
        shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
        defer cancel()
        if err := srv.Shutdown(shutdownCtx); err != nil {
            slog.Error("shutdown", "err", err)
        }
    }()

    slog.Info("listening", "addr", srv.Addr)
    if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
        slog.Error("server", "err", err)
        os.Exit(1)
    }
}

Проверка вручную:

curl -s -X POST localhost:8080/orders -d '{"items":[{"sku":"GO-127","qty":2}]}'
# {"id":"0199a8c4-...","created_at":"2026-10-02T10:00:00Z","items":[{"sku":"GO-127","qty":2}]}

curl -s -X POST localhost:8080/orders -d '{"items":[],"Items":[{"sku":"x","qty":1}]}'
# 400: v2 различает регистр, а RejectUnknownMembers отклоняет поле "Items"

curl -s localhost:6060/debug/pprof/goroutineleak?debug=1

Что здесь важно для прода: лимит тела через MaxBytesReader защищает от гигантских запросов, строгий разбор не даёт клиенту протащить лишние поля, UUIDv7 в Location можно сразу использовать как курсор пагинации, а pprof-сервер на 127.0.0.1 не светит профили наружу.

Как замерить выигрыш от обновления

Обещанные «до 30% на маленьких аллокациях» и ускорение разбора JSON стоит проверить на своём коде. Минимальный набор бенчмарков (b.Loop доступен с Go 1.24):

package bench

import (
    jsonv1 "encoding/json"
    json "encoding/json/v2"
    "os"
    "testing"
)

type point struct{ X, Y int32 } // 8 байт — попадает в класс «маленьких» аллокаций

var sink *point

func BenchmarkSmallAlloc(b *testing.B) {
    b.ReportAllocs()
    for b.Loop() {
        sink = &point{X: 1, Y: 2} // глобальный sink заставляет значение уйти в кучу
    }
}

// Order — модель ответа вашего API (как в примере сервиса выше).
var payload, _ = os.ReadFile("testdata/orders.json") // реальный ответ вашего API

func BenchmarkUnmarshalV1(b *testing.B) {
    b.ReportAllocs()
    for b.Loop() {
        var v []Order
        if err := jsonv1.Unmarshal(payload, &v); err != nil {
            b.Fatal(err)
        }
    }
}

func BenchmarkUnmarshalV2(b *testing.B) {
    b.ReportAllocs()
    for b.Loop() {
        var v []Order
        if err := json.Unmarshal(payload, &v); err != nil {
            b.Fatal(err)
        }
    }
}

Сравнение двух тулчейнов на одном и том же коде. Строка go в go.mod должна оставаться ≤ 1.26, иначе старый тулчейн откажется собирать модуль:

go install golang.org/x/perf/cmd/benchstat@latest

GOTOOLCHAIN=go1.26.0 go test -run='^$' -bench=. -count=10 ./bench > old.txt
GOTOOLCHAIN=go1.27.0 go test -run='^$' -bench=. -count=10 ./bench > new.txt
benchstat old.txt new.txt

# Изолировать эффект нового аллокатора внутри Go 1.27:
GOEXPERIMENT=nosizespecializedmalloc go test -run='^$' -bench=SmallAlloc -count=10 ./bench > nomalloc.txt
benchstat nomalloc.txt new.txt

Запускайте не меньше 10 повторов (-count=10) на машине без фоновой нагрузки: benchstat покажет разницу только там, где она статистически значима (p < 0.05), и не даст принять шум за ускорение.

FAQ

Когда вышел Go 1.27?

В августе 2026 года, через шесть месяцев после Go 1.26, по обычному полугодовому циклу.

Нужно ли переписывать код с encoding/json на v2?

Нет. Старый пакет работает и сохраняет поведение v1, изменился только движок и текст ошибок. Переходите на v2 постепенно, начиная с нового кода.

Можно ли в Go 1.27 объявить дженерик-метод в интерфейсе?

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

Стоит ли заменить google/uuid на стандартный uuid?

Да, если вам хватает генерации v4/v7 и разбора строк. Для v1/v3/v5 и извлечения времени из v7 оставьте стороннюю библиотеку.

Станет ли сервис быстрее после обновления?

Скорее всего, немного: маленькие аллокации дешевле до 30%, десериализация JSON быстрее. Проверяйте на своих бенчмарках через benchstat.

Как найти утечки горутин в Go?

В Go 1.27 — через профиль goroutineleak: pprof.Lookup("goroutineleak") в тестах или /debug/pprof/goroutineleak в работающем сервисе.

Итоги

Go 1.27 стоит обновления уже сейчас: бесплатный прирост производительности, детектор утечек горутин и меньше сторонних зависимостей. Риски сосредоточены в трёх местах: удалённые GODEBUG, тесты на текст JSON-ошибок и golden-файлы сжатия. Дженерик-методы и json/v2 лучше внедрять постепенно — в новом коде и после поднятия go 1.27 в go.mod.

Читайте также

Источники: Go 1.27 Release Notes, документация пакета uuid, документация пакета crypto/mldsa.

Поделиться:

Ответить

Ваш адрес email не будет опубликован. Обязательные поля помечены *