Релиз Go 1.24 запланирован на февраль, поэтому сейчас самое время узнать, что нового появилось в языке. Официальные release notes (примечания к релизу) довольно сухие, поэтому я подготовил интерактивную версию с множеством примеров, которые покажут, что именно изменилось и как теперь работает код.

Также я добавил ссылки на соответствующие предложения (?) и коммиты (??) для описанных фич. Рекомендую их изучить, чтобы лучше понять детали реализации.

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

Содержание

Обобщённые псевдонимы типов

Короткое напоминание: псевдоним типа в Go создаёт синоним для существующего типа, но при этом не создаёт новый тип.

Когда тип определяется на основе другого типа, эти типы считаются разными:

type ID int

var n int = 10
var id ID = 10

// id = n
// Ошибка на этапе компиляции:
// нельзя использовать n (переменную типа int) в качестве значения типа ID при присваивании

id = ID(n)
fmt.Printf("id is %T\n", id)

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

type ID = int

var n int = 10
var id ID = 10

id = n // работает без ошибок
fmt.Printf("id is %T\n", id)

В Go 1.24 теперь поддерживаются обобщённые (generic) псевдонимы типов: псевдонимы могут быть параметризованы, как и обычные определённые типы. Например, можно определить Set как обобщённый псевдоним для map, где значения имеют тип bool (хотя это не даёт особых преимуществ):

type Set[T comparable] = map[T]bool
set := Set[string]{"one": true, "two": true}

fmt.Println("'one' in set:", set["one"])
fmt.Println("'six' in set:", set["six"])
fmt.Printf("set is %T\n", set)

Соответствующие изменения внесены в спецификацию языка. На данный момент функцию можно отключить, установив переменную окружения GOEXPERIMENT=noaliastypeparams, но этот флаг будет удалён в Go 1.25.

? 46477 

Слабые указатели

Слабый указатель (weak pointer) ссылается на объект так же, как и обычный указатель. Но в отличие от обычного указателя, слабый указатель не удерживает объект в памяти. Если на объект ссылаются только слабые указатели, сборщик мусора может освободить занимаемую им память.

Допустим, у нас есть тип blob:

// Blob — это большой срез байтов.
type Blob []byte

func (b Blob) String() string {
    return fmt.Sprintf("Blob(%d KB)", len(b)/1024)
}

// newBlob возвращает новый Blob указанного размера в КБ.
func newBlob(size int) *Blob {
    b := make([]byte, size*1024)
    for i := range size {
        b[i] = byte(i) % 255
    }
    return (*Blob)(&b)
}

И указатель на объект размером 1000 КБ:

func main() {
    b := newBlob(1000) // 1000 KB
    fmt.Println(b)
}

Можно создать слабый указатель (weak.Pointer) из обычного с помощью weak.Make, а затем получить оригинальный указатель через метод Pointer.Value:

func main() {
    wb := weak.Make(newBlob(1000)) // 1000 KB
    fmt.Println(wb.Value())
}

Обычный указатель предотвращает удаление объекта сборщиком мусора:

func main() {
    heapSize := getAlloc()
    b := newBlob(1000)

    fmt.Println("value before GC =", b)
    runtime.GC()
    fmt.Println("value after GC =", b)
    fmt.Printf("heap size delta = %d KB\n", heapDelta(heapSize))
}
Что такое getAlloc и heapDelta?
// heapDelta возвращает разницу в КБ между 
// текущим размером кучи и предыдущим размером кучи.
func heapDelta(prev uint64) uint64 {
    cur := getAlloc()
    if cur < prev {
        return 0
    }
    return cur - prev
}

// getAlloc возвращает текущий размер кучи в КБ.
func getAlloc() uint64 {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    return m.Alloc / 1024
}

Слабый указатель позволяет сборщику мусора освободить память:

func main() {
    heapSize := getAlloc()
    wb := weak.Make(newBlob(1000))

    fmt.Println("value before GC =", wb.Value())
    runtime.GC()
    fmt.Println("value after GC =", wb.Value())
    fmt.Printf("heap size delta = %d KB\n", heapDelta(heapSize))
}

Как видно, Pointer.Value возвращает nil, если оригинальный объект был собран сборщиком мусора. Обратите внимание: нет гарантии, что nil вернётся сразу после того, как объект перестанет использоваться (или в любой последующий момент); решение о его удалении принимает runtime.

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

? 67552 • ?? 628455

Улучшенные финализаторы

Вернёмся к blob:

func main() {
    b := newBlob(1000)
    fmt.Printf("b=%v, type=%T\n", b, b)
}

Что, если нужно запустить функцию очистки, когда blob будет удалён сборщиком мусора?

Ранее для этого использовался runtime.SetFinalizer, но этот механизм был известен своей сложностью в использовании. Теперь появилась более удобная альтернатива — runtime.AddCleanup:

func main() {
    b := newBlob(1000)
    now := time.Now()
    // Регистрируем функцию очистки, которая будет выполняться, 
    // когда объект станет недоступным.
    runtime.AddCleanup(b, cleanup, now)

    time.Sleep(10 * time.Millisecond)
    b = nil
    runtime.GC()
    time.Sleep(10 * time.Millisecond)
}

func cleanup(created time.Time) {
    fmt.Printf(
        "object is cleaned up! lifetime = %dms\n",
        time.Since(created)/time.Millisecond,
    )
}

AddCleanup привязывает функцию очистки к объекту, которая выполняется, когда объект становится недоступным. Функция очистки запускается в отдельной горутине, которая последовательно обрабатывает все вызовы очистки в программе. К одному указателю можно привязать несколько функций очистки.

Обратите внимание на аргумент функции очистки:

