Pull to refresh

Освобождение ресурсов в GO

Reading time5 min
Views6.6K

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

Источник изображения: http://kenimation.net/doctor-who%E2%80%AC-dalek/
Источник изображения: http://kenimation.net/doctor-who%E2%80%AC-dalek/

Единственное, что может предложить сборщик мусора для автоматизации этого процесса - это при сборке объекта вызвать его финализатор, установленный с помощью функции runtime.SetFinalizer. Но этот механизм не гарантирует ни порядка вызова финализаторов, ни даже того, что финализатор вообще будет вызван. Это поведение не специфично для Go - подобным образом ведут себя все языки со сборкой мусора, поддерживающие финализацию, и, как следствие, полагаться на неё обычно не рекомендуется.

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

res1, err := NewResource1()
if err != nil {
    return nil, err
}

res2, err := NewResource2(res1)
if err != nil {
  res1.Close()
  return nil, err
}

res3, err := NewResource3(res2)
if err != nil {
  res2.Close()
  res1.Close()
  return nil, err
}

v, err := res3.DoSomething()
if err != nil {
  res3.Close()
  res2.Close()
  res1.Close()
  return nil, err
}

res3.Close()
res2.Close()
res1.Close()
return v, nil

очевидно слишком много мест, в которых можно допустить ошибку просто по невнимательности. И даже если ошибок допущено не будет, паника в любой из вызываемых функций сведёт все усилия на нет (в других языках аналогичную проблему создают исключения). А если функции Close ещё и возвращают ошибки, то дело становится совсем плохо - зачастую эти ошибки вообще игнорируются.

Разные языки предлагают разные инструменты для упрощения освобождения ресурсов. Например, C# и Java предлагают using statement и try-with-resources statement соответственно. В Go же есть, как мне кажется, намного более гибкий defer statement. Код выше с его использованием становится на порядок удобнее и писать, и сопровождать:

res1, err := NewResource1()
if err != nil {
  return nil, err
}
defer res1.Close()

res2, err := NewResource2(res1)
if err != nil {
  return nil, err
}
defer res2.Close()

res3, err := NewResource3(res2)
if err != nil {
  return nil, err
}
defer res3.Close()

return res3.DoSomething()

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

Казалось бы, этого достаточно практически в любой ситуации. Но есть одно но - defer работает в пределах одной функции. Вы не сможете использовать код выше, например, в конструкторе - при выходе из него все созданные ресурсы будут освобождены, хотя они всё ещё требуются для работы программы. Конечно, можно возразить, что лучше бы в такой ситуации использовать внедрение зависимости. Однако, даже не считая того, что какие-то ресурсы всё ещё могут быть инкапсулированы, зависимости всё равно нужно где-то создать - а значит, мы должны либо объединить инициализацию с основной логикой в одной функции (увы, но нередко таковой является main), либо использовать инверсию управления, например, в форме колбэка, что в любом случае связывает инициализацию и основную логику сильнее, чем того хотелось бы.

Наглядной демонстрацией описываемой проблемы является код, генерируемый Wire. Конструктор (провайдер в терминологии Wire) может вернуть т. н. cleanup function, которая будет использована для освобождения созданного им ресурса. Но код сгенерированного конструктора будет похож на первый сниппет в этой статье и в случае паники процесс освобождения ресурсов сломается.

Dedicated finalization

Тем не менее, cама по себе идея возврата cleanup function, предложенная Wire, на мой взгляд выглядит перспективной. Дело в том, что у метода Close (или аналогичного финализатора) есть сразу два недостатка:

  • метод можно просто забыть вызвать;

  • метод может быть по ошибке вызван зависимой от ресурса функцией.

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

res, cleanup, err := NewResource()
if err != nil {
  return err
}
// Если не вызвать cleanup, то программа просто не скомпилируется.

if err := res.DoSomething(); err != nil {
  return err
}

