golang

Глубокий разбор новых указателей в Go 1.24: слабые указатели и их реализация

  • воскресенье, 16 марта 2025 г. в 00:00:08
https://habr.com/ru/articles/891144/

Go 1.24 привнес в язык новый тип указателей – слабые указатели (weak pointers). В этой статье разберёмся, что они из себя представляют, как устроены внутри компилятора и runtime, а также как их использовать для оптимизации кода. Мы подробно изучим внутреннее устройство новых указателей, примеры их применения (например, для создания самоочищающихся кешей) и посмотрим, как они работают под капотом с точки зрения управления памятью и производительности.

Что такое слабые указатели и зачем они нужны

Слабый указатель – это особый вид указателя, который не мешает сборщику мусора освобождать объект, на который он ссылается. Иными словами, наличие только слабой ссылки на объект не делает объект «достижимым» для GC. Если больше нигде в программе нет обычных (сильных) ссылок на этот объект, сборщик мусора может его собрать, а все слабые указатели на него автоматически превращаются в nil. Таким образом, слабые указатели позволяют хранить «мягкие» ссылки на объекты — «намёк» сборщику мусора, что объект можно удалить, когда на него не осталось других ссылок.

В других языках программирования аналогичный механизм известен как «слабые ссылки» (weak references). Они широко применяются для реализации структур данных, эффективно управляющих памятью: например, кешей, которые не удерживают объекты в памяти дольше, чем нужно, или структур, привязывающих время жизни одного объекта к другому (аналогично WeakMap в JavaScript). Go до версии 1.24 не предоставлял прямого средства для слабых ссылок, из-за чего разработчикам приходилось изобретать обходные пути. Появление слабых указателей восполняет этот пробел и делает работу с подобными задачами удобнее.

Основные случаи использования слабых указателей:

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

  • Канонизация объектов (interning). Путём хранения единственного экземпляра объекта и выдачи слабых ссылок на него можно добиться ситуации, когда дублирующие объекты не создаются повторно. Если же объект перестал быть нужен (нет сильных ссылок), он будет очищен. (Для примитивных типов, вроде строк, в Go 1.23 уже появился пакет unique для интернирования, а слабые указатели расширяют эти возможности на произвольные типы.)

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

Слабые указатели в Go сделаны максимально простыми в использовании, но при этом мощными. Новый стандартный пакет weak предоставляет необходимый API:

  • weak.Pointer[T] – тип слабого указателя на значение типа T.

  • weak.Make(ptr *T) Pointer[T] – функция для создания слабого указателя из обычного указателя T.

  • (Pointer[T]).Value() *T метод, возвращающий исходный указатель *T, если объект ещё не собран, или nil, если объект уже был освобождён GC.

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

Внутренняя реализация в компиляторе и рантайме

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

При создании слабого указателя через weak.Make происходит следующая магия под капотом: для объекта, на который мы создаём слабую ссылку, выделяется отдельная небольшая структура – косвенный объект (indirection object). Этот объект занимает всего 8 байт и содержит единственное поле – указатель на исходный объект. Слабый указатель (weak.Pointer[T]) в свою очередь хранит ссылку на этот косвенный объект, а не напрямую на сам целевой объект.

Зачем нужен такой уровень косвенности? Дело в том, что он существенно упрощает работу сборщика мусора. Когда целевой объект становится недостижим (не осталось сильных ссылок), GC может атомарно обнулить указатель в косвенном объекте, тем самым сразу обнулив все слабые указатели, которые на него ссылаются. По сути, косвенный объект acts как прокси: пока объект жив, прокси содержит его адрес; когда объект должен быть очищен, прокси «отвязывается» (зануляется), и все слабые указатели замечают, что ссылки больше нет.

Такое решение добавляет минимальную память на оверхед (8 байт на объект с слабой ссылкой) и при этом делает реализацию очень простой и эффективной: в алгоритме сборки мусора требуется изменить совсем немного. Косвенные объекты позволяют не трогать существующую логику маркера достижимости GC – достаточно в конце цикла сборки пройтись по специальному списку слабых ссылок и очистить их прокси.