// AddCleanup привязывает функцию очистки к ptr.  
// Через некоторое время после того, как ptr станет недоступным,  
// рантайм вызовет cleanup(arg) в отдельной горутине.
func AddCleanup[T, S any](ptr *T, cleanup func(S), arg S) Cleanup

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

Допустим, мы хотим реализовать WeakMap, в которой элементы автоматически удаляются, если на них больше никто не ссылается. Используем map со значениями типа weak.Pointer:

// WeakMap — это карта (map) со слабо ссылочными значениями.
type WeakMap[K comparable, V any] struct {
    store map[K]weak.Pointer[V]
    mu    sync.Mutex
}

// NewWeakMap создаёт новый WeakMap.
func NewWeakMap[K comparable, V any]() *WeakMap[K, V] {
    return &WeakMap[K, V]{
        store: make(map[K]weak.Pointer[V]),
    }
}

// Len возвращает количество элементов в карте.
func (wm *WeakMap[K, V]) Len() int {
    wm.mu.Lock()
    defer wm.mu.Unlock()
    return len(wm.store)
}

Получить значение просто:

// Get возвращает значение, хранящееся в карте для указанного ключа,  
// или nil, если значение отсутствует.
func (wm *WeakMap[K, V]) Get(key K) *V {
    wm.mu.Lock()
    defer wm.mu.Unlock()

    if wp, found := wm.store[key]; found {
        return wp.Value()
    }
    return nil
}

Как сделать так, чтобы элемент удалялся из map, когда runtime освобождает память, занимаемую значением? С runtime.AddCleanup это просто:

// Set устанавливает значение для указанного ключа.
func (wm *WeakMap[K, V]) Set(key K, value *V) {
    wm.mu.Lock()
    defer wm.mu.Unlock()

    // Создаёт слабую ссылку на значение.
    wp := weak.Make(value)

    // Удаляет элемент, когда значение будет собрано сборщиком мусора.
    runtime.AddCleanup(value, wm.Delete, key)

    // Сохраняет слабую ссылку в карте.
    wm.store[key] = wp
}

// Delete удаляет элемент по ключу.
func (wm *WeakMap[K, V]) Delete(key K) {
    wm.mu.Lock()
    defer wm.mu.Unlock()
    delete(wm.store, key)
}

Мы передаём текущий ключ в функцию очистки (wm.Delete), чтобы она знала, какой элемент удалить из map.

var sink *Blob

func main() {
    wm := NewWeakMap[string, Blob]()
    wm.Set("one", newBlob(10))
    wm.Set("two", newBlob(20))

    fmt.Println("Before GC:")
    fmt.Println("len(map) =", wm.Len())
    fmt.Println("map[one] =", wm.Get("one"))
    fmt.Println("map[two] =", wm.Get("two"))

    // Позволяем сборщику мусора освободить 
    // второй элемент, но не первый.
    sink = wm.Get("one")
    runtime.GC()

    fmt.Println("After GC:")
    fmt.Println("len(map) =", wm.Len())
    fmt.Println("map[one] =", wm.Get("one"))
    fmt.Println("map[two] =", wm.Get("two"))
}

Работает как надо!

Обратите внимание, что функция очистки не гарантированно выполняется сразу после того, как объект перестал быть доступен. Она может запуститься в произвольный момент в будущем.

С появлением AddCleanup использование SetFinalizer не рекомендуется. В новом коде следует отдавать предпочтение AddCleanup.

? 67535 • ?? 627695, 627975

Swiss Tables

Спустя многие годы команда Go изменила базовую реализацию map. Теперь она основана на SwissTable, что привело к ряду оптимизаций:

  • Операции чтения и записи в map с более чем 1024 элементами стали быстрее примерно на 30%.

  • Запись в заранее выделенные map стала быстрее примерно на 35%.

  • Итерация ускорилась в среднем на 10%, а для map с небольшим числом записей относительно размера — до 60%.

Бенчмарки

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

                                                          │ /tmp/noswiss.lu.txt │    /tmp/swiss.lu.txt           │
                                                          │       sec/op        │    sec/op      vs base         │
