Утечки управляемой памяти при реализации событий

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

Проблематика 1

В неуправляемых языках вроде C++ вы должны не забывать об освобождении памяти вручную, когда объект больше не требуется; в противном случае возникнет утечка памяти. В мире управляемых языков такая ошибка невозможна, поскольку в среде CLR существует система автоматической сборки мусора.

Несмотря на это, крупные и сложные приложения .NET могут демонстрировать аналогичный синдром в легкой форме с тем же самым конечным результатом: с течением времени жизни приложение потребляет все больше и больше памяти до тех пор, пока его не придется перезапустить. Хорошая новость в том, что утечки управляемой памяти обычно легче диагностировать и предотвращать.

Утечки управляемой памяти вызваны неиспользуемыми объектами, которые остаются активными по причине существования неиспользуемых или забытых ссылок на них. Распространенным кандидатом являются обработчики событий – они удерживают ссылку на целевой объект (если только он не является статическим методом).

Например, взгляните на следующие классы:

class Host
{
  public event EventHandler Click;
}
class Client
{
  Host _host;
  public Client(Host host)
  {
    _host = host;
    _host.Click += HostClicked;
  }
  void HostClicked(object sender, EventArgs e) { ... }
}

Приведенный ниже тестовый класс содержит метод, который создает 1000 экземпляров класса Client:

class Test
{
	static Host _host = new Host();
	public static void CreateClients()
	{
		Client[] clients = Enumerable.Range(0, 1000)
		.Select(i => new Client(_host))
		.ToArray();
		// Делать что-нибудь с экземплярами класса Client ...
	}
}

Метод Enumerable.Range(int start, int count) создает последовательность целых чисел в указанном диапазоне, где start – значение первого целого числа для последовательности, а count – количество генерируемых последовательных целых чисел.

Может показаться, что после того, как метод CreateClients завершит выполнение, тысяча объектов Client станут пригодными для сборки мусора. К сожалению, на каждый объект Client имеется еще одна ссылка: объект _host, событие Click которого теперь ссылается на каждый экземпляр Client.

Первое решение

Один из способов решения проблемы – обеспечить, чтобы класс Client реализовывал интерфейс IDisposable, и в методе Dispose отсоединиться от обработчика событий:

public void Dispose() { _host.Click -= HostClicked; }

Тогда потребители класса Client освободят его экземпляры после завершения работы с ними:

Array.ForEach (clients, с => с.Dispose ());

Второе решение 2

Слабые ссылки предлагают еще одно решение, но, прежде чем перейти к его рассмотрению, давайте разберемся, что же представляют собой слабые ссылки.

Иногда удобно удерживать ссылку на объект, который является “невидимым” сборщику мусора, в том смысле, что сборщик мусора не учитывает связь ссылки и объекта в куче при выявлении объектов, подлежащих удалению. Это называется слабой ссылкой и реализовано классом System.WeakReference.

Для использования класса WeakReference необходимо сконструировать его экземпляр с целевым объектом, как показано ниже:

var sb = new StringBuilder("this is a test");
var weak = new WeakReference(sb);
Console.WriteLine(weak.Target); // Выводит this is a test

Если на целевой объект имеется только одна или более слабых ссылок, то сборщик мусора считает его пригодным для сборки. После того, как целевой объект обработан сборщиком мусора, свойство Target экземпляра WeakReference получает значение null:

static WeakReference weak;
static void Main()
{
  InitWR();
  ShowTarget(); // Выводит weak
  GC.Collect();
  ShowTarget(); // Выводит null
}
static void InitWR()
{
  weak = new WeakReference(new StringBuilder("week"));
}
static void ShowTarget()
{
  if (weak.Target != null) Console.WriteLine(weak.Target.ToString());
  else Console.WriteLine("null");
}

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

static WeakReference weak;
static void Main()
{
  var sb = new StringBuilder("week");
  weak = new WeakReference(sb);
  ShowTarget();
  GC.Collect();
  ShowTarget();
}
static void ShowTarget()
{
  if (weak.Target != null) Console.WriteLine(weak.Target.ToString());
  else Console.WriteLine("null");
}

и скомпилировать его в режиме отладки с отключенной оптимизацией, то время жизни объекта sb расширится до завершения метода Main (то есть в данном случае объект sb не будет обработан сборщиком мусора после вызова GC.Collect). Это делается для того, чтобы была возможность проверять переменные после того, как они выйдут за пределы области действия, но до завершения метода.

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

Предположим, что есть делегат, который удерживает только слабые ссылки на свои целевые объекты. Такой делегат не будет сохранять свои целевые объекты в активном состоянии – если только не существуют независимые ссылки на них. Конечно, при этом нельзя предотвратить ситуацию, когда запущенный делегат сталкивается с висячей ссылкой на целевой объект – в период времени между моментом, когда целевой объект пригоден для сборки мусора, и моментом, когда сборщик мусора подхватит его. Чтобы такое решение было эффективным, код должен быть надежным в указанном сценарии. С учетом этого случая класс слабого делегата может быть реализован так, как показано ниже:

public class WeakDelegate<TDelegate> where TDelegate : class
{
  class MethodTarget
  {
    public readonly WeakReference Reference;
    public readonly MethodInfo Method;
    public MethodTarget(Delegate d)
    {
      // d.Target будет null для целей в виде статических методов:
      if (d.Target != null) Reference = new WeakReference(d.Target);
      Method = d.Method;
    }
  }
  List<MethodTarget> _targets = new List<MethodTarget>();
  public WeakDelegate()
  {
    if (!typeof(TDelegate).IsSubclassOf(typeof(Delegate)))
      throw new InvalidOperationException
      ("TDelegate must be a delegate type");
      // TDelegate должен быть типом делегата
  }
  public void Combine(TDelegate target)
  {
    if (target == null) return;
    foreach (Delegate d in (target as Delegate).GetInvocationList())
      _targets.Add(new MethodTarget(d));
  }
  public void Remove(TDelegate target)
  {
    if (target == null) return;
    foreach (Delegate d in (target as Delegate).GetInvocationList())
    {
      MethodTarget mt = _targets.Find(w =>
       Equals(d.Target, w.Reference?.Target) &&
       Equals(d.Method.MethodHandle, w.Method.MethodHandle));
      if (mt != null) _targets.Remove(mt);
    }
  }
  public TDelegate Target
  {
    get
    {
      Delegate combinedTarget = null;
      foreach (MethodTarget mt in _targets.ToArray())
      {
        WeakReference wr = mt.Reference;
        // Статический целевой объект или активный целевой объект экземпляра
        if (wr == null || wr.Target != null)
        {
          var newDelegate = Delegate.CreateDelegate(
          typeof(TDelegate), wr?.Target, mt.Method);
          combinedTarget = Delegate.Combine(combinedTarget, newDelegate);
        }
        else
          _targets.Remove(mt);
      }
      return combinedTarget as TDelegate;
    }
    set
    {
      _targets.Clear();
      Combine(value);
    }
  }
}

Справочные сведения по коду:

  • Класс MethodInfo выявляет атрибуты метода и обеспечивает доступ к его метаданным.
  • Метод GetInvocationList возвращает массив делегатов, представляющих список вызовов текущего делегата.
  • Свойство MethodHandle возвращает дескриптор представления внутренних метаданных метода.
  • Метод Delegate.Combine возвращает новый делегат со списком вызовов, представляющим собой сцепление списков вызовов делегатов, заданных в параметрах.

В приведенном коде демонстрируется несколько интересных моментов, связанных с C# и CLR. Для начала обратите внимание на проверку TDelegate на принадлежность к типу делегата в конструкторе. Это объясняется особенностью C# – следующее ограничение типа является недопустимым, т.к. C# считает System.Delegate специальным типом, для которого ограничения не поддерживаются:

... where TDelegate : Delegate 
// Компилятор не разрешает поступать
// подобным образом

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

В методах Combine и Remove мы осуществляем ссылочное преобразование target в Delegate с помощью операции as, а не более привычной операции приведения. Причина в том, что C# запрещает использовать операцию приведения с таким параметром типа, поскольку существует потенциальная неоднозначность между специальным преобразованием и ссылочным преобразованием.

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

В свойстве Target мы строим групповой делегат, комбинирующий все делегаты, на которые имеются слабые ссылки с активными целевыми объектами, удаляя оставшиеся (висячие) ссылки из списка _targets во избежание его разрастания до бесконечности. (Мы могли бы усовершенствовать наш класс, делая то же самое в методе Combine; еще одним улучшением было бы добавление блокировок для обеспечения безопасности в отношении потоков.) Мы также разрешаем иметь делегаты вообще без слабой ссылки; они представляют делегаты, целевой метод которых является статическим.

В следующем коде показано, как использовать готовый делегат при реализации события.

public class Foo
{
  WeakDelegate<EventHandler> _click = 
    new WeakDelegate<EventHandler>();
  public event EventHandler Click
  {
    add { _click.Combine(value); }
    remove { _click.Remove(value); }
  }
  protected virtual void OnClick(EventArgs e)
  => _click.Target?.Invoke(this, e);
}

Делегат EventHandler представляет метод, обрабатывающий событие, не имеющее данных.

Использованные источники

  1. Албахари Д., Албахари Б. C# 7.0. Справочник. Полное описание языка.: Пер. с англ. – СпБ.: ООО «Альфа-книга», 2018. – С. 530-531. (См. главу 12. Освобождение и сборка мусора, п. Утечки управляемой памяти.) 

  2. Албахари Д., Албахари Б. C# 7.0. Справочник. Полное описание языка.: Пер. с англ. – СпБ.: ООО «Альфа-книга», 2018. – С. 533-536. (См. главу 12. Освобождение и сборка мусора, п. Слабые ссылки.)