Является ли это допустимым шаблоном для создания событий на С#?

Обновить. Для всех, кто это читает, начиная с .NET 4, блокировка не нужна из-за изменений в синхронизации автоматически генерируемых событий, поэтому я просто использую это сейчас:

public static void Raise<T>(this EventHandler<T> handler, object sender, T e) where T : EventArgs
{
    if (handler != null)
    {
        handler(sender, e);
    }
}

И поднять его:

SomeEvent.Raise(this, new FooEventArgs());

Прочитав одну из статей Jon Skeet статей по многопоточности, я попытался инкапсулировать подход, который он защищает, чтобы поднять событие в метод расширения, подобный этому (с аналогичной общей версией):

public static void Raise(this EventHandler handler, object @lock, object sender, EventArgs e)
{
    EventHandler handlerCopy;
    lock (@lock)
    {
        handlerCopy = handler;
    }

    if (handlerCopy != null)
    {
        handlerCopy(sender, e);
    }
}

Затем это можно вызвать так:

protected virtual void OnSomeEvent(EventArgs e)
{
    this.someEvent.Raise(this.eventLock, this, e);
}

Есть ли проблемы с этим?

Кроме того, я немного запутался в необходимости блокировки в первую очередь. Как я понимаю, делегат копируется в примере в статье, чтобы избежать возможности его изменения (и обнуления) между нулевой проверкой и вызовом делегата. Тем не менее, у меня создалось впечатление, что доступ/назначение такого типа является атомарным, поэтому зачем нужна блокировка?

Обновление: Что касается комментария Марка Симпсона ниже, я собрал тест:

static class Program
{
    private static Action foo;
    private static Action bar;
    private static Action test;

    static void Main(string[] args)
    {
        foo = () => Console.WriteLine("Foo");
        bar = () => Console.WriteLine("Bar");

        test += foo;
        test += bar;

        test.Test();

        Console.ReadKey(true);
    }

    public static void Test(this Action action)
    {
        action();

        test -= foo;
        Console.WriteLine();

        action();
    }
}

Выводится:

Foo
Bar

Foo
Bar

Это показывает, что параметр делегата для метода (action) не отражает аргумент, который был передан в него (test), который, как я думаю, является ожидаемым. Мой вопрос в том, повлияет ли это на действительность блокировки в контексте моего метода расширения Raise?

Обновление: Вот код, который я сейчас использую. Это не так элегантно, как мне бы хотелось, но, похоже, это работает:

public static void Raise<T>(this object sender, ref EventHandler<T> handler, object eventLock, T e) where T : EventArgs
{
    EventHandler<T> copy;
    lock (eventLock)
    {
        copy = handler;
    }

    if (copy != null)
    {
        copy(sender, e);
    }
}

Ответ 1

Цель блокировки - поддерживать безопасность потоков, когда вы переопределяете настройки событий по умолчанию. Извините, если некоторые из них объясняют то, что вы уже смогли сделать из статьи Джона; Я просто хочу убедиться, что я полностью понимаю все.

Если вы объявляете свои события следующим образом:

public event EventHandler Click;

Затем подписки на событие автоматически синхронизируются с lock(this). Вы должны не писать специальный код блокировки для вызова обработчика событий. Вполне допустимо написать:

var clickHandler = Click;
if (clickHandler != null)
{
    clickHandler(this, e);
}

Однако, если вы решите переопределить события по умолчанию, то есть:

public event EventHandler Click
{
    add { click += value; }
    remove { click -= value; }
}

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

public event EventHandler Click
{
    add
    {
        lock (someLock)      // Normally generated as lock (this)
        {
            _click += value;
        }
    }
    remove
    {
        lock (someLock)
        {
            _click -= value;
        }
    }
}

Лично я не беспокоюсь об этом, но обоснование Джона звучит. Однако у нас есть небольшая проблема. Если вы используете приватное поле EventHandler для хранения вашего события, у вас, вероятно, есть внутренний код для класса, который делает это:

protected virtual void OnClick(EventArgs e)
{
    EventHandler handler = _click;
    if (handler != null)
    {
        handler(this, e);
    }
}

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

Если какой-то код, открытый для класса, идет:

MyControl.Click += MyClickHandler;

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

Часть присваивания переменной clickHandler = _click является атомарной, да, но во время этого назначения поле _click может находиться в переходном состоянии, которое было наполовину написано внешним классом. Когда вы синхронизируете доступ к полю, этого недостаточно для синхронизации доступа на запись, вам также необходимо синхронизировать доступ для чтения:

protected virtual void OnClick(EventArgs e)
{
    EventHandler handler;
    lock (someLock)
    {
        handler = _click;
    }
    if (handler != null)
    {
        handler(this, e);
    }
}

UPDATE

Оказывается, что некоторые из диалоговых окон вокруг комментариев были на самом деле правильными, что подтверждается обновлением OP. Это не проблема с методами расширения как таковой, это факт, что делегаты имеют семантику значения-типа и копируются при назначении. Даже если вы извлекли this из метода расширения и просто вызвали его как статический метод, вы получите то же поведение.

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

public static void RaiseEvent(ref EventHandler handler, object sync,
    object sender, EventArgs e)
{
    EventHandler handlerCopy;
    lock (sync)
    {
        handlerCopy = handler;
    }
    if (handlerCopy != null)
    {
        handlerCopy(sender, e);
    }
}

Эта версия работает, потому что мы фактически не передаем EventHandler, просто ссылку на нее (обратите внимание на ref в сигнатуре метода). К сожалению, вы не можете использовать ref с this в методе расширения, поэтому он должен оставаться простым статическим методом.

(И, как указано ранее, вы должны убедиться, что вы передаете тот же объект блокировки, что и параметр sync, который вы используете в своих открытых событиях, если вы передаете любой другой объект, тогда все обсуждение будет спорным.)

Ответ 2

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

public event Action foo = delegate { };

Ответ 3

lock (@lock)
{
    handlerCopy = handler;
}

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

Ответ 4

"Потоковые" события могут стать довольно сложными. Там есть несколько различных проблем, с которыми вы могли бы столкнуться:

NullReferenceException

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

// DO NOT USE - this can cause deadlocks
void OnEvent(EventArgs e) {
    // lock on this, since that what the compiler uses. 
    // Otherwise, use custom add/remove handlers and lock on something else.
    // You still have the possibility of deadlocks, though, because your subscriber
    // may add/remove handlers in their event handler.
    //
    // lock(this) makes sure that our check and call are atomic, and synchronized with the
    // add/remove handlers. No possibility of Event changing between our check and call.
    // 
    lock(this) { 
       if (Event != null) Event(this, e);
    }
}

скопировать обработчик (рекомендуется)

void OnEvent(EventArgs e) {
    // Copy the handler to a private local variable. This prevents our copy from
    // being changed. A subscriber may be added/removed between our copy and call, though.
    var h = Event;
    if (h != null) h(this, e);
}

или иметь нулевого делегата, который всегда подписывался.

EventHandler Event = (s, e) => { }; // This syntax may be off...no compiler handy

Обратите внимание, что опция 2 (копировать обработчик) не требует блокировки - поскольку копия является атомарной, нет никакой возможности для несогласованности.

Чтобы вернуть это к вашему методу расширения, вы делаете небольшое изменение в опции 2. Ваша копия происходит при вызове метода расширения, так что вы можете уйти просто:

void Raise(this EventHandler handler, object sender, EventArgs e) {
    if (handler != null) handler(sender, e);
}

Возможно, проблема JITter заключается в создании и удалении временной переменной. Мое ограниченное понимание заключается в том, что это действительное поведение для <.NET 2.0 или ECMA - но .NET 2.0+ укрепил гарантии, чтобы сделать его не-проблемой - YMMV на Mono.

Старые данные

Хорошо, поэтому мы решили NRE, взяв копию обработчика. Теперь у нас есть вторая проблема с устаревшими данными. Если абонент отменил подписку между нами, взяв копию и вызвав делегата, мы все равно позвоним. Возможно, это неверно. Вариант 1 (блокировка callsite) решает эту проблему, но рискует зайти в тупик. Мы застряли - у нас есть две разные проблемы, требующие двух разных решений для одного и того же кода.

Так как взаимоблокировки действительно трудно диагностировать и предотвращать, рекомендуется перейти с опцией 2. Это требует, чтобы вызываемый должен обрабатывать вызов даже после отмены подписки. Это должно быть достаточно просто, чтобы обработчик мог проверить, что он все еще хочет/может быть вызван, и выходить чисто, если нет.

Хорошо, так почему Джон Скит предлагает взять замок в OnEvent? Он предотвращает чтение кэшированного чтения из-за устаревших данных. Вызов блокировки переводится в Monitor.Enter/Exit, который генерирует барьер памяти, который предотвращает переупорядочение чтения/записи и кэшированных данных. Для наших целей они по существу делают делегат изменчивым - это означает, что он не может быть кэширован в регистре CPU и должен каждый раз считываться из основной памяти для обновленного значения. Это предотвращает отказ подписчика от подписчика, но значение события кэшируется потоком, который никогда не замечает.

Заключение

Итак, как насчет вашего кода:

void Raise(this EventHandler handler, object @lock, object sender, EventArgs e) {
     EventHandler handlerCopy;
     lock (@lock) {
        handlerCopy = handler;
     }

     if (handlerCopy != null) handlerCopy(sender, e);
}

Ну, вы делаете копию делегата (фактически дважды) и выполняете блокировку, которая генерирует барьер памяти. К сожалению, ваша блокировка выполняется при копировании вашей локальной копии - что ничего не сделает для проблемы устаревших данных, которую пытается решить Jon Skeet. Вам понадобится что-то вроде:

void Raise(this EventHandler handler, object sender, EventArgs e) {
   if (handler != null) handler(sender, e);
}

void OnEvent(EventArgs e) {
   // turns out, we don't really care what we lock on since
   // we're using it for the implicit memory barrier, 
   // not synchronization     
   EventHandler h;  
   lock(new object()) { 
      h = this.SomeEvent;
   }
   h.Raise(this, e);
}

который на самом деле не похож на гораздо меньше кода для меня.

Ответ 6

Здесь есть несколько проблем, и я буду решать их по одному.

Проблема №1: Ваш код, вам нужно заблокировать?

Прежде всего, код, который у вас есть в вашем вопросе, в этом коде нет необходимости блокировать.

Другими словами, метод Raise можно просто переписать:

public static void Raise(this EventHandler handler, object sender, EventArgs e)
{
    if (handler != null)
        handler(sender, e);
}

Причиной этого является то, что делегат является неизменяемой конструкцией, что означает, что делегат, который вы получаете в свой метод, после того, как вы в этом методе, не изменится на время жизни этого метода.

Даже если другой поток смещается вокруг события одновременно, это создаст новый делегат. Объект делегата, который у вас есть в вашем объекте, не изменится.

Итак, что проблема № 1, вам нужно заблокировать, если у вас есть код, как вы. Ответ - нет.

Проблема №3: ​​Почему результат с вашего последнего фрагмента кода не изменился?

Это относится к приведенному выше коду. Метод расширения уже получил свою копию делегата, и эта копия никогда не изменится. Единственный способ "сделать это изменение" - это не передавать метод копии, а вместо этого, как показано в других ответах здесь, передать ему псевдониму имя для поля/переменной, содержащей делегат. Только после этого вы будете наблюдать изменения.

Вы можете посмотреть на это следующим образом:

private int x;

public void Test1()
{
    x = 10;
    Test2(x);
}

public void Test2(int y)
{
    Console.WriteLine("y = " + y);
    x = 5;
    Console.WriteLine("y = " + y);
}

Ожидаете ли вы, что в этом случае y изменится до 5? Нет, вероятно, нет, и то же самое с делегатами.

Проблема № 3: Почему Джон использовал блокировку в своем коде?

Так почему же Джон использовал блокировку в своем post: "Выбор того, что нужно заблокировать" ? Ну, его код отличается от вашего в том смысле, что он не передал копию основного делегата где-нибудь.

В коде, который выглядит следующим образом:

protected virtual OnSomeEvent(EventArgs e)
{
    SomeEventHandler handler;
    lock (someEventLock)
    {
        handler = someEvent;
    }
    if (handler != null)
    {
        handler (this, e);
    }
}

существует вероятность, что если он не будет использовать блокировки и вместо этого напишет код следующим образом:

protected virtual OnSomeEvent(EventArgs e)
{
    if (handler != null)
        handler (this, e);
}

то другой поток может изменить "обработчик" между оценкой выражения, чтобы выяснить, есть ли какие-либо подписчики и до фактического вызова, другими словами:

protected virtual OnSomeEvent(EventArgs e)
{
    if (handler != null)
                         <--- a different thread can change "handler" here
        handler (this, e);
}

Если бы он прошел handler по отдельному методу, его код был бы похож на ваш и, следовательно, не требовал блокировки.

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

Одной из причин использования блокировки, даже если вы звоните, может стать введение барьера памяти, но я сомневаюсь, что это повлияет на этот код.

Итак, эта проблема №3, зачем Джону действительно нужна блокировка.

Проблема № 4: Как насчет изменения методов доступа к событиям по умолчанию?

Проблема 4, которая была затронута в других ответах, здесь вращается вокруг необходимости блокировки при переписывании объектов добавления/удаления по умолчанию для события, чтобы управлять логикой по любой причине.

В основном, вместо этого:

public event EventHandler EventName;

вы хотите записать это или некоторые его варианты:

private EventHandler eventName;
public event EventHandler EventName
{
    add { eventName += value; }
    remove { eventName -= value; }
}

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

Возможно, мы закончим сценарий выполнения, который выглядит так (помните, что "a + = b" действительно означает "a = a + b" ):

Thread 1              Thread 2
read eventName
                      read eventName
add handler1
                      add handler2
write eventName
                      write eventName  <-- oops, handler1 disappeared

Чтобы исправить это, вам понадобится блокировка.

Ответ 7

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

Мое предложение - просто попробовать/поймать его;

    if (handler != null)
    {
        try
        {
            handler(this, e);
        }
        catch(NullReferenceException)
        {
        }
    }

Я также подумал, что посмотрю, как Microsoft поднимает самое важное событие из всех; нажав кнопку. Они просто делают это, в базе Control.OnClick;

protected virtual void OnClick(EventArgs e)
{
    EventHandler handler = (EventHandler) base.Events[EventClick];
    if (handler != null)
    {
        handler(this, e);
    }
}

поэтому они копируют обработчик, но не блокируют его.