Тур по Go 1.24

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

Псевдонимы generic-типов

Сначала напоминалка: псевдоним типа (type alias) в Go создает синоним для типа, не создавая новый тип.

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

type ID int

var n int = 10
var id ID = 10

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

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

type ID = int

var n int = 10
var id ID = 10

id = n // works fine
fmt.Printf("id is %T\n", id)
id is int

Go 1.24 поддерживает generic-псевдонимы типов: псевдоним типа может быть параметризован, как и определенный тип.

Например, можно определить Set как generic-псевдоним для map с логическими значениями (не то чтобы это было сильно полезно, но):

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)
'one' in set: true
'six' in set: false
Set is map[string]bool

Вполне возможно, что вы никогда не использовали обычные псевдонимы, и не будете использовать generic-псевдонимы. Но нужно же было пополнить вашу копилку бесполезных знаний :)

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

Если вы сильный программист, то не читайте дальше

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

Предположим, у нас есть тип blob (реализация скрыта для краткости):

// Blob is a large byte slice.
type Blob []byte

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

b := newBlob(1000)

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

wb := weak.Make(newBlob(1000))
fmt.Println(wb.Value())
// Blob(1000 KB)

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

b := newBlob(1000)
fmt.Println("before GC =", b)
runtime.GC()
fmt.Println("after GC =", b)
before GC = Blob(1000 KB)
after GC = Blob(1000 KB)

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

wb := weak.Make(newBlob(1000))
fmt.Println("before GC =", wb.Value())
runtime.GC()
fmt.Println("after GC =", wb.Value())
before GC = Blob(1000 KB)
after GC = <nil>

Как видите, Pointer.Value возвращает nil, если сборщик мусора уже освободил значение по указателю.

Пр этом нет гарантии, что nil вернется сразу после того, как объект перестал использоваться (или в любое другое время позже). Рантайм сам решает, когда освобождать память, и освобождать ли вообще.

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

Мы еще поговорим об этом в следующей секции.

runtime.AddCleanup

Помните наш блоб?

b := newBlob(1000)
fmt.Printf("b=%v, type=%T\n", b, b)
b=Blob(1000 KB), type=*main.Blob

Что если мы хотим запустить некоторую функцию очистки (cleanup function), когда объект будет собран сборщиком мусора?

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

func main() {
    b := newBlob(1000)
    now := time.Now()
    // Регистрируем функцию, которую рантайм
    // вызовет после сборки памяти объекта b.
    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,
    )
}
object is cleaned up! lifetime = 10ms

AddCleanup прикрепляет функцию очистки к объекту. Она выполняется после того как объект становится недоступен (на него больше никто не ссылается).

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

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

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

Швейцарские таблицы

Спустя много лет команда Go решила изменить реализацию map!

Начиная с Go 1.24, она основана на SwissTable и предлагает несколько оптимизаций (со слов разработчиков, лично не проверял):

  • Чтение и запись в больших картах (>1024 записей) быстрее на ~30%.
  • Запись в аллоцированных картах (с установленной емкостью) быстрее на ~35%.
  • Итерация в целом быстрее на ~10%, для карт с низкой наполненностью (большая емкость, мало записей) на ~60%.

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

Если интересно, вот исходники.

Конкурентно-безопасная карта

Лирическое отступление. Обычная карта в Go никогда не скукоживается, только растет пока не поглотит вселенную. GC не освобождает память, занятую самой картой, даже если удалять из нее элементы. Не изменилось это и в новой «швейцарской» карте в Go 1.24.

Но есть в Go еще одна карта, конкурентно-безопасная (sync.Map). И по странному стечению обстоятельств, в Go 1.24 у нее тоже новая реализация! Теперь она основана на concurrent hash-trie (помесь хэш-таблицы и префиксного дерева) и работает быстрее, особенно при модификациях карты.

Кроме того, новая sync.Map лучше освобождает память, чем предыдущая. Та тоже умела это делать, но там использовалась «поколенческая» модель, и память собиралась с запаздыванем. В новой никаких поколений нет, и память освобождается по мере удаления элементов.

Исходно новую расчудесную карту сделали для пакета unique в Go 1.23 — там как раз нужен был конкурентно-безопасный кэш. А теперь заметили, что и для пакета sync новая реализация отлично подходит. В результате, sync.Map теперь по сути фасад к HashTrieMap.

Если вы страшный ретроград, вернуться к старой sync.Map можно через переменную GOEXPERIMENT=nosynchashtriemap при сборке.

os.Root

Тип os.Root (Go 1.24+) ограничивает операции с файловой системой определенной директорией.

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

dir, err := os.OpenRoot("data")
fmt.Println(dir.Name(), err)
// data <nil>

Методы Root работают внутри директории и не позволяют использовать пути за ее пределами:

file, err := dir.Open("01.txt")
fmt.Println(file.Name(), err)
// data/01.txt <nil>

