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


Когда сборщик мусора 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 теперь документировано: ссылка. Что лишний раз подтверждает, что свои финализаторы тоже неплохо бы документировать.

Комментарии (12)


  1. ivan4th
    15.10.2015 10:07
    +7

    Если варить кашу из топора, она будет, скорее всего, вовсе не вкуснее каши без топора, и, быть может, будет иметь какой-то привкус — особенно в случае, если топор был грязный. В этой связи считаю топор инструментом бесполезным и даже немного вредным.

    Искренне прошу прощения, не удержался. По делу: представьте себе, что вы делаете биндинги для сторонней библиотеки, написанной, например, на C. И в этой библиотеке какие-то объекты должны создаваться функциями create_XXX() (внутри вызывают malloc()) и освобождаться функциями free_XXX(). И вот, честно избегая SetFinalizer, мы пишем в документации, что, хотя Go — язык с GC, конкретно вот эти вот объекты, вследствие особенностей их внутренней реализации, надо обязательно освобождать через какой-нибудь Free(), при том, что эта функция/метод по своей сути не будет делать ничего, кроме освобождения памяти. Звучит логично? По-моему, нет. Как раз для оборачивания подобной логики управления памятью SetFinalizer подходит идеально.


    1. LK4D4
      15.10.2015 16:23

      Спасибо, я думаю вы правы. Хотя я наверное все-равно вызывал бы явно.
      Можете дать пример на гитхабе, я добавлю в статью?


      1. ivan4th
        15.10.2015 17:09

        Вот на вскидку из чьих-то биндингов для библиотеки exiv2: github.com/abustany/goexiv/blob/44f56aad5477a9e8dabe44270c99aeabffec7361/exiv.go#L53 «Дистиллированный» пример прямо сейчас нет времени писать, сорри, от компа уже убегаю.


    1. 0xd34df00d
      15.10.2015 16:27

      По делу: представьте себе, что вы делаете биндинги для сторонней библиотеки, написанной, например, на C. И в этой библиотеке какие-то объекты должны создаваться функциями create_XXX() (внутри вызывают malloc()) и освобождаться функциями free_XXX().

      Ещё круче, если это какое-нибудь общесистемное соединение с устройством по какому-нибудь протоколу типа MTP. Если я правильно понимаю, если создать объект прямо перед выходом программы, то без цирка с конями
          runtime.GC()
          time.Sleep(1 * time.Millisecond)
      

      оно всё утечёт и не закроется.


      1. LK4D4
        15.10.2015 16:37

        Все верно — утечёт. И даже цирк может не помочь, потому что некоторые объекты еще будут иметь ссылки. Поэтому надо логику очистки писать руками в таком случае.


  1. tirinox
    15.10.2015 10:19
    +4

    Мне кажется, ваш пример некорректен. Если уж возвращать, то объект файла, а не дескриптор, тогда он и не помрет раньше времени.


    1. LK4D4
      15.10.2015 16:25

      Вы правы, так я теперь и делаю.


      1. LK4D4
        15.10.2015 16:31

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


  1. AMDmi3
    15.10.2015 12:31
    +1

    В языке с GC ручное управление ресурсами это, честно говоря, дикость.


  1. JIghtuse
    15.10.2015 14:08
    +2

    Но проблема здесь не в том, что файл закроется, а в том, что такое поведение недокументированно и совершенно неожиданно.
    func (f *File) Fd() uintptr

    Fd returns the integer Unix file descriptor referencing the open file. The file descriptor is valid only until f.Close is called or f is garbage collected.

    Во многих языках возвращать нечто из кишков другого нечто — не самая хорошая идея. Тем более в случае, когда первое может пережить второе. Не понимаю, что здесь неожиданного.


    1. LK4D4
      15.10.2015 16:33

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


  1. 0xd34df00d
    15.10.2015 16:25
    -1

    То же самое случится, если убрать поле A из типа Test — структура будет пустой, а пустые структуры не занимают памяти и не требуют очистки сборщиком мусора.

    Стройный, логичный язык. Принцип наименьшего удивления в работе, ага.