MapIter/impl=runtimeMap/t=Int64/len=64-12                          642.0n ±  3%    603.8n ±  6%   -5.95% (p=0.004 n=6)
MapIter/impl=runtimeMap/t=Int64/len=8192-12                        87.98µ ±  1%    78.80µ ±  1%  -10.43% (p=0.002 n=6)
MapIter/impl=runtimeMap/t=Int64/len=4194304-12                     47.40m ±  2%    44.41m ±  2%   -6.30% (p=0.002 n=6)
MapIterLowLoad/impl=runtimeMap/t=Int64/len=64-12                  145.85n ±  3%    92.85n ±  2%  -36.34% (p=0.002 n=6)
MapIterLowLoad/impl=runtimeMap/t=Int64/len=8192-12                13.205µ ±  0%    6.078µ ±  1%  -53.97% (p=0.002 n=6)
MapIterLowLoad/impl=runtimeMap/t=Int64/len=4194304-12              15.20m ±  1%    18.22m ±  1%  +19.87% (p=0.002 n=6)
MapIterGrow/impl=runtimeMap/t=Int64/len=64-12                     10.196µ ±  2%    8.092µ ±  8%  -20.63% (p=0.002 n=6)
MapIterGrow/impl=runtimeMap/t=Int64/len=8192-12                    1.259m ±  2%    1.008m ±  4%  -19.97% (p=0.002 n=6)
MapIterGrow/impl=runtimeMap/t=Int64/len=4194304-12                  1.424 ±  5%     1.275 ±  0%  -10.47% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=Int64/len=64-12                        14.08n ±  4%    15.28n ±  3%   +8.45% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=Int64/len=8192-12                      27.61n ±  1%    18.80n ±  1%  -31.89% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=Int64/len=4194304-12                   82.94n ±  1%   102.20n ±  0%  +23.22% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=Int32/len=64-12                        13.84n ±  5%    15.56n ±  2%  +12.39% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=Int32/len=8192-12                      26.90n ±  2%    18.47n ±  2%  -31.34% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=Int32/len=4194304-12                   79.60n ±  0%    93.00n ±  0%  +16.83% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=String/len=64-12                       16.36n ±  6%    18.69n ±  1%  +14.24% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=String/len=8192-12                     38.39n ±  1%    25.67n ±  1%  -33.13% (p=0.002 n=6)
MapGetHit/impl=runtimeMap/t=String/len=4194304-12                  146.0n ±  1%    172.2n ±  1%  +17.95% (p=0.002 n=6)
MapGetMiss/impl=runtimeMap/t=Int64/len=64-12                       15.63n ±  8%    15.08n ±  8%        ~ (p=0.240 n=6)
MapGetMiss/impl=runtimeMap/t=Int64/len=8192-12                     17.55n ±  1%    17.59n ±  4%        ~ (p=0.909 n=6)
MapGetMiss/impl=runtimeMap/t=Int64/len=4194304-12                 106.40n ±  1%    72.99n ±  2%  -31.40% (p=0.002 n=6)
MapGetMiss/impl=runtimeMap/t=Int32/len=64-12                       15.63n ±  7%    15.27n ±  8%        ~ (p=0.132 n=6)
MapGetMiss/impl=runtimeMap/t=Int32/len=8192-12                     17.18n ±  3%    17.25n ±  1%        ~ (p=0.729 n=6)
MapGetMiss/impl=runtimeMap/t=Int32/len=4194304-12                 100.15n ±  1%    74.71n ±  1%  -25.40% (p=0.002 n=6)
MapGetMiss/impl=runtimeMap/t=String/len=64-12                      18.96n ±  3%    18.19n ± 11%        ~ (p=0.132 n=6)
MapGetMiss/impl=runtimeMap/t=String/len=8192-12                    23.79n ±  3%    20.98n ±  2%  -11.79% (p=0.002 n=6)
MapGetMiss/impl=runtimeMap/t=String/len=4194304-12                134.85n ±  1%    84.82n ±  1%  -37.10% (p=0.002 n=6)
MapPutGrow/impl=runtimeMap/t=Int64/len=64-12                       5.886µ ±  3%    5.699µ ±  3%   -3.18% (p=0.015 n=6)
MapPutGrow/impl=runtimeMap/t=Int64/len=8192-12                     739.1µ ±  2%    816.0µ ±  4%  +10.41% (p=0.002 n=6)
MapPutGrow/impl=runtimeMap/t=Int64/len=4194304-12                  929.3m ±  1%    894.2m ±  5%        ~ (p=0.065 n=6)
MapPutGrow/impl=runtimeMap/t=Int32/len=64-12                       5.487µ ±  4%    5.326µ ±  2%   -2.93% (p=0.028 n=6)
MapPutGrow/impl=runtimeMap/t=Int32/len=8192-12                     681.6µ ±  2%    767.3µ ±  2%  +12.58% (p=0.002 n=6)
MapPutGrow/impl=runtimeMap/t=Int32/len=4194304-12                  831.9m ±  2%    802.9m ±  1%   -3.49% (p=0.002 n=6)
MapPutGrow/impl=runtimeMap/t=String/len=64-12                      7.607µ ±  2%    7.379µ ±  2%   -2.99% (p=0.002 n=6)
MapPutGrow/impl=runtimeMap/t=String/len=8192-12                    1.204m ±  4%    1.212m ±  4%        ~ (p=0.310 n=6)
MapPutGrow/impl=runtimeMap/t=String/len=4194304-12                  1.699 ±  2%     1.876 ±  1%  +10.37% (p=0.002 n=6)
MapPutPreAllocate/impl=runtimeMap/t=Int64/len=64-12                2.179µ ±  1%    1.428µ ±  5%  -34.47% (p=0.002 n=6)
MapPutPreAllocate/impl=runtimeMap/t=Int64/len=8192-12              277.6µ ±  2%    198.6µ ±  1%  -28.45% (p=0.002 n=6)
MapPutPreAllocate/impl=runtimeMap/t=Int64/len=4194304-12           389.7m ±  1%    518.2m ±  1%  +32.97% (p=0.002 n=6)
MapPutPreAllocate/impl=runtimeMap/t=Int32/len=64-12                1.784µ ±  2%    1.110µ ±  3%  -37.78% (p=0.002 n=6)
MapPutPreAllocate/impl=runtimeMap/t=Int32/len=8192-12              228.1µ ±  5%    151.4µ ±  4%  -33.62% (p=0.002 n=6)
MapPutPreAllocate/impl=runtimeMap/t=Int32/len=4194304-12           361.5m ±  1%    481.2m ±  1%  +33.10% (p=0.002 n=6)
MapPutPreAllocate/impl=runtimeMap/t=String/len=64-12               2.670µ ±  3%    2.167µ ±  3%  -18.81% (p=0.002 n=6)
MapPutPreAllocate/impl=runtimeMap/t=String/len=8192-12             380.1µ ±  2%    417.2µ ±  9%   +9.77% (p=0.015 n=6)
MapPutPreAllocate/impl=runtimeMap/t=String/len=4194304-12          493.1m ±  4%    718.1m ±  7%  +45.62% (p=0.002 n=6)
MapPutReuse/impl=runtimeMap/t=Int32/len=64-12                     1421.0n ±  3%    804.0n ±  5%  -43.42% (p=0.002 n=6)
MapPutReuse/impl=runtimeMap/t=Int32/len=8192-12                    192.4µ ±  1%    120.6µ ±  1%  -37.30% (p=0.002 n=6)
MapPutReuse/impl=runtimeMap/t=Int32/len=4194304-12                 364.0m ±  2%    473.0m ±  2%  +29.95% (p=0.002 n=6)
MapPutReuse/impl=runtimeMap/t=String/len=64-12                     1.602µ ±  4%    1.083µ ± 14%  -32.41% (p=0.002 n=6)
MapPutReuse/impl=runtimeMap/t=String/len=8192-12                   232.4µ ±  1%    165.7µ ±  2%  -28.68% (p=0.002 n=6)
MapPutReuse/impl=runtimeMap/t=String/len=4194304-12                440.4m ±  2%    672.5m ±  1%  +52.72% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=Int64/len=64-12                     34.25n ±  3%    37.76n ±  5%  +10.23% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=Int64/len=8192-12                   57.91n ±  2%    45.24n ±  2%  -21.89% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=Int64/len=4194304-12                170.5n ±  0%    222.0n ±  1%  +30.20% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=Int32/len=64-12                     34.06n ±  4%    37.87n ±  6%  +11.16% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=Int32/len=8192-12                   54.92n ±  1%    43.41n ±  2%  -20.96% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=Int32/len=4194304-12                153.4n ±  1%    178.3n ±  2%  +16.26% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=String/len=64-12                    42.11n ±  8%    48.48n ±  7%  +15.12% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=String/len=8192-12                  78.46n ±  1%    56.10n ±  2%  -28.50% (p=0.002 n=6)
MapPutDelete/impl=runtimeMap/t=String/len=4194304-12               204.6n ±  1%    261.4n ±  1%  +27.76% (p=0.002 n=6)

