Утечки управляемой памяти при реализации событий
В посте показано, каким образом события могут вызывать утечки управляемой памяти. Предлагаются два решения, чтобы избежать подобных утечек. Первое решение связано с реализацией метода Dispose для отмены подписки, во втором решении демонстрируется применение слабых ссылок.
Проблематика 1
В неуправляемых языках вроде C++ вы должны не забывать об освобождении памяти вручную, когда объект больше не требуется; в противном случае возникнет утечка памяти. В мире управляемых языков такая ошибка невозможна, поскольку в среде CLR существует система автоматической сборки мусора.
Несмотря на это, крупные и сложные приложения .NET могут демонстрировать аналогичный синдром в легкой форме с тем же самым конечным результатом: с течением времени жизни приложение потребляет все больше и больше памяти до тех пор, пока его не придется перезапустить. Хорошая новость в том, что утечки управляемой памяти обычно легче диагностировать и предотвращать.
Утечки управляемой памяти вызваны неиспользуемыми объектами, которые остаются активными по причине существования неиспользуемых или забытых ссылок на них. Распространенным кандидатом являются обработчики событий – они удерживают ссылку на целевой объект (если только он не является статическим методом).
Например, взгляните на следующие классы:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
:
1
2
3
4
5
6
7
8
9
10
11
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
отсоединиться от обработчика событий:
1
public void Dispose() { _host.Click -= HostClicked; }
Тогда потребители класса Client
освободят его экземпляры после завершения работы с ними:
1
Array.ForEach (clients, с => с.Dispose ());
Второе решение 2
Слабые ссылки предлагают еще одно решение, но, прежде чем перейти к его рассмотрению, давайте разберемся, что же представляют собой слабые ссылки.
Иногда удобно удерживать ссылку на объект, который является “невидимым” сборщику мусора, в том смысле, что сборщик мусора не учитывает связь ссылки и объекта в куче при выявлении объектов, подлежащих удалению. Это называется слабой ссылкой и реализовано классом System.WeakReference
.
Для использования класса WeakReference
необходимо сконструировать его экземпляр с целевым объектом, как показано ниже:
1
2
3
var sb = new StringBuilder("this is a test");
var weak = new WeakReference(sb);
Console.WriteLine(weak.Target); // Выводит this is a test
Если на целевой объект имеется только одна или более слабых ссылок, то сборщик мусора считает его пригодным для сборки. После того, как целевой объект обработан сборщиком мусора, свойство Target
экземпляра WeakReference
получает значение null
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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");
}
Во избежание обработки сборщиком мусора целевого объекта его следует присвоить локальной переменной. Так он получит надежный корневой объект и потому не сможет быть обработан сборщиком мусора, пока эта переменная используется. Следует отметить, если переделать вышеуказанный код следующим образом:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
). Это делается для того, чтобы была возможность проверять переменные после того, как они выйдут за пределы области действия, но до завершения метода.
Разобравшись со слабыми ссылками, перейдем к описанию второго решения для предотвращения утечек управляемой памяти при реализации событий.
Предположим, что есть делегат, который удерживает только слабые ссылки на свои целевые объекты. Такой делегат не будет сохранять свои целевые объекты в активном состоянии – если только не существуют независимые ссылки на них. Конечно, при этом нельзя предотвратить ситуацию, когда запущенный делегат сталкивается с висячей ссылкой на целевой объект – в период времени между моментом, когда целевой объект пригоден для сборки мусора, и моментом, когда сборщик мусора подхватит его. Чтобы такое решение было эффективным, код должен быть надежным в указанном сценарии. С учетом этого случая класс слабого делегата может быть реализован так, как показано ниже:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
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
специальным типом, для которого ограничения не поддерживаются:
1
2
3
... where TDelegate : Delegate
// Компилятор не разрешает поступать
// подобным образом
Взамен мы должны выбрать ограничение класса и предусмотреть в конструкторе проверку во время выполнения.
В методах Combine
и Remove
мы осуществляем ссылочное преобразование target
в Delegate
с помощью операции as
, а не более привычной операции приведения. Причина в том, что C# запрещает использовать операцию приведения с таким параметром типа, поскольку существует потенциальная неоднозначность между специальным преобразованием и ссылочным преобразованием.
Затем мы вызываем метод GetInvocationList
, т.к. эти методы могут быть вызваны групповыми делегатами, т.е. делегатами с более чем одним методом для вызова.
В свойстве Target
мы строим групповой делегат, комбинирующий все делегаты, на которые имеются слабые ссылки с активными целевыми объектами, удаляя оставшиеся (висячие) ссылки из списка _targets
во избежание его разрастания до бесконечности. (Мы могли бы усовершенствовать наш класс, делая то же самое в методе Combine
; еще одним улучшением было бы добавление блокировок для обеспечения безопасности в отношении потоков.) Мы также разрешаем иметь делегаты вообще без слабой ссылки; они представляют делегаты, целевой метод которых является статическим.
В следующем коде показано, как использовать готовый делегат при реализации события.
1
2
3
4
5
6
7
8
9
10
11
12
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
представляет метод, обрабатывающий событие, не имеющее данных.
Использованные источники
Албахари Д., Албахари Б. C# 7.0. Справочник. Полное описание языка.: Пер. с англ. – СпБ.: ООО «Альфа-книга», 2018. – С. 530-531. (См. главу 12. Освобождение и сборка мусора, п. Утечки управляемой памяти.) ↩︎
Албахари Д., Албахари Б. C# 7.0. Справочник. Полное описание языка.: Пер. с англ. – СпБ.: ООО «Альфа-книга», 2018. – С. 533-536. (См. главу 12. Освобождение и сборка мусора, п. Слабые ссылки.) ↩︎