Кроме того, описанная модель автоматически задаёт удобную семантику сравнения слабых указателей. Раз два слабых указателя, указывающие на один и тот же исходный объект, ссылаются на один общий прокси-объект, они всегда будут равны друг другу при сравнении (даже если сам объект уже удалён). Слабые указатели на разные объекты, естественно, не равны. А если слабые указатели создавались на разные части одного объекта (например, на разные поля структуры), то они не будут равны, так как каждая слабая ссылка привязывается к точному адресу внутри объекта. Таким образом, можно, например, использовать weak.Pointer в качестве ключа в map или sync.Map — две слабые ссылки на один объект будут считаться одним и тем же ключом.

Изменения в сборщике мусора (GC)

Главная задача при внедрении слабых указателей – научить сборщик мусора игнорировать их при поиске достижимых объектов. В Go 1.24 это реализовано следующим образом:

  • Во время маркировки объектов GC пропускает слабые ссылки. Когда сборщик натыкается на объект типа weak.Pointer при обходе heap, он не помечает как достижимый тот объект, на который указывает слабая ссылка. Таким образом, слабые указатели не удерживают объекты от удаления.

  • После основной фазы маркировки (когда выявлены все живые объекты) GC проходит по всем косвенным объектам слабых ссылок. Если выясняется, что целевой объект не отмечен как живой (т.е. больше нигде не был доступен кроме слабых ссылок), сборщик мусора обнуляет указатель в соответствующем прокси-объекте. В результате метод Pointer.Value() для всех слабых указателей на этот объект начнёт возвращать nil. После этого память под сам объект будет освобождена как обычно.

  • Если же во время маркировки обнаружится, что объект всё-таки достижим через какую-либо сильную ссылку, то косвенный объект трогаться не будет (он продолжит хранить адрес), и слабые указатели останутся валидными, возвращая не свой Value().

Благодаря такому подходу в Go избежали сложных многопроходных алгоритмов для слабых ссылок. В некоторых языках (например, раньше в JVM) слабые ссылки рассматривались как эфемероны, требующие дополнительных фаз GC для правильной обработки взаимосвязанных объектов. В Go 1.24 слабые указатели сделаны намного проще: фактически это лишь «ярлыки» на объекты, которые сбрасываются при очистке, без необходимости откладывать удаление на несколько циклов сборки. Важное требование реализации – не допустить «воскрешения» объектов, которые были решены как мёртвые. Воскрешение означало бы, что после сборки мусора объект каким-то образом снова становится доступным (например, через побочный эффект финалайзера), что сильно усложнило бы логику. Слабые указатели спроектированы так, чтобы такого не происходило: как только объект признан недостижимым, все слабые ссылки на него тут же сбрасываются, и пути назад уже нет.

Особый случай: взаимодействие с финализаторами. Если на объект установлен финализатор (runtime.SetFinalizer), то возникает вопрос: когда обнулять слабую ссылку – до вызова финализатора или после? Go выбрал так называемую семантику «короткой» слабой ссылки (short weak reference) – слабый указатель считается недействительным уже в момент, когда GC поставил финализатор в очередь на выполнение, ещё до фактического исполнения финализатора. То есть, если объект уходит в сборку с финализатором, метод Pointer.Value() сразу начнёт возвращать nil, даже если финализатор ещё не отработал. Такая политика предотвращает потенциальные гонки: клиенты, получив nil из слабого указателя, гарантированно не смогут «воскресить» объект после финализации, ведь слабой ссылки уже нет. В других языках тоже рекомендуются короткие слабые ссылки – «длинные» (которые ждут полного окончания финализатора) считаются опасными и обычно либо не используются по умолчанию, либо вовсе не предоставляются.

