Потокобезопасные события (Events)

При написании компонентов в многопоточном мире обычно возникает вопрос: Как сделать мои события (Events) потокобезопасными?. Как правило, этот вопрос относиться к подписке (subscription) и отписке (unsubscription) в многопоточной среде, но следует также учитывать потокобезопасность при вызове события.

В этой статье вы узнаете мнение Стивена Клири о потокобезопасности событий (Events) в .NET. Как по мне, довольно интересная статья для ознакомления. Это мнение человека, который посвятил работе с многопоточностью многие годы и имеет огромный опыт в этой области.

ВАЖНО: Это перевод статьи Стивена Клири (Stephen Cleary). Я тут только переводчик. Я нашел блог этого Microsoft евангелиста достаточно давно. Я прочел большую часть статей в его блоге. Как по мне – это сокровищница полезнейшей информации для разработчиков ПО. И поэтому я решил перевести некоторые наиболее интересные статьи на русский.
Далее перевод статьи. Оригинал можно найти тут: https://blog.stephencleary.com/2009/06/threadsafe-events.html

Неправильное решение №1, из спецификации языка C#

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

public event MyEventHandler MyEvent;

Компилятор превращает его в подобие этого кода:

private MyEventHandler __MyEvent;
public event MyEventHandler MyEvent
{
    add
    {
        lock (this)
        {
            this.__MyEvent += value;
        }
    }

    remove
    {
        lock (this)
        {
            this.__MyEvent -= value;
        }
    }
}

Крис Барроуз, разработчик Microsoft в команде компилятора C #, объясняет, почему это плохо, в своем посте на блоге Field-like Events Considered Harmful. Этот пост в блоге подробно описывает почему это плохо, поэтому здесь мы не будем повторяться. Хотя для краткости – lock(this) – это очень плохо.

Давайте притворимся на минуту, что lock(this) — это нормально. В конце концов, этот код действительно сработал бы. Основная проблема с ним — это повышенная вероятность неожиданных взаимоблокировок (deadlocks). Также возможно, что будущий компилятор C# может делать блокировку на сверхсекретном приватном поле вместо этого. Тем не менее, даже если реализация в порядке, дизайн все еще имеет недостатки. Проблема становится понятной, если подумать, как вызвать событие в поточно-безопасном режиме.

Это стандартный, простой и логичный код для создания событий:

if (this.MyEvent != null)
{
    this.MyEvent(this, args);
}

Если существует несколько потоков, подписывающихся на событие и отписывающихся от него, то встроенная блокировка событий в виде полей работает только для подписки и отписки. Код, вызывающий событие, содержит проблему: если другой поток отписывается от события после оператора if, но до вызова события, то этот код может привести к исключению NullReferenceException!

Таким образом, оказывается, что потокобезопасные события не были действительно потокобезопасными. Двигаемся дальше …

Неправильное решение №2, из Framework Design Guidelines и MSDN

Одним из решений проблемы, описанной выше, является создание копии делегата события перед его проверкой в условии if. Код вызова события становится следующим:

MyEventHandler myEvent = this.MyEvent;
if (myEvent != null)
{
    myEvent(this, args);
}

Это решение, используемое в примерах MSDN и рекомендованное в полустандартных Framework Design Guidelines.

Это простое, очевидное и неправильное решение. [Между прочим, я не отказываюсь от Framework Design Guidelines. У них много хороших советов, и я не хочу критиковать книгу в целом. Они просто ошибаются в этой конкретной рекомендации.]

Программисты, не имеющие большого опыта в многопоточном программировании, могут не сразу определить, почему это решение неверно. Делегаты являются неизменяемыми ссылочными типами, поэтому копия локальной переменной является атомарной — тут нет проблем. Проблема в том, что клиент может отписаться от события после копирования но перед самим вызовом события. Это решение предотвращает выброс исключения NullReferenceException, но оно вводит другую проблему – вызов обработчика события, который был отписан от него.

Неправильное решение №3, от Джона Скита (Jon Skeet)

Но вначале позвольте мне сказать: Джон Скит — отличный программист. Я настоятельно рекомендую его книгу C # in Depth всем и каждому, кто использует C #. Я слежу за его блогом. Я очень уважаю этого человека и не могу поверить, что мое первое упоминание о нем в моем блоге находится в негативном свете … Однако он придумал неправильное решение для многопоточных событий. Однако, чтобы отдать ему должное, он закончил свою статью с рекомендацией правильного решения!

Джон Скит прекрасно раскрывает эту тему в своей статье Delegates and Events (вы можете перейти к разделу Thread-safe events). Он охватывает все, что я описал выше, но затем предлагает другое неправильное решение. Ему не нравится решение с барьером памяти (как и мне) и пытается решить его, заключив операцию копирования (делегата события) внутрь блока блокировки. Как указывает Джон, методы добавления / удаления событий могут использовать для блокировки указатель this или блокировать что-то еще (помните, что будущий компилятор C # может вместо this использовать для блокировки суперсекретное приватное поле). Таким образом, стандартные методы добавления / удаления должны быть заменены методами, которые выполняют явную блокировку, например так:

private object myEventLock = new object();
private MyEventHandler myEvent;
public MyEventHandler MyEvent
{
    add
    {
        lock (this.myEventLock)
        {
            this.myEvent += value;
        }
    }

    remove
    {
        lock (this.myEventLock)
        {
            this.myEvent -= value;
        }
    }
}

protected virtual OnMyEvent(MyEventArgs args)
{
    MyEventHandler localMyEvent;
    lock (this.myEventLock)
    {
        localMyEvent = this.myEvent;
    }

    if (localMyEvent != null)
    {
        localMyEvent(this, args);
    }
}

Это достаточное большое количество кода для одного простого события! Некоторые люди даже пишут вспомогательные объекты, чтобы уменьшить объем кода. Перед тем, как прыгнуть на эту подножку, помните, что это решение также неверно. В нем все еще есть состояние гонки.

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

Неправильное решение №4, просто если вы вдруг подумали о нем

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

Если используется это решение, то обработчик событий не может ожидать другой поток, который будет пытаться подписать или отписать обработчик на то же событие. Другими словами, этот способ легко вносит в программу проблему неожиданной взаимной блокировки (deadlock). Т.е. тело обработчика события будет выполнятся внутри блокировки и если обработчик события будет ждать другой поток, который попробует подписаться или отписаться на то же самое событие, возникнет ситуация взаимной блокировки и ваше приложение зависнет.

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

Почему все решения неправильные, от Стивена Клири

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

Это причина того, почему блокировка на открытых объектах (таких как this) считается плохой практикой. Удержание блокировки на объекте this при вызове события (как, например, решение №4) делает плохую практику еще хуже.

Если присмотреться, все решения выше терпят неудачу в одной из двух ситуаций.

Решения 1-3 терпят неудачу в одном и том же сценарии:

  • Поток А собирается запускать обработчик события.
  • Поток B подписывает обработчик на событие. Код обработчика зависит от некоторого ресурса.
  • Поток A начинает запускать обработчик события. Непосредственно перед вызовом делегата поток А прерывается потоком В.
  • Поток B больше не нуждается в уведомлении о событии, поэтому он отменяет подписку обработчика на событие и удаляет ресурс.
  • Поток A продолжает вызывать событие (которое было отписано). Код обработчика зависит от ресурса, который теперь был удален.

Решение 4 терпит неудачу немного в другом случае:

  • Поток А собирается запускать обработчик события.
  • Поток B подписывает обработчик на событие. Код обработчика связывается с потоком C (например ожидает завершения чего то в потоке С).
  • Поток A начинает запускать обработчик события. Непосредственно перед вызовом делегата поток А вытесняется потоком С.
  • Поток C подписывает обработчик на событие. Поток С вызывает код блокировки.
  • Поток A продолжает вызывать событие. Код обработчика не может связаться с потоком C, потому что он заблокирован.

Универсального решения «поточно-ориентированного события» не существует — по крайней мере, без использования примитивов синхронизации, которыми мы в настоящее время располагаем. Реализация должна иметь либо состояние гонки (race condition), либо возможность тупика (deadlock). Блокировка может предотвратить конфликт (решить условие гонки), но только если она удерживается во время вызова обработчика события (возможно, создавая ситуацию deadlock). Как альтернатива можно вызывать обработчик события без блокировки, что спасет от deadlock, но есть возможность получить исключение при вызове уже отписавшегося обработчика события.

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

Решение 3 (и Решение 2 в текущей реализации Microsoft) работает, если обработчик события может корректно работать даже после того, как он отписался от события. Написать такой обработчик события совсем нетрудно . Контексты асинхронного обратного вызова могут помочь в реализации. Недостатком является то, что каждый обработчик событий должен включать в себя код беспокоящийся о многопоточности, что усложняет эти методы.

Решение 4 также можно заставить работать, если обработчик события не блокирует поток, который подписывается или отменяет подписку на это же событие. Для простоты API-интерфейсы, которые используют этот способ, часто просто заявляют, что обработчики событий не могут вызывать блокировки потока внутри себя. Недостатком является то, что это может быть трудно гарантировать, так как многие объекты скрывают логику блокировки от вызывающего их кода.

Заключение

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

По этой причине я рекомендую тот же подход, который Джон Скит рекомендует в конце своего поста Delegates and Events: не делайте этого, то есть не используйте события в многопоточном режиме. Если событие существует в объекте, то только один поток должен иметь возможность подписаться или отказаться от подписки на это событие, и это тот же поток, который вызовет событие.

Одним приятным побочным эффектом этого подхода является то, что код становится намного проще:

public event MyEventHandler MyEvent;

protected virtual OnMyEvent(MyEventArgs args)
{
    if (this.MyEvent != null)
    {
        this.MyEvent(this, args);
    }
}

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

private MyEventHandler myEvent;
public event MyEventHandler MyEvent
{
    add
    {
        this.myEvent += value;
    }

    remove
    {
        this.myEvent -= value;
    }
}

protected virtual OnMyEvent(MyEventArgs args)
{
    if (this.myEvent != null)
    {
        this.myEvent(this, args);
    }
}

Другим побочным эффектом является то, что этот тип обработки событий подталкивает вас к асинхронному программированию на основе событий (Event-Based Asynchronous Programming, EBAP), или к чему-то очень похожему на это. EBAP — это логические правила для асинхронного проектирования объектов, обеспечивающее максимальное повторное использование. EBAP также лучше соответствует нормальным ограничениям для параллелизма объектов:

  • Открытые статические члены этого типа являются потокобезопасными.
  • Ни один из членов экземпляра класса не является потокобезопасным.

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

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

Мои собственные замечания

Статья довольно старая, но актуальна до сих пор.

Единственное, начиная с версии C# 6.0 вы можете использовать такую запись:

EventObject?.Invoke(this, args);

Эта запись проверяет EventObject на равенство null, если ссылка не null то она сохраняется во временной переменной и затем происходит вызов функции Invoke. Но это тоже не потокобезопасное решение. Просто так проще, короче и яснее что происходит. Тут все еще можно словить вызов на отписавшемся обработчике события.

В своем коде я иногда использую события, которые подписываются и отписываются в одном потоке, а вызываются в другом. Я придерживаюсь стандартного C# способа для событий в виде полей и скрытой блокировки. Но также я всегда помню, что нельзя делать блокировку на this или на объект, который содержит события. Все обработчики подписываются до того, как запускается поток, в котором они будут вызываться. Отписываются обработчики только после того, как завершиться поток, из которого они вызываются. Обработчики вызываются только таким образом:

EventObject?.Invoke(this, args);

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

Будет интересно узнать, что вы думаете по этому поводу.

Добавить комментарий

Ваш e-mail не будет опубликован.