Перевод/заметки Go Runtime Finalizer and Keep Alive
1. Finalizer
В Go есть функция runtime.SetFinalizer
, которая позволяет вам установить так называемый финализатор для объекта.
Финализатор — это функция, привязанная к объекту и предназначенная для выполнения определенных действий, когда сборщик мусора решает, что объект больше не нужен.
type FourInts struct {
A int; B int; C int; D int
}
func final() {
a := &FourInts{}
runtime.SetFinalizer(a, func(a *FourInts) {
fmt.Println("finalizer of FourInts called")
})
}
func main() {
final()
runtime.GC()
time.Sleep(time.Millisecond)
}
// Output: finalizer of FourInts called
В этом фрагменте кода у меня есть указатель, который ссылается на экземпляр структуры FourInts
. Я установил для этого указателя финализатор. Когда сборщик мусора запустится, он вызовет финализатор.
Сборщик мусора запустит финализатор, но это не произойдёт сразу после того, как объект перестанет использоваться. Время запуска финализатора не определено, поэтому вы не можете полагаться на его немедленное выполнение.
Поскольку сборщик мусора работает в фоновом режиме, время выполнения финализаторов полностью зависит от цикла GC. Эта непредсказуемость придаёт финализаторам некоторое «магическое» свойство.
Go не поощряет использование финализаторов, как деструкторов в других языках, главным образом потому, что, полагаясь на них таким образом, можно получить утечку памяти, если сборщик мусора не сработает в ожидаемое время.
Вот небольшой пример, иллюстрирующий, насколько непредсказуемыми могут быть финализаторы:
type FourBytes struct {
A byte; B byte; C byte; D byte
}
func final() {
a := &FourBytes{}
runtime.SetFinalizer(a, func(a *FourBytes) {
fmt.Println("finalizer of FourBytes called")
})
}
func main() {
final()
runtime.GC()
time.Sleep(time.Millisecond)
}
Попробуйте: https://go.dev/play/p/9EkDLsj-tse
Удивительно, но на экран ничего не выводится. Хотя мы всего лишь заменили структуру FourInts
на структуру FourBytes
, содержащую byte
поля.
Приведенный выше фрагмент кода не стабильный. Он может как вывести сообщение о финализаторе, так и не сделать этого.
Дело в том, что переход от четырёх целых чисел к четырём байтам уменьшает размер объекта. Это, в свою очередь, влияет на способ выделения памяти для таких объектов.
Объект FourBytes
(4 байта) считается tiny object, и Go упаковывает несколько таких объектов в один блок.
У такого объединения есть неожиданный побочный эффект: финализатор для любого объекта в группе может никогда не сработать, если хотя бы один из объектов всё ещё используется. По сути, Go считает всю группу «in use», пока существует хотя бы один её элемент.
Чтобы считаться «tiny object», объект должен соответствовать двум критериям: его размер должен быть меньше 16 КБ, и он не должен содержать указателей.
Откровенно говоря, если вы сталкиваетесь с необходимостью использования финализаторов, то, вероятно, это свидетельствует о проблемах с дизайном. Тем не менее, в их применении нет ничего плохого, если они являются правильным инструментом для решения вашей задачи.
Хороший способ избежать «магии» финализатора - предоставить явную функцию вместе с резервным планом в финализаторе:
- Создайте метод типа
Close
,Release
,Dispose
(любой другой), чтобы у пользователей был способ программно освобождать ресурсы, предоставляя им контроль, а не оставляя все на усмотрение сборщика мусора. - Затем, в качестве запасного варианта, используйте финализатор, чтобы гарантировать, что ресурсы будут освобождены, даже если что-то пойдет не так.
Когда вы создаете os.File
, Go на самом деле устанавливает финализатор для автоматического close()
дескриптора файла, когда он больше не нужен:
func newFile(fd int, name string, kind newFileKind, nonBlocking bool) *File {
f := &File{&file{
pfd: poll.FD{
Sysfd: fd,
IsStream: true,
ZeroReadIsEOF: true,
},
name: name,
stdoutOrErr: fd == 1 || fd == 2,
}}
...
runtime.SetFinalizer(f.file, (*file).close)
return f
}
Теперь, если вы явно вызовете метод Close()
, он будет выполнять те же шаги: сначала вызовет close()
, а затем финализатор.
func (file *file) close() error {
...
// no need for a finalizer anymore
runtime.SetFinalizer(file, nil)
return err
}
Обратите внимание: когда ваша программа завершается, Go не запускает цикл GC только для того, чтобы выполнить финализаторы. Поэтому, если ваша программа завершит свою работу до того, как GC снова начнёт свою работу, все ожидающие финализаторы не будут запущены.
Из этого примера становится ясно, что Go позволяет иметь только один финализатор для каждого объекта. Если вы попытаетесь добавить ещё один, он просто заменит предыдущий. Чтобы удалить финализатор, установите его значение в nil
.
После того как финализатор был запущен, объект технически не должен использоваться повторно. Однако что произойдёт, если мы всё же попытаемся это сделать?
Восстановление объекта — это процесс, при котором объект, который сборщик мусора (GC) пометил как недоступный, неожиданно получает новую ссылку. В результате он возвращается в пул доступных объектов, что препятствует его очистке. Это может произойти в языках, поддерживающих финализаторы, включая Go.
Когда GC обнаруживает недоступный объект с финализатором, он вызывает этот финализатор, но не освобождает объект сразу. Внутри финализатора у вас всё ещё есть доступ к объекту, и если финализатор создаст новую ссылку на него, возможно, назначив её глобальной переменной или другой структуре, объект снова станет доступным.
Чтобы избежать проблем, Go не выполняет окончательную очистку памяти сразу после завершения цикла GC. Процесс происходит следующим образом:
- В первом проходе GC объект помечается как достижимый, и запускается его финализатор. Независимо от того, есть ли ссылка на объект в финализаторе, он всё равно считается достижимым в течение этого цикла.
- Во втором проходе GC, если объект стал недостижимым, он окончательно удаляется из памяти.
Именно поэтому финализаторы не совсем удобны для новичков: чтобы использовать их безопасно, нужен некоторый опыт работы с Go. Без этого опыта вы можете легко нарушить работу своей программы:
type FourInt struct {
A int
B *int
C int
D int
}
func final() {
a := &FourInt{}
runtime.SetFinalizer(&a.B, func(b **int) {
fmt.Println("finalizer of FourInt.B called")
})
}
func main() {
final()
runtime.GC()
time.Sleep(time.Millisecond)
}
// fatal error: runtime.SetFinalizer: pointer not at beginning of allocated block
Я изменил структуру, сделав B
указателем на int
, и установил финализатор для a.B
вместо a
.
Это привело к фатальной ошибке, и программа перестала работать.
Из этого следует, что для работы runtime.SetFinalizer
необходимо, чтобы финализатор был связан с первым значением (или началом) блока памяти. В данном случае a.B
не является началом блока, а лишь его частью.
Это означает, что если вы добавите финализатор для FourInt.A
, а FourInt.A
окажется указателем, то всё будет работать корректно, так как FourInt.A
и FourInt
имеют одинаковый начальный адрес.
Давайте вернём тип B
обратно к int
и снова попробуем добавить финализатор. Посмотрим, что из этого получится:
type FourInt struct {
A int
B int
C int
D int
}
func final() {
a := &FourInt{}
runtime.SetFinalizer(&a.B, func(b *int) {
fmt.Println("finalizer of FourInt.B called")
})
}
func main() {
final()
runtime.GC()
time.Sleep(time.Millisecond)
}
// Output: finalizer of FourInt.B called
Теперь программа работает без сбоев, и финализатор вызывается, как и ожидалось.
Но подождите, разве я только что не упоминал, что SetFinalizer
аварийно завершает работу, если объект не находится в начале блока памяти?
Оказывается, tiny objects являются исключением из этого правила. В данном случае FourInt.B
не является указателем, и его размер в 8 байт ниже порога в 16 байт, что позволяет квалифицировать его как tiny objects.
В итоге, хотя финализаторы могут быть полезны для создания резервного слоя, их непредсказуемое и не всегда понятное поведение делает их не лучшим выбором для повседневного использования. На самом деле, уже существует и было принято решение отказаться от финализаторов в пользу нового API под названием AddCleanup.
2. Keep Alive
Следующий API, о котором стоит поговорить в пакете runtime, — runtime.KeepAlive
. Его название говорит само за себя: он сохраняет объект живым, предотвращая его сборку GC. Однако причина, по которой вам это может понадобиться, не столь очевидна.
Давайте вернемся к примеру с файлом, но на этот раз не будем использовать os.File
напрямую, а создадим его имитацию:
type File struct {
fd int
_ [2]int
}
func OpenFile() *File {
f := &File{fd: rand.Int() % 100}
runtime.SetFinalizer(f, func(b *File) {
fmt.Println("Closing file with fd", b.fd)
})
return f
}
func doingSomethingWithFile(fd int) {
runtime.GC()
fmt.Printf("Doing something with file with fd %d\n", fd)
}
func main() {
f := OpenFile()
doingSomethingWithFile(f.fd)
}
// Output:
// Closing file with fd 25
// Doing something with file with fd 25
Попробуйте: https://go.dev/play/p/rVprMIlx2qb
Для тех, кто не знаком с работой с файлами, поясню вкратце: когда ваше приложение открывает файл, ОС присваивает ему файловый дескриптор (или fd
). Этот номер можно сравнить с ручкой, которая позволяет вашему приложению выполнять различные действия с файлом — читать, записывать, закрывать его — без необходимости прямого управления самим файлом.
Теперь, возвращаясь к нашему примеру, рассмотрим, как он работает:
- При открытии файла мы настраиваем финализатор, который будет закрывать дескриптор файла, когда
f
больше не нужен. - Когда мы вызываем функцию
doingSomethingWithFile
, запускается сборщик мусора, который освобождает память, связанную с файломf
, который мы только что открыли. - Финализатор выполняет свою задачу и закрывает дескриптор, выводя на печать: «Closing file with fd 25».
- Наконец, мы все еще пытаемся работать с дескриптором, который уже закрыт. В результате мы получаем сообщение: «Doing something with file with fd 25».
По сути, финализатор сработал преждевременно, закрыв файл, связанный с fd
, в то время как функция doSomethingWithFile
всё ещё ожидала, что он будет открыт.
Почему это происходит? Мы всё ещё используем
f
вmain
, так как же его можно собрать?
Хотя очевидно, что f
используется в main
, и его не следует собирать во время выполнения doingSomethingWithFile
, компилятор воспринимает ситуацию иначе.
После того как мы передаем f.fd
в doingSomethingWithFile
, компилятор считает, что f
больше не нужен, и разрешает сборщику мусора рассматривать его как доступный для сбора. В результате срабатывает финализатор.
Вы, вероятно, уже догадались, как это исправить: нам просто нужно сохранить f
доступным до тех пор, пока doingSomethingWithFile
не завершит свою работу с ним.
func main() {
f := OpenFile()
doingSomethingWithFile(f.fd)
runtime.KeepAlive(f)
}
Попробуйте: https://go.dev/play/p/yCChTvjP3pl
В конце функции main
мы добавляем вызов runtime.KeepAlive(f)
, который гарантирует, что объект f
останется в памяти до этой строки. Это предотвращает его преждевременную финализацию.
Понять runtime.KeepAlive
несложно, она просто создает ссылку на объект, поддерживая его в активном состоянии, и обеспечивает наличие явной ссылки в коде. Эта функция поддерживается рантаймом специально для того, чтобы избежать таких оптимизаций, как инлайнинг, удаление неиспользуемого кода и т. д.
Согласно документации Go, основная задача runtime.KeepAlive
— предотвращение ситуаций, когда финализатор запускается слишком рано.
Комментарии в Telegram-группе!