Слабые указатели в Go: в консерватории не всё в порядке
- вторник, 1 апреля 2025 г. в 00:00:10
Меня зовут Дмитрий Солдатенко, я разработчик в Ви.Tech, IT-дочке ВсеИнструменты.ру. И теперь, когда формальное представление завершено, хочу поделиться своим, местами не очень формальным, батхертом по поводу слабых указателей.
Предполагается, что вы пишете на Go и хотя бы на уровне чтения релиз-ноутов знакомы с концепцией слабых указателей (weak pointers).
На первый взгляд, это полезный механизм для некоторых сценариев. Но у меня есть одна идеологическая и несколько фактических претензий к их реализации, о двух из которых вообще никто и нигде почему-то не упоминает.
Если вы читаете эту статью, значит, меня держат в плену я всё-таки довёл её до публикации, и она не повторила судьбу многих других. Постараюсь кратко и тезисно, пока мне не стало лень писать. =)
Главная идеологическая претензия: слабые ссылки разрушают абстракцию сборщика мусора. Они нарушают концепцию "бесконечной" памяти, где мы её только выделяем, но никогда явно не освобождаем.
Да, финализаторы тоже нарушают эту абстракцию — с них разработчики Go и открыли этот ящик Пандоры. Но финализаторы хотя бы имеют чёткое назначение — они единственный способ через GC освободить память, аллоцированную malloc. Про cleanup я пока сознательно не говорю ни слова, эту тему я отложил на следующую статью.
Слабые указатели раскрывают момент, когда память реально освобождается, что для приложения совершенно непредсказуемо. И, кстати, память может не освобождаться даже тогда, когда мы этого ждём (подробнее об этом расскажу в конце статьи).
Кроме того, со слабыми ссылками становится значимым, какую именно область памяти мы используем — и этому посвящена большая часть моих технических претензий.
Прежде чем двигаться дальше, нужно в крупных мазках, поговорить о механизме работы слабых указателей. Динамическая память в Go организована в спаны (span), в которых хранятся объекты. Помимо всего необходимого для работы рантайма, в спанах есть specials — односвязный список, содержащий записи о специальном поведении для содержащихся там объектов (по смещению).
Типов специальных записей несколько:
const (
_KindSpecialFinalizer = 1
_KindSpecialWeakHandle = 2 // <== наш чувак.
_KindSpecialProfile = 3
_KindSpecialReachable = 4
_KindSpecialPinCounter = 5
_KindSpecialCleanup = 6
)
Для _KindSpecialWeakHandle там хранится указатель на косвенный объект, на который будут ссылаться все weak.Pointer, созданные на нашего "пациента":
type specialWeakHandle struct {
_ sys.NotInHeap
special special
handle *atomic.Uintptr
}
Когда мы вызываем weak.Make, рантайм в первую очередь ищет уже созданный слабый указатель: находит спан, в котором размещен объект, на который мы ссылаемся, и пытается пробежаться по specials этого спана в поисках записи KindSpecialWeakHandle. Если такой записи нет, то создает новую.
Сначала покажу пример, потом объясню
type kilo [1_024]byte
type NonGC struct {
nonHeap *kilo
heap *kilo
}
func NewNonGC() *NonGC {
return &NonGC{
heap: &kilo{},
nonHeap: (*kilo)(C.malloc(C.size_t(1_024))),
}
}
Было у бабуси,
Два веселых буфера по 1 килобайту,
Один в куче, другой нет,
Поэт из меня не очень
Структура содержит два буфера одинакового размера, просто по разному аллоцированных. В общем то для кода который работает с экземпляром NonGC должно быть монопенисуально, каким способом созданы ее поля, они ведь одного типа.
Энакин_и_Падме_на_пикнике.jpg
o := NewNonGC()
copy(
o.heap[:],
[]byte("vseinstrumenti"),
)
fmt.Printf("me: %s\n",*o.heap)
//>> me: vseinstrumenti
weak0 := weak.Make(o.heap)
fmt.Printf("me: %s",*weak0.Value())
//>> me: vseinstrumenti
Все довольно ожидаемо, а теперь попробуем с nonHeap:
copy(
o.nonHeap[:],
[]byte("vseinstrumenti"),
)
fmt.Printf("me: %s\n",*o.nonHeap)
//>> me: vseinstrumenti
weak0 := weak.Make(o.nonHeap)
//ПАНИКА fatal error: getWeakHandle on invalid pointer
Причина проблемы очевидна, если вы внимательно прочитали предыдущий раздел. Поскольку nonHeap размещен вне кучи, у него нет спана, и Make закономерно завершается с паникой.
Можно было бы сделать какой-то воркэраунд для таких ситуаций, но проблема не в аллокации, а в освобождении. Если бы я мог сослаться на nonHeap через слабый указатель, возникла бы другая проблема:
runtime.SetFinalizer(o, func(obj *NonGC) {
C.free(unsafe.Pointer(obj.nonHeap))
})
В общем ничего тут не попишешь, просто надо запомнить на случай, если у вас или в зависимостях, есть код на C
Допустим malloc нигде, под капотом, у нас не используется. C обычными переменными weak должен ведь работать без проблем, верно? Спойлер да - только если они не глобальные, а если глобальные- то иногда.
Давайте сыграем в игру =)
Вот у меня несколько глобальных переменных, объявленных разными способами:
type obj struct {
i int
}
var (
noptrBss obj // Значение в block started by symbol
bss *obj // Указатель в block started by symbol
noptrData = obj{i: 1} // Значение в data
data = &obj{i: 1} // Указатель в data
dataNew = new(obj) // Указатель в data через new
)
Попробуйте, дорогой читатель,(мне очень приятно, если вы дочитали мой опус аж до сюда) угадать, на каких из этих переменных weak.Make сработает, а на каких вызовет панику?
Ставлю свой ужин, что вы не угадали
dataNew = new(obj)
Только это сработает, всё остальные вызовы будут вызывать паник.
Понятно, что глобальные переменные всегда достижимы, и практического смысла создавать на них слабый указатель нет. Но согласитесь, это не совсем то поведение, которого мы ждем от языка.
К счастью, конкретно с этим, команда разработки опомнилась. В феврале вышел коммит (который войдет в Go 1.25), добавляющий новую сущность immortalWeakHandles. В dev-сборке 1.25 weak.Make будет работать без паники для всех переменных из списка выше:
// go1.25-4b1ac7bbfe Mon Mar 3 11:07:14 2025 –080
// runtime/mheap.go
func getWeakHandle(p unsafe.Pointer) *atomic.Uintptr {
span := spanOf(uintptr(p))
if span == nil {
if isGoPointerWithoutSpan(p) {
return mheap_.immortalWeakHandles.getOrAdd(uintptr(p))
}
throw("getWeakHandle on invalid pointer")
}
........
Почему это не было реализовано сразу? А фиг знает, так уж вышло
Вы уже знаете что такое Tiny Objects? Возможно, вы, как и я, узнали о них благодаря финализаторам или при изучении документации к пакету weak.
Если вы не в курсе, что это такое, поясню в двух словах: объекты размером менее 16 байт и не содержащие указателей, группируются в памяти в слотах по 16 байт:
Даже если наша переменная больше не достижима, но в слоте, где выделена память под неё, остались другие достижимые объекты, GC не соберёт её. А значит, специальное поведение (в нашем случае, установка косвенного объекта слабого указателя в nil) не активируется.
На практике это выглядит так:
type TestStruct struct {
i uint8
s [2]byte
}
func main() {
obj := &TestStruct{
i: 1,
s: [2]byte{0x76, 0x69},
}
w := weak.Make(obj)
runtime.GC()
fmt.Printf("val: %p", w.Value() )
// >> val: 0x1400008200a
}
Это критически важно помнить , если вы решили завязать какую то логику на проверке weak.Value() == nil (я бы посоветовал вообще быть с этим очень аккуратным).
Я не назвал бы это прям проблемой, просто число 5 мне нравиться больше чем 4.
А вы в курсе что вызов weak.Value может парковать вашу горутину? Не надолго, и лишь на определенном этапе цикла GC, но все же.
Суть в том, что после завершения маркировки, когда рантайм начинает подготовку к STW, конверсия слабых указателей в сильные, несколько загодя блокируется и для горутин вызывающих ее, мир как будто встанет на паузу немного раньше.
Хорошая новость: если у вас GC срабатывает настолько часто, что небольшое увеличение времени простоя горутин заметно, то проблема не в слабых указателях.
А у вас нет чувства, что в последнее время новые фичи в Go вводятся как-то... бессистемно?