Pull to refresh

Тайна финализаторов в Go

Reading time 3 min
Views 15K
Original author: Alexander Morozov

Финализаторы


Когда сборщик мусора Go готов собрать объект, оставшийся без ссылок, предварительно вызывается функция, называемая финализатором. Добавить такую функцию к своему объекту можно при помощи runtime.SetFinalizer. Посмотрим на него в работе:

package main

import (
    "fmt"
    "runtime"
    "time"
)

type Test struct {
    A int
}

func test() {
    // создаём указатель
    a := &Test{}
    // добавляем простой финализатор
    runtime.SetFinalizer(a, func(a *Test) { fmt.Println("I AM DEAD") })
}

func main() {
    test()
    // запускаем сборку мусора
    runtime.GC()
    // даём время горутине финализатора отработать
    time.Sleep(1 * time.Millisecond)
}

Очевидно, что вывод будет:
I AM DEAD

Итак, мы создали объект a, который является указателем, и поставили на него простой финализатор. Когда функция test() завершается, все ссылки на a пропадают, и сборщик мусора получает разрешение собрать его и, следовательно, вызвать финализатор в собственной горутине. Попробуйте изменить test() так, чтобы она возвращала *Test и печатала его в main() — вы обнаружите, что финализатор не вызывался. То же самое случится, если убрать поле A из типа Test — структура будет пустой, а пустые структуры не занимают памяти и не требуют очистки сборщиком мусора.

Примеры финализаторов


Исходный код стандартной библиотеки Go отлично подходит для изучения языка. Попробуем обнаружить в ней примеры финализаторов — и найдём только использование их при закрытии дескрипторов файлов, как, например, в пакете net:
runtime.SetFinalizer(fd, (*netFD).Close)

Таким образом, файловый дескриптор никогда не утечёт, даже если забыть вызвать Close у net.Conn.
Может быть, финализаторы — не такая уж классная штука, раз их почти не использовали авторы стандартной библиотеки? Посмотрим, какие с ними могут быть проблемы.

Почему финализаторов стоит избегать


Идея использовать финализаторы довольно притягательна, особенно для адептов языков без GC или в тех случаях, когда вы не ожидаете от пользователей качественного кода. В Go у нас есть и GC, и опытные разработчики, так что, по моему мнению, лучше всегда явно вызывать Close, чем использовать магию финализаторов. К примеру, вот финализатор из os, обрабатывающий дескриптор файла:
func NewFile(fd uintptr, name string) *File {
    fdi := int(fd)
    if fdi < 0 {
        return nil
    }
    f := &File{&file{fd: fdi, name: name}}
    runtime.SetFinalizer(f.file, (*file).close)
    return f
}

os.NewFile вызывается функцией os.OpenFile, которая в свою очередь вызывается из os.Open, так что этот код исполняется при каждом открытии файла. Одна из проблем финализаторов в том, что они нам неподконтрольны, но, что ещё хуже, они неожиданны. Взгляните на код:
func getFd(path string) (int, error) {
    f, err := os.Open(path)
    if err != nil {
        return -1, err
    }
    return f.Fd(), nil
}

Это обычный подход к получению дескриптора файла по заданному пути при разработке на Linux. Но этот код ненадёжен: при возврате из getFd объект f теряет последнюю ссылку, и ваш файл обречён вскоре закрыться (при следующем цикле сборки мусора). Но проблема здесь не в том, что файл закроется, а в том, что такое поведение недокументированно и совершенно неожиданно.

Вывод


Я считаю, лучше считать пользователей в меру смышлёными и способными самостоятельно подчищать объекты. По крайней мере, все методы, вызывающие SetFinalizer(даже не напрямую как в примере с os.Open), должны иметь соответствующее упоминание в документации. Я лично считаю этот метод бесполезным и может даже немного вредным.

EDIT 1: ivan4th привел пример где использование финализаторов уместно (очистка памяти в C коде): ссылка
EDIT 2: JIghtuse справедливо указал, что поведение метода Fd теперь документировано: ссылка. Что лишний раз подтверждает, что свои финализаторы тоже неплохо бы документировать.
Tags:
Hubs:
+12
Comments 12
Comments Comments 12

Articles