Итак, использование трёх выходных параметров (ресурс, финализатор и ошибка) вместо обычных двух (ресурс и ошибка) в типовой сигнатуре конструктора выглядит хорошим решением. В качестве бонуса, оно позволяет ввести концепцию "владения ресурсом", при которой ресурс принадлежит той сущности, которой известен его финализатор, а все остальные "заимствуют" или "арендуют" этот ресурс, не взаимодействуя с интерфейсом управления его жизненным циклом.

Composite finalization

Чтобы получить возможность использовать в конструкторе defer (вернее, максимально близкий к нему механизм), можно сделать следующее:

func Finalize(finalizers ...func()) {
  // Вызываем финализаторы в обратном порядке.
  for i := len(finalizers) - 1; i >= 0; i-- {
    func() {
      defer func() {
        // Для примера проигнорируем панику так же, как любые ошибки финализации.
        // В реальном коде нужно использовать любую реализацию multierror и:
        // 1) возвращать ошибку из финализатора;
        // 2) превращать панику в ошибки.
        recover()
      }()
      finalizers[i]()
    }()
  }
}

func NewResource3() (*Resource3, func(), error) {
  var (
    finalizers []func() // Массив финализаторов внутренних ресурсов
    successful bool     // Флаг успешного создания ресурса
  )
  defer func() {
    // Если флаг успешного создания ресурса не поднят,
    // освобождаем все созданные в процессе внутренние ресурсы -
    // работа конструктора завершилась ошибкой и больше некому.
    if !successfull {
      Finalize(finalizers...)
    }
  }()
  
  res1, fin1, err := NewResource1()
  if err != nil {
    return nil, nil, err
  }
  finalizers = append(finalizers, fin1)
  
  res2, fin2, err := NewResource2(res1)
  if err != nil {
    return nil, nil, err
  } 
  finalizers = append(finalizers, fin2)
  
  res3 := &Resource3{
    resource2: res2,
  }
  fin3 := func() {
    Finalize(finalizers...)
  }
  
  // Поднимаем флаг успешного создания ресурса и возвращаем результат.
  // С этого момента внутренние ресурсы будут освобождены только
  // при вызове финализатора внешним кодом.
  successful = true
  return res3, fin3, nil
}

Функция Finalize и типовая часть конструктора - отличные кандидаты для выноса в библиотеку и последующего переиспользования.

Помимо возможности использовать привычный шаблон new - error - defer предложенный подход, при некоторой доработке, также предоставляет механизм для удобной обработки ошибок финализации, которые нередко игнорируются даже в примерах из официальной документации, поскольку их обработка оказывается весьма запутанной.

KDone в качестве заключения

Я опубликовал библиотеку KDone, предоставляющую описанный выше набор инструментов. Она является частью проекта Kata, о котором в следующий раз. Можно считать, что в данный момент её API стабилен и если и будет изменяться, то незначительно - тем не менее, библиотека пока свежая и я всё ещё использую нулевую мажорную версию на случай непредвиденных изменений.

Типичный конструктор с использованием этой библиотеки выглядит так:

func NewResource(...) (res Resource, dtor kdone.Destructor, err error) {
  
  defer kerror.Catch(&err)               // Превращаем панику в ошибку
                                         // с помощью KError.
                                         // Можно опустить этот обработчик.
  
  reaper := kdone.NewReaper()            // Создаём reaper.
  defer reaper.MustFinalize()            // Финализируем внутренние ресурсы
                                         // в случае ошибки.
  
  // ... reaper.MustAssume(dtor) ...     // Возлагаем на reaper ответственность
                                         // за вызов деструктора внутреннего
                                         // ресурса.
  
  return res, reaper.MustRelease(), nil  // Освобождаем reaper от ответственности
                                         // за вызов деструкторов и передаём её
                                         // вышестоящему коду.
}

Что думаете? Концепция достаточно простая, но возможно я всё же что-то упустил в своих рассуждениях? Или у вас есть предложения улучшений? Буду рад дискуссиям в комментариях.

Tags:
Hubs:
Total votes 7: ↑4 and ↓3+1
Comments9

Articles