Поддержка в компиляторе

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

  • Новый тип weak.Pointer[T] — это обобщённый тип, но компилятор компилирует его как особую структуру с невидимыми внутренними полями (косвенный указатель). Точные детали скрыты от программиста, поэтому мы не видим содержимого Pointer (в документации указано “contains filtered or unexported fields”). Компилятор по сути доверяет runtime и не оптимизирует сильно эти внутренности, чтобы не нарушить логику GC.

  • Вызовы weak.Make и Pointer.Value — компилируются в специальные вызовы runtime. Например, weak.Make вызывает внутреннюю функцию рантайма для создания/получения косвенного объекта и возврата слабого указателя. Аналогично, Pointer.Value при компиляции преобразуется, возможно, в встроенный runtime-хук, который считывает указатель из прокси и может при необходимости убедиться, что объект не «зомби». Все эти детали тщательно спроектированы, чтобы вы не могли случайно получить несогласованные данные.

  • Генерация вспомогательных структур. Компилятор обеспечивает, что для каждого типа T, для которого используется weak.Pointer[T], сгенерируется нужный код (мономорфизация generic). В остальном, для программиста слабый указатель ведёт себя как обычный struct-тип: его можно хранить в переменных, передавать по значению, сравнивать, и т.д., – всё это поддерживается компилятором из коробки.

Основная сложность легла не на компилятор, а на runtime, особенно на сборщик мусора, как описано выше. Однако и эти изменения минимальны: разработчики Go отмечают, что для поддержки слабых указателей «очень мало чего в GC пришлось менять». Большая часть работы выполняется через уже существующие механизмы финализаторов и специальных объектов в списках runtime.

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

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

Пример 1: Освобождение памяти больших объектов

Начнём с простого эксперимента. Представим, что у нас есть функция newBlob(sizeKB) которая выделяет в куче объект размером N килобайт (для наглядности). Сравним поведение сборщика мусора с обычным указателем и слабым:

type Blob struct { data []byte }

// newBlob выделяет Blob заданного размера (в KB)
func newBlob(kb int) *Blob {
    return &Blob{data: make([]byte, kb*1024)}
}

func main() {
    // 1. Обычный указатель
    heapSize := getHeapSize()
    b := newBlob(1000)               // выделяем ~1000 KB
    fmt.Println("Перед GC:", b)
    runtime.GC()
    fmt.Println("После GC:", b)      // объект всё ещё жив
    fmt.Printf("Рост кучи: %d KB\n", getHeapSize()-heapSize)

    // 2. Слабый указатель
    heapSize2 := getHeapSize()
    wb := weak.Make(newBlob(1000))   // создаём слабую ссылку на ~1000 KB
    fmt.Println("Перед GC (weak):", wb.Value())
    runtime.GC()
    fmt.Println("После GC (weak):", wb.Value())  // может стать nil
    fmt.Printf("Рост кучи: %d KB\n", getHeapSize()-heapSize2)
}
Перед GC: &{[1000000...]}     // Blob(1000 KB)
После GC: &{[1000000...]}      // объект не собран, т.к. на него есть b
Рост кучи: 1002 KB
Перед GC (weak): &{[1000000...]}   // Blob(1000 KB)
После GC (weak):             // объект собран, слабая ссылка обнулилась
Рост кучи: 2 KB

Здесь мы видим, что обычный указатель b удерживает объект в памяти, и после принудительного GC память не освобождается (рост кучи ~1000 КБ). Слабый же указатель wb не помешал сборщику мусора очистить объект – после runtime.GC() wb.Value() вернул nil, а дополнительная память почти не используется (рост всего ~2 КБ, грубо говоря это накладные данные). Таким образом, слабые указатели дают возможность автоматически освобождать крупные неиспользуемые объекты, избегая проблем с утечками памяти.

Пример 2: Самоочищающийся кеш значений

Рассмотрим задачу кэширования результатов дорогостоящей операции. Пусть функция ExpensiveCompute(key) вычисляет некоторое значение типа V по ключу key и работает долго или потребляет много памяти. Мы хотим кешировать результаты, чтобы повторные вызовы с тем же ключом не выполняли вычисление повторно. Однако, если определённый ключ больше не используется в программе, хотелось бы, чтобы и закешированное значение освобождалось из памяти.

Решение с помощью слабых указателей и нового механизма очистки (cleanups) может выглядеть так:

var cache sync.Map  // типизированный как sync.Map[string, weak.Pointer[V]]

