Слабые события в C#

ОГЛАВЛЕНИЕ

В данной статье мы обсудим разные подходы к слабым событиям.

Введение

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


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

Есть много разных подходов к этой проблеме. Данная статья объясняет некоторые из них и рассматривает их плюсы и минусы. Подходы были разделены на две категории: сначала предполагается, что источник события – существующий класс с нормальным событием C#; после этого разрешается изменение источника события, чтобы позволить разные подходы.

Чем именно являются события?

Многие программисты считают события списком делегатов – это неверно. Делегаты сами могут быть групповыми (многоадресными):
EventHandler eh = Method1;
eh += Method2;
Так что же такое события? По сути, они похожи на свойства: они инкапсулируют поле делегата и ограничивают доступ к нему. Открытое поле делегата (или открытое свойство делегата) может означать, что другие объекты могут очистить список обработчиков событий, или возбуждать событие – но нужно, чтобы только создавший событие объект мог делать это.

В сущности, свойства – пара методов get (прочитать)/set (установить). События – пара методов add (добавить)/remove (удалить).
public event EventHandler MyEvent {
   add { ... }
   remove { ... }
}
Только добавление и удаление обработчиков public (открытое). Другие классы не могут запрашивать список обработчиков, не могут очистить список, или не могут вызвать событие.
К путанице иногда приводит то, что C# имеет сокращенный синтаксис:
public event EventHandler MyEvent;
Это разворачивается в:
private EventHandler _MyEvent; // подразумеваемое поле
// оно на самом деле называется не "_MyEvent", а "MyEvent",
// но вы не можете увидеть разницу между полем
// и событием.
public event EventHandler MyEvent {
  add { lock(this) { _MyEvent += value; } }
  remove { lock(this) { _MyEvent -= value; } }
}
Стандартные события C# зафиксированы на this (этот)! Это можно проверить с помощью дизассемблера - методы add и remove декорированы [MethodImpl(MethodImplOptions.Synchronized)], что эквивалентно фиксации на this.
Регистрация и снятие с регистрации событий поточно-ориентированы. Но возбуждение события с ориентацией на многопоточное исполнение оставлено на усмотрение программиста, пишущего код, который возбуждает событие, и часто делает это неверно: код возбуждения, вероятно, используемый большинством, не ориентирован на многопоточное исполнение:
if (MyEvent != null)
   MyEvent(this, EventArgs.Empty);
   // может прерваться выполнение с исключением NullReferenceException
   // когда одновременно удаляется обработчик последнего события.
Вторая наиболее часто встречающаяся стратегия – первое считывание делегата события в локальную переменную.
EventHandler eh = MyEvent;
if (eh != null) eh(this, EventArgs.Empty);
Является ли это поточно-ориентированным? Ответ: когда как. Согласно модели памяти в спецификации C#, это не поточно-ориентировано. Компилятору JIT разрешается убирать локальную переменную, смотрите Понимание воздействия методов низкой блокировки в многопоточных приложениях. Однако среда выполнения Microsoft .NET имеет более устойчивую модель памяти (начиная с версии 2.0), и в ней этот код поточно-ориентированный. Он также является поточно-ориентированным в Microsoft .NET 1.0 и 1.1, но это не описанная в документации деталь реализации.

Корректное решение, согласно спецификации ECMA, должно бы перемещать присваивание локальной переменной в блок lock(this) или использовать поле volatile (временный) для хранения делегата.
EventHandler eh;
lock (this) { eh = MyEvent; }
if (eh != null) eh(this, EventArgs.Empty);
Это означает, что нужно различать события, являющиеся поточно-ориентированными, и события, не являющиеся таковыми.