Вы можете отключить новую реализацию, установив GOEXPERIMENT=noswissmap во время сборки.

? 54766

Concurrent hash-trie map

Реализация sync.Map была изменена на конкурентный hash-trie, что повысило производительность, особенно при изменении map. Изменения непересекающихся наборов ключей реже приводят к конфликтам при работе с крупными map, и больше не требуется время на «прогрев» для достижения низкого уровня конфликтов.

Новая реализация превосходит старую почти во всех бенчмарках:

                                │     before      │                 after                 │
                                │     sec/op      │    sec/op      vs base                │
MapLoadMostlyHits                   7.870n ±   1%    8.415n ±  3%    +6.93% (p=0.002 n=6)
MapLoadMostlyMisses                 7.210n ±   1%    5.314n ±  2%   -26.28% (p=0.002 n=6)
MapLoadOrStoreBalanced             360.10n ±  18%    71.78n ±  2%   -80.07% (p=0.002 n=6)
MapLoadOrStoreUnique                707.2n ±  18%    135.2n ±  4%   -80.88% (p=0.002 n=6)
MapLoadOrStoreCollision             5.089n ± 201%    3.963n ±  1%   -22.11% (p=0.002 n=6)
MapLoadAndDeleteBalanced           17.045n ±  64%    5.280n ±  1%   -69.02% (p=0.002 n=6)
MapLoadAndDeleteUnique             14.250n ±  57%    6.452n ±  1%         ~ (p=0.368 n=6)
MapLoadAndDeleteCollision           19.34n ±  39%    23.31n ± 27%         ~ (p=0.180 n=6)
MapRange                            3.055µ ±   3%    1.918µ ±  2%   -37.23% (p=0.002 n=6)
MapAdversarialAlloc                245.30n ±   6%    14.90n ± 23%   -93.92% (p=0.002 n=6)
MapAdversarialDelete              143.550n ±   2%    8.184n ±  1%   -94.30% (p=0.002 n=6)
MapDeleteCollision                  9.199n ±  65%    3.165n ±  1%   -65.59% (p=0.002 n=6)
MapSwapCollision                    164.7n ±   7%    108.7n ± 36%   -34.01% (p=0.002 n=6)
MapSwapMostlyHits                   33.12n ±  15%    35.79n ±  9%         ~ (p=0.180 n=6)
MapSwapMostlyMisses                 604.5n ±   5%    280.2n ±  7%   -53.64% (p=0.002 n=6)
MapCompareAndSwapCollision          96.02n ±  40%    69.93n ± 24%   -27.17% (p=0.041 n=6)
MapCompareAndSwapNoExistingKey      6.345n ±   1%    6.202n ±  1%    -2.24% (p=0.002 n=6)
MapCompareAndSwapValueNotEqual      6.121n ±   3%    5.564n ±  4%    -9.09% (p=0.002 n=6)
MapCompareAndSwapMostlyHits         44.21n ±  13%    43.46n ± 11%         ~ (p=0.485 n=6)
MapCompareAndSwapMostlyMisses       33.51n ±   6%    13.51n ±  5%   -59.70% (p=0.002 n=6)
MapCompareAndDeleteCollision        27.85n ± 104%    31.02n ± 26%         ~ (p=0.180 n=6)
MapCompareAndDeleteMostlyHits       50.43n ±  33%   109.45n ±  8%  +117.03% (p=0.002 n=6)
MapCompareAndDeleteMostlyMisses     27.17n ±   7%    11.37n ±  3%   -58.14% (p=0.002 n=6)
MapClear                            300.2n ±   5%    124.2n ±  8%   -58.64% (p=0.002 n=6)
geomean                             50.38n           25.79n         -48.81%

Сценарий частых успешных чтений (MapLoadMostlyHits) стал немного медленнее из-за того, что Swiss Tables улучшили производительность старой версии sync.Map. Некоторые бенчмарки показывают значительное замедление, но это в основном связано с тем, что новая реализация сразу уменьшает размер map при удалении элементов, тогда как старая версия делала это поэтапно — сначала перемещая удалённые элементы в dirty map, прежде чем окончательно их удалять.