func GetCached(key string) V {
    // Проверяем, есть ли слабая ссылка на результат в кеше
    if wpIface, ok := cache.Load(key); ok {
        wp := wpIface.(weak.Pointer[V])
        if valPtr := wp.Value(); valPtr != nil {
            // Объект ещё в памяти, возвращаем его
            return *valPtr
        }
        // Если wp.Value() == nil, значит объект уже сборщиком удалён
        cache.Delete(key)  // очищаем устаревшую запись
    }
    // Выполняем вычисление, т.к. в кеше нет актуального значения
    result := ExpensiveCompute(key)
    // Сохраняем *результат* в виде слабого указателя
    wp := weak.Make(&result)
    cache.Store(key, wp)
    // Регистрируем очистку: когда *result освободится, убрать запись из map
    runtime.AddCleanup(&result, func(k string) {
        cache.CompareAndDelete(k, wp)
    }, key)
    return result
}

Здесь cache хранит соответствие ключа (строка) и слабого указателя на значение. При запросе GetCached мы сначала пытаемся загрузить weak.Pointer из cache по ключу. Затем через wp.Value() проверяем, жив ли ещё объект. Если да – возвращаем его, избегая повторного вычисления. Если wp.Value() вернуло nil, значит ранее закешированное значение уже было удалено GC (на него не осталось сильных ссылок), поэтому мы удаляем запись из cache и вычисляем заново.

Интересная часть – регистрация runtime.AddCleanup. Эта новая функция (тоже новшество Go 1.24) по сути привязывает функцию очистки к объекту, которая вызовется, когда объект станет недостижимым. Мы привязываем к исходному результату функцию, удаляющую запись из кеша. Как только GC решит освободить result (когда на него не будет сильных ссылок кроме слабого указателя в мапе), наш cleanup вызовется и удалит слабый указатель из sync.Map. Таким образом, кеш “самоподдерживается” в чистоте: пока значение кому-то нужно, оно может быть возвращено; когда же нет – оно пропадает из памяти, а запись удаляется, не раздувая бесконечно структуру данных.

В результате мы получаем кеш, который не приводит к утечкам памяти: ненужные значения удаляются автоматически. Без слабых указателей подобную логику было реализовать трудно – пришлось бы вручную следить за временем жизни объектов или периодически чистить кеш. С новым API всё получается довольно прозрачно.

Стоит отметить, что слабые указатели в комбинации с runtime.AddCleanup превосходят старый механизм финализаторов (runtime.SetFinalizer). Ранее можно было пытаться удалять объекты из кеша в финализаторе, но это было ненадёжно: финализаторы сложно использовать правильно, они могли отработать с большой задержкой (объект держался в памяти минимум на два цикла GC) и требовали отсутствия циклических ссылок. Новый подход намного более прямой и лишён этих недостатков: нескольким объектам можно привязать несколько cleanup-функций, они могут срабатывать без задержек и даже для объектов, участвующих в циклах ссылок. Это делает слабые указатели + cleanups более гибким и эффективным инструментом, чем финализаторы.

Пример 3: Слабые ключи в структуре данных

Ещё один сценарий – когда нам нужно хранить дополнительные данные про объект, но не хотим продлевать жизнь этого объекта. Представьте, что мы строим кэш настроек или результатов по структурам, но ключом выступает сама структура (например, указатель на пользовательский объект). Если использовать обычную map[*Obj]Value, то пока эта map живёт, все ключевые объекты *Obj будут считаться достижимыми и не соберутся, даже если больше нигде не используются. Слабые указатели позволяют сделать map со слабыми ключами.

Например:

type Obj struct { /* ... */ }
var metaCache = sync.Map{}  // sync.Map[weak.Pointer[Obj], Meta]

func GetMeta(obj *Obj) Meta {
    key := weak.Make(obj)
    if meta, ok := metaCache.Load(key); ok {
        return meta.(Meta)
    }
    // Если нет, вычисляем Meta для obj
    meta := computeMeta(obj)
    metaCache.Store(key, meta)
    // Привязываем очистку: когда *obj уйдёт, убрать из кеша
    runtime.AddCleanup(obj, func(key weak.Pointer[Obj]) {
        metaCache.Delete(key)
    }, key)
    return meta
}

Здесь в качестве ключа в metaCache используется weak.Pointer[Obj] – слабая ссылка на объект Obj. При извлечении мы снова сравниваем через weak.Pointer (напомним, две слабые ссылки на один объект равны, пока объект не удалён). Если объект уже был собран, слабый ключ пропадёт (его Value() станет nil), и при очередном обращении мы просто не найдём ключ в map, рассчитав данные заново. Очистка удалит запись, когда объект умрёт, поэтому metaCache не будет постоянно расти.

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

Управление памятью. С точки зрения выделения памяти, слабые указатели добавляют небольшие накладные расходы. Как мы упоминали, при создании слабого указателя создаётся мини-объект-прокси (8 байт на 64-битной системе). Это значит, что если у вас очень много слабых указателей на множество разных объектов, память под эти прокси тоже будет использоваться. Однако в типичных сценариях слабые указатели применяются именно для оптимизации памяти, поэтому 8 байт на объект обычно мизерны по сравнению с размерами самих объектов. Например, для кеширования крупных структур или результатов вычислений выигрыш от освобождения мегабайт данных сильно перевесит затраты на несколько байт метаданных.

Слабые указатели позволяют сократить общее потребление памяти приложением, устраняя удержание давно неиспользуемых объектов. Это, в свою очередь, снижает давление на сборщик мусора. Меньший heap – реже запускается GC, меньше тратится времени на его проходы. В некоторых случаях применение слабых ссылок может заметно улучшить производительность программы за счёт того, что она реже «стопорится» на паузах GC и реже тратит CPU на уборку большого количества мусора.

Производительность доступа. Доступ к слабому указателю (вызов Value()) чуть медленнее, чем разыменование обычного указателя, но довольно быстрый. Фактически это две операции: чтение адреса из прокси-объекта и проверка, не nil ли он. Если объект ещё жив, получаем обычный указатель, с которым можно работать. Если объект уже собран, мы узнаем об этом сразу через nil. Разработчики Go отмечают, что при необходимости Value() может потребовать очистки объекта перед возвращением указателя, чтобы гарантировать отсутствие «воскрешения» (об этом была речь выше). Однако такую очистку он делает не чаще одного раза за цикл GC, и она происходит очень быстро и незаметно в общем случае. Иными словами, overhead на каждое разыменование слабого указателя минимален – обычно это просто чтение одного указателя из памяти.

Стоит упомянуть, что слабые указатели – инструмент для специализированных оптимизаций, и применять их следует осознанно. Они нарушают привычную абстракцию сборщика мусора, в том смысле, что вы начинаете задумываться о том, когда память освобождается (что обычно в Go скрыто от нас). Если переборщить с их использованием, можно усложнить логику программы. Поэтому команда Go рекомендует использовать weak пакет только в тех случаях, где без него сложно добиться приемлемой эффективности – например, для построения кешей, хранения метаданных и т.п. . В повседневном же коде, скорее всего, слабые указатели понадобятся редко.

Заключение

Слабые указатели в Go 1.24 – это мощное дополнение к языку, дающее программистам больше контроля над жизненным циклом объектов без отказа от автоматического управления памятью. Мы разобрали, что слабый указатель не удерживает объект от сборки мусора, и увидели на примерах, как это позволяет строить эффективные по памяти структуры (кеши, словари с «мягкими» ссылками). Мы также заглянули внутрь реализации: косвенный 8-байтовый объект-прокси, особая обработка в GC, отсутствие проблем финализаторов и минимальное влияние на производительность.

Эта функциональность сближает Go с возможностями других высокоуровневых языков (Java, C#, Python), где слабые ссылки давно нашли свое применение. При этом реализация в Go получилась лаконичной и безопасной: она избегает ловушек вроде воскрешения объектов и сложных таймингов. Для разработчиков это означает, что слабые указатели можно использовать относительно спокойно, не боясь загадочных багов — нужно лишь помнить о проверке на nil и о том, что сами по себе они не панацея, а инструмент для конкретных сценариев.

Go продолжает развиваться, и появление пакета weak – отличный пример, как язык адаптируется к требованиям пишущих на нём систем. Теперь у нас есть ещё один инструмент для написания высокопроизводительных и при этом безопасных по памяти программ. Экспериментируйте с новым API, и, возможно, ваши сервисы станут чуть быстрее и экономичнее благодаря этому нововведению!

P.S. Больше разборов Go и полезных материалов — в моём Telegram-канале: https://t.me/siliconchannel. Подписывайтесь.