file, err = dir.Open("../main.txt")
fmt.Println(err)
// openat ../main.txt: path escapes from parent

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

file, err := dir.Create("new.txt")
stat, err := dir.Stat("02.txt")
err = dir.Remove("03.txt")

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

dir, err := os.OpenRoot(path)
defer dir.Close()
// do stuff

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

B.Loop

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

var sink int

func BenchmarkSlicesMax(b *testing.B) {
    // Setup the benchmark.
    s := randomSlice(10_000)
    b.ResetTimer()

    // Run the benchmark.
    for range b.N {
        sink = slices.Max(s)
    }
}

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

Но есть и нюансы:

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

Go 1.24 предлагает кое-что получше — testing.B.Loop:

func BenchmarkSlicesMax(b *testing.B) {
    // Setup the benchmark.
    s := randomSlice(10_000)

    // Run the benchmark.
    for b.Loop() {
        slices.Max(s)
    }
}

b.Loop решает все проблемы b.N:

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

В общем, с выходом Go 1.24 больше нет причин использовать for range b.N. Переходим на b.Loop!

Контекст для тестов

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

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

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

// Запускает сервер. Остановка через отмену контекста.
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")
    }
}

Но практика показывает, что люди часто забывают это делать 🤷‍♀️

Поэтому в Go 1.24 добавили метод T.Context. Он возвращает контекст, который автоматически отменяется перед тем, как тест завершится:

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

Удобно!

Больше итераторов

Как известно, в 1.23 авторы Go воспылали необъяснимой стратью к итератором, и этот пожар с тех пор только разгорается жарче.

Вот притащили еще горстку в пакете strings.

Lines итерирует по строкам, разделенным \n:

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

SplitSeq итерирует по частям, разделенным произвольным разделителем:

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

SplitAfterSeq как SplitSeq, но делит после разделителя:

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

FieldsSeq итерирует по частям, разделенным пробельными символами (unicode.IsSpace) и их последовательностями:

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

FieldsFuncSeq как FieldsSeq, но логику «пробельных» символов определяете вы сами:

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)
}
one
two
six

Ровно такие же итераторы добавили в пакет bytes.

SHA-3 и его друзья

Пакет crypto/sha3 в Go 1.24+ реализует хеш-функцию SHA-3 и другую криптографическую хурму, про которую вам вряд ли интересно знать (смотрите 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))
Source: go is awesome
SHA3-224: 6df...94a
SHA3-256: ece...5c4
SHA3-384: c7b...47c
SHA3-512: 9d4...c5e

И еще несколько crypto-пакетиков:

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

Опция omitzero в Go 1.24+ инструктирует JSON-маршалер пропускать нулевые значения.

Вообще у нас уже был для этого omitempty, но omitzero вроде как поудобнее будет. Например, он пропускает нулевые значения time.Time, чего omitempty делать не умеет.

Вот 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))
{"name":"Alice","birth_date":"0001-01-01T00:00:00Z"}

А вот 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))
{"name":"Alice"}

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

Если метода нет, используется стандартное понятие нулевого значения (0 для целого, "" для строки, и так далее).

Наверно, стоило бы сразу задизайнить так omitempty, но кто же знал :)

Заглушить логи

С появлением пакета log/slog в Go 1.21 наконец-то стало возможно нормально вести журналы без использования внешних библиотек.

Если еще не пробовали, то рекомендую простенький туториал и доку, она понятная и с примерами.

log := slog.New(slog.NewTextHandler(os.Stdout, nil))
log.Info("operation", "count", 3, "took", 50*time.Millisecond)
time=2024-12-09T10:20:47.660+00:00 level=INFO msg=operation count=3 took=50ms

Но вообще сказать я хотел не о самом slog, а о том, как в нем заглушить логи (например, для тестов или бенчмарков).

Сделать это несложно — просто используйте io.Discard в качестве приемника для slog.TextHandler:

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

А в Go 1.24 появился еще более простой способ — через slog.DiscardHandler:

log := slog.New(slog.DiscardHandler)
log.Info("Prints nothing")

Вроде и так не особо сложно было, ну да ладно :)

rand.Text

Небольшое, но весьма приятное дополнение стандартной библиотеки в Go 1.24. Появилась функция crypto/rand.Text, которая возвращает криптографически случайную строку:

text := rand.Text()
fmt.Println(text)
// 4PJOOV7PVL3HTPQCD5Z3IYS5TC

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

Использует алфавит Base32.

Заключение

Go 1.24 принес много новых фич: слабые указатели, новая очистка, доступ к файловой системе в рамках одного каталога. Разработчики сильно ускорили работу с картами, что очень приятно. Команда Go явно заботится о программистах: теперь проще и безопаснее писать бенчмарки, тестировать многозадачный код и использовать допольнительные инструменты. Появились и улучшения в криптографии, например, поддержка SHA-3 и генерация случайного текста.

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

★ Подписывайтесь на канал и проходите курсы.