Конкурентный hash-trie (HashTrieMap) впервые появился в Go 1.23 в составе пакета unique. Он оказался быстрее оригинального sync.Map в большинстве случаев, поэтому команда Go переработала sync.Map как обёртку над HashTrieMap.

Чтобы отключить новую реализацию, установите GOEXPERIMENT=nosynchashtriemap во время сборки.

? 70683 • ?? 608335

Ограниченный каталогом доступ к файловой системе

Новый тип os.Root ограничивает файловые операции определённым каталогом.

Функция OpenRoot открывает каталог и возвращает Root:

dir, err := os.OpenRoot("data")
fmt.Printf("opened root=%s, err=%v\n", dir.Name(), err)

Методы Root ограничивают файловые операции пределами этого каталога, запрещая доступ к внешним путям:

file, err := dir.Open("01.txt")
fmt.Printf("opened file=%s, err=%v\n", file.Name(), err)

file, err = dir.Open("../main.txt")
fmt.Printf("opened file=%v, err=%v\n", file, err)

Методы Root дублируют большинство файловых операций, доступных в пакете os

file, err := dir.Create("new.txt")
fmt.Printf("created file=%s, err=%v\n", file.Name(), err)

stat, err := dir.Stat("02.txt")
fmt.Printf(
    "file info: name=%s, size=%dB, mode=%v, err=%v\n",
    stat.Name(), stat.Size(), stat.Mode(), err,
)

err = dir.Remove("03.txt")
fmt.Printf("deleted 03.txt, err=%v\n", err)

Завершив работу с Root, его следует закрыть:

func process(dir string) error {
    r, err := os.OpenRoot(dir)
    if err != nil {
        return err
    }
    defer r.Close()
    // выполняем операции
    return nil
}

После закрытия Root вызовы его методов будут возвращать ошибки:

err = dir.Close()
fmt.Printf("closed root, err=%v\n", err)

file, err := dir.Open("01.txt")
fmt.Printf("opened file=%v, err=%v\n", file, err)

Методы Root следуют символическим ссылкам, но такие ссылки не могут указывать за пределы корневого каталога. Символические ссылки должны быть относительными. Методы Root не ограничивают:

  • пересечение границ файловых систем,

  • монтирование с привязкой (bind mount) в Linux,

  • специальные файлы /proc,

  • доступ к файловым устройствам Unix.

На большинстве платформ создание Root открывает файловый дескриптор или хендл для каталога. Если каталог был перемещён, методы Root продолжают ссылаться на этот каталог в его новом местоположении.

? 67002 • ?? 612136, 627076, 627475, 629518, 629555

Benchmark loop

Вы, вероятно, знакомы с традиционным циклом бенчмарка (for range b.N):

var sink int

func BenchmarkSlicesMax(b *testing.B) {
    // Подготовка бенчмарка.
    s := randomSlice(10_000)
    b.ResetTimer()

    // Запуск бенчмарка.
    for range b.N {
        sink = slices.Max(s)
    }
}

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

Однако есть несколько нюансов:

  • Функция бенчмарка (BenchmarkSlicesMax) выполняется многократно, этап подготовки запускается каждый раз (избежать этого нельзя).

  • Нужно сбрасывать таймер (b.ResetTimer), чтобы исключить время подготовки из времени бенчмарка.

  • Чтобы убедиться, что компилятор не оптимизирует вызов тестируемой функции, используется переменная-поглотитель (sink variable).

В Go 1.24 появился новый, более быстрый и надёжный механизм — b.Loop, который заменяет традиционный цикл for range b.N:

func BenchmarkSlicesMax(b *testing.B) {
    // Подготовка бенчмарка.
    s := randomSlice(10_000)

    // Запуск бенчмарка.
    for b.Loop() {
        slices.Max(s)
    }
}

b.Loop устраняет недостатки подхода с b.N:

  • Функция бенчмарка выполняется один раз на каждые -count, поэтому инициализация и очистка выполняются только один раз.

  • Всё, что находится за пределами b.Loop, не влияет на время выполнения бенчмарка, поэтому b.ResetTimer не нужен.

  • Компилятор никогда не оптимизирует вызовы функций внутри b.Loop.

Бенчмарки должны использовать либо b.Loop, либо b.N, но не оба метода одновременно.

? 61515 • ?? 608798, 612043, 612835, 627755, 635898

Синтетическое (эмулируемое) время для тестирования

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

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

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

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

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

На помощь приходит новый пакет testing/synctest

Функция synctest.Run() создаёт изолированную среду в новой горутине, где функции пакета time работают с эмулированным временем. Это позволяет тесту проходить моментально:

func TestReadTimeout(t *testing.T) {
    synctest.Run(func() {
        ch := make(chan int)
        _, err := Read(ch)
        if err == nil {
            t.Fatal("ожидалась ошибка таймаута, но получили nil")
        }
    })
}

Горутины внутри bubble используют эмулируемое время. Изначальное время — полночь UTC, 1 января 2000 года. Время автоматически продвигается вперёд, когда все горутины внутри bubble блокируются.

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

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

synctest.Run(func() {
    const timeout = 5 * time.Second
    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    defer cancel()

    // Ждём чуть меньше таймаута.
    time.Sleep(timeout - time.Nanосекунда)
    synctest.Wait()
    fmt.Printf("до таймаута: ctx.Err() = %v\n", ctx.Err())

    // Дожидаемся оставшегося времени до таймаута.
    time.Sleep(time.Наносекунда)
    synctest.Wait()
    fmt.Printf("после таймаута: ctx.Err() = %v\n", ctx.Err())
})
before timeout: ctx.Err() = <nil>
after timeout:  ctx.Err() = context deadline exceeded

Пакет synctest является экспериментальным и должен быть включён вручную путём установки GOEXPERIMENT=synctest при сборке. API пакета может измениться в будущих версиях. 

? 67434 • ?? 629735, 629856

Контекст теста и рабочая директория

Допустим, мы хотим протестировать этот полезный сервер:

// Server предоставляет ответы на все вопросы.
type Server struct{}

// Get возвращает ответ от сервера.
func (s *Server) Get(query string) int {
    return 42
}

// startServer запускает сервер, который можно 
// остановить путем отмены контекста.
func startServer(ctx context.Context) *Server {
    go func() {
        select {
        case <-ctx.Done():
            // Освобождение ресурсов.
        }
    }()
    return &Server{}
}

И вот тест, который я написал:

func Test(t *testing.T) {
    srv := startServer(context.Background())
    if srv.Get("how much?") != 42 {
        t.Fatal("unexpected value")
    }
}

Ура, тест прошёл! Однако есть проблема: я использовал пустой контекст, поэтому сервер не остановился. Такая утечка ресурсов может стать проблемой, особенно если тестов много.

Чтобы это исправить, я создам отменяемый контекст и отменю его по завершении теста:

func Test(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    srv := startServer(ctx)
    if srv.Get("how much?") != 42 {
        t.Fatal("unexpected value")
    }
}

Ещё лучше: теперь я могу использовать новый метод T.Context. Он возвращает контекст, который автоматически отменяется после завершения теста:

func Test(t *testing.T) {
    srv := startServer(t.Context())
    if srv.Get("how much?") != 42 {
        t.Fatal("unexpected value")
    }
}

Но есть нюанс. Что, если освобождение ресурсов сервера занимает время? Горутина startServer начнёт очистку при закрытии контекста, но успеет ли она завершиться, прежде чем основная горутина завершит тест? Не обязательно.

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

func Test(t *testing.T) {
    srv := startServer(t.Context())
    t.Cleanup(func() {
        <-srv.Done()
    })
    if srv.Get("how much?") != 42 {
        t.Fatal("unexpected value")
    }
}

Изменения в коде сервера (спойлер)

Код сервера также изменился.
// Server предоставляет ответы на все вопросы.
type Server struct {
    done chan struct{}
}

// Get возвращает ответ от сервера.
func (s *Server) Get(query string) int {
    return 42
}

// Stop останавливает сервер.
func (s *Server) Stop() {
    // Симуляция долгой операции.
    time.Sleep(10 * time.Millisecond)
    fmt.Println("сервер остановлен")
    close(s.done)
}

// Done возвращает канал, который закрывается при остановке сервера.
func (s *Server) Done() <-chan struct{} {
    return s.done
}

// startServer запускает сервер, который можно
// остановить путем отмены контекста.
func startServer(ctx context.Context) *Server {
    srv := &Server{done: make(chan struct{})}
    go func() {
        select {
        case <-ctx.Done():
            srv.Stop()
        }
    }()
    return srv
}

Как и тесты, бенчмарки теперь имеют свой собственный B.Context.

? 36532 • ?? 603959, 637236

Кстати, о тестах: новые методы T.Chdir и B.Chdir изменяют рабочую директорию на время выполнения теста или бенчмарка:

func Test(t *testing.T) {
    t.Run("test1", func(t *testing.T) {
        // Изменяем рабочую директорию для текущего теста.
        t.Chdir("/tmp")
        cwd, _ := os.Getwd()
        if cwd != "/tmp" {
            t.Fatalf("неожиданная рабочая директория: %s", cwd)
        }
    })
    t.Run("test2", func(t *testing.T) {
        // Этот тест использует исходную рабочую директорию.
        cwd, _ := os.Getwd()
        if cwd == "/tmp" {
            t.Fatalf("неожиданная рабочая директория: %s", cwd)
        }
    })
}

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

? 62516 • ?? 529895

Отключение вывода логов

Простой способ создать логгер без вывода сообщений (например, для тестирования или бенчмаркинга) — использовать slog.TextHandler с io.Discard:

log := slog.New(
    slog.NewTextHandler(io.Discard, nil),
)
log.Info("Prints nothing")

Теперь можно просто использовать slog.DiscardHandler:

log := slog.New(slog.DiscardHandler)
log.Info("Ничего не выводится")

? 62005 • ?? 626486

Интерфейсы добавления данных

Новые интерфейсы encoding.TextAppender и encoding.BinaryAppender позволяют добавлять текстовое или бинарное представление объекта в существующий срез байтов:

type TextAppender interface {
    // AppendText добавляет текстовое представление объекта в конец `b`  
    // (выделяя дополнительную память при необходимости) и возвращает обновленный срез.
    //
    // Реализации не должны сохранять `b` или изменять любые байты в `b[:len(b)]`.
    AppendText(b []byte) ([]byte, error)
}

type BinaryAppender interface {
    // AppendBinary добавляет бинарное представление объекта в конец `b`  
    // (выделяя дополнительную память при необходимости) и возвращает обновленный срез.
    //
    // Реализации не должны сохранять `b` или изменять любые байты в `b[:len(b)]`.
    AppendBinary(b []byte) ([]byte, error)
}

Эти интерфейсы предоставляют тот же функционал, что и TextMarshaler и BinaryMarshaler, но вместо создания нового среза при каждом вызове добавляют данные напрямую в существующий.

Теперь эти интерфейсы реализованы стандартными типами библиотеки, которые уже поддерживали TextMarshaler или BinaryMarshaler:

  • math/big.Float

  • net.IP

  • regexp.Regexp

  • time.Time

  • и другие

// 2021-02-03T04:05:06Z
t := time.Date(2021, 2, 3, 4, 5, 6, 0, time.UTC)

var b []byte
b, err := t.AppendText(b)
fmt.Printf("b=%s, err=%v", b, err)

? 62384 • ?? 601595, 601776, 603255, 603815, 605056, 605758, 606655, 607079, 607520, 634515

Больше итераторов для строк и байтов

В Go 1.23 была введена поддержка итераторов, и теперь в стандартной библиотеке появилось ещё больше таких функций.

Новые функции в пакете strings:

Lines — возвращает итератор по строке s, разбивая её на строки, каждая из которых включает завершающий символ новой строки, за исключением последней, если s не оканчивается \n:

s := "one\ntwo\nsix"
for line := range strings.Lines(s) {
    fmt.Print(line)
}

SplitSeq — возвращает итератор по всем подстрокам строки s, разделённым разделителем sep:

s := "one-two-six"
for part := range strings.SplitSeq(s, "-") {
    fmt.Println(part)
}

SplitAfterSeq — возвращает итератор по подстрокам строки s, разделённым после каждого вхождения sep:

s := "one-two-six"
for part := range strings.SplitAfterSeq(s, "-") {
    fmt.Println(part)
}

FieldsSeq — возвращает итератор по подстрокам строки s, разделённым в местах последовательностей пробельных символов, как определено в unicode.IsSpace:

s := "one two\nsix"
for part := range strings.FieldsSeq(s) {
    fmt.Println(part)
}

FieldsFuncSeq — возвращает итератор по подстрокам строки s, разделённым в местах последовательностей символов Unicode, удовлетворяющих функции f(c):

f := func(c rune) bool {
    return !unicode.IsLetter(c) && !unicode.IsNumber(c)
}

s := "one,two;six..."
for part := range strings.FieldsFuncSeq(s, f) {
    fmt.Println(part)
}

Те же функции-итераторы были добавлены в пакет bytes.

? 61901 • ?? 587095

SHA-3 и связанные функции

Новый пакет crypto/sha3 реализует хеш-функцию SHA-3, а также функции с расширяемым выводом SHAKE и cSHAKE, как определено в стандарте FIPS 202:

s := []byte("go is awesome")
fmt.Printf("Source: %s\n", s)
fmt.Printf("SHA3-224: %x\n", sha3.Sum224(s))
fmt.Printf("SHA3-256: %x\n", sha3.Sum256(s))
fmt.Printf("SHA3-384: %x\n", sha3.Sum384(s))
fmt.Printf("SHA3-512: %x\n", sha3.Sum512(s))

? 69982 • ?? 629176

Добавлены два новых криптографических пакета:

crypto/hkdf — реализует функцию извлечения и расширения ключей HKDF на основе HMAC, как определено в RFC 5869.

? 61477 • ?? 630296

crypto/pbkdf2 — реализует функцию извлечения ключей на основе пароля PBKDF2, как определено в RFC 8018.

? 69488 • ?? 628135

HTTP-протоколы

В пакете net/http появились новые поля Server.Protocols и Transport.Protocols, которые позволяют простым способом настроить используемые HTTP-протоколы на сервере или клиенте:

t := http.DefaultTransport.(*http.Transport).Clone()

// Используем либо HTTP/1, либо HTTP/2.
t.Protocols = new(http.Protocols)
t.Protocols.SetHTTP1(true)
t.Protocols.SetHTTP2(true)

cli := &http.Client{Transport: t}
res, err := cli.Get("http://httpbingo.org/status/200")
if err != nil {
    panic(err)
}
res.Body.Close()

Поддерживаются следующие протоколы:

  • HTTP1 — HTTP/1.0 и HTTP/1.1. Поддерживается как по незащищённому TCP, так и через защищённое TLS-соединение.

  • HTTP2 — протокол HTTP/2 поверх TLS-соединения.

  • UnencryptedHTTP2 — протокол HTTP/2 поверх незащищённого TCP-соединения.

? 67814 • ?? 607496

Пропуск нулевых значений в JSON

В JSON-маршалере появилась новая опция omitzero, которая указывает пропускать нулевые значения. Это более читаемая и надёжная альтернатива omitempty, если цель — пропускать только нулевые значения. В отличие от omitempty, omitzero также исключает нулевые значения time.Time, что часто является проблемным местом.

Сравните omitempty:

type Person struct {
    Name      string    `json:"name"`
    BirthDate time.Time `json:"birth_date,omitempty"`
}

alice := Person{Name: "Alice"}
b, err := json.Marshal(alice)
fmt.Println(string(b), err)

С omitzero:

type Person struct {
    Name      string    `json:"name"`
    BirthDate time.Time `json:"birth_date,omitzero"`
}

alice := Person{Name: "Alice"}
b, err := json.Marshal(alice)
fmt.Println(string(b), err)

Если у типа поля есть метод IsZero() bool, он используется для проверки, является ли значение нулевым. В противном случае значение считается нулевым, если оно равно нулевому значению для своего типа.

? 45669 • ?? 615676

Случайный текст

Функция crypto/rand.Text возвращает криптографически случайную строку, используя стандартный алфавит Base32:

text := rand.Text()
fmt.Println(text)

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

? 67057 • ?? 627477

Зависимости от инструментов

Теперь Go-модули могут отслеживать зависимости исполняемых файлов, используя директиву tool в go.mod.

Чтобы добавить зависимость, используйте go get -tool:

go mod init sandbox
go get -tool golang.org/x/tools/cmd/stringer

Это добавляет зависимость с директивой require в go.mod:

module sandbox

go 1.24rc1

tool golang.org/x/tools/cmd/stringer

require (
    golang.org/x/mod v0.22.0 // indirect
    golang.org/x/sync v0.10.0 // indirect
    golang.org/x/tools v0.29.0 // indirect
)

Ранее приходилось использовать обходное решение — добавлять инструменты в виде пустых импортов в файл, который традиционно назывался "tools.go". Теперь в этом нет необходимости.

Команда go tool теперь может запускать эти инструменты, помимо тех, что поставляются с Go:

go tool stringer

Подробности — в документации.

? 48429

Поддержка JSON-вывода для команд build, install и test

Команды go build, go install и go test теперь поддерживают флаг -json, который выводит результаты и ошибки в формате JSON в стандартный поток вывода.

Пример вывода go test в обычном режиме:

go test -v
=== RUN   TestSet_Add
--- PASS: TestSet_Add (0.00s)
=== RUN   TestSet_Contains
--- PASS: TestSet_Contains (0.00s)
PASS
ok      sandbox 0.934s

Вывод go test в JSON-режиме:

go test -json
{"Time":"2025-01-11T19:22:29.280091+05:00","Action":"start","Package":"sandbox"}
{"Time":"2025-01-11T19:22:29.671331+05:00","Action":"run","Package":"sandbox","Test":"TestSet_Add"}
{"Time":"2025-01-11T19:22:29.671418+05:00","Action":"output","Package":"sandbox","Test":"TestSet_Add","Output":"=== RUN   TestSet_Add\n"}
{"Time":"2025-01-11T19:22:29.67156+05:00","Action":"output","Package":"sandbox","Test":"TestSet_Add","Output":"--- PASS: TestSet_Add (0.00s)\n"}
{"Time":"2025-01-11T19:22:29.671579+05:00","Action":"pass","Package":"sandbox","Test":"TestSet_Add","Elapsed":0}
{"Time":"2025-01-11T19:22:29.671601+05:00","Action":"run","Package":"sandbox","Test":"TestSet_Contains"}
{"Time":"2025-01-11T19:22:29.671608+05:00","Action":"output","Package":"sandbox","Test":"TestSet_Contains","Output":"=== RUN   TestSet_Contains\n"}
{"Time":"2025-01-11T19:22:29.67163+05:00","Action":"output","Package":"sandbox","Test":"TestSet_Contains","Output":"--- PASS: TestSet_Contains (0.00s)\n"}
{"Time":"2025-01-11T19:22:29.671638+05:00","Action":"pass","Package":"sandbox","Test":"TestSet_Contains","Elapsed":0}
{"Time":"2025-01-11T19:22:29.671645+05:00","Action":"output","Package":"sandbox","Output":"PASS\n"}
{"Time":"2025-01-11T19:22:29.672058+05:00","Action":"output","Package":"sandbox","Output":"ok  \tsandbox\t0.392s\n"}
{"Time":"2025-01-11T19:22:29.6721+05:00","Action":"pass","Package":"sandbox","Elapsed":0.392}

При этом в стандартный поток ошибок (stderr) могут выводиться сообщения об ошибках, не относящиеся к JSON, даже при использовании флага -json. Обычно это указывает на критические ошибки, возникшие на ранних этапах выполнения.

Формат JSON описан в go help buildjson.

? 62067

Версия главного модуля

Команда go build теперь устанавливает версию главного модуля (BuildInfo.Main.Version) в скомпилированном бинарном файле на основе версии в системе контроля версий (VCS). Если есть неотправленные изменения, к версии добавляется суффикс +dirty.

Программа для вывода версии:

// Получаем информацию о сборке, встроенную в выполняемый бинарный файл.
info, _ := debug.ReadBuildInfo()
fmt.Println("Версия Go:", info.GoVersion)
fmt.Println("Версия приложения:", info.Main.Version)

Пример вывода для Go 1.23:

Go version: go1.23.4
App version: (devel)

Пример вывода для Go 1.24:

Go version: go1.24rc1
App version: v0.0.0-20250111143208-a7857c757b85+dirty

Если текущий коммит соответствует тегированной версии, используется v<тег>[+dirty]:

v1.2.4
v1.2.4+dirty

Если текущий коммит не соответствует тегированной версии, значение устанавливается в <pseudo>[+dirty], где pseudo включает последний тег, текущую дату и хеш коммита:

v1.2.3-0.20240620130020-daa7c0413123
v1.2.3-0.20240620130020-daa7c0413123+dirty

Если информации о VCS нет, отображается (devel), как в Go 1.23.

Флаг -buildvcs=false предотвращает добавление информации о версии в бинарный файл.

? 50603

Заключение

Go 1.24 включает множество новых возможностей, среди которых слабые указатели, финализаторы и файловый доступ с областью видимости каталога. Значительные усилия были направлены на ускорение работы map, что является долгожданным улучшением.

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

Кроме того, приятным дополнением стали криптографические улучшения, такие как SHA-3 и генерация случайного текста.

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


В заключение рекомендуем Go-разработчикам посетить открытые уроки февраля в Otus:

  • 13 февраля: «Паттерны параллельного программирования».
    Узнаете, как эффективно использовать горутины и каналы для написания производительного кода. Покажем, в каких именно сценариях Go оказывается особенно мощным инструментом. Записаться

  • 27 февраля: «Управление зависимостями в Go: продвинутые техники и корпоративные паттерны».
    Разберём систему модулей в Go: управление зависимостями, прокси, локальный кэш, контроль сумм для безопасности. Также затронем приватные репозитории, Semantic Import Versioning и оптимизацию сборок в компании. Записаться

Весь список бесплатных онлайн-уроков февраля по разработке и не только можно посмотреть в календаре.

Комментарии (0)