Готовился я как-то к собеседованию по C# и среди прочего нашел вопрос примерно следующего содержания:
«Как организовать потокобезопасный вызов события в C# с учетом того, что большое количество потоков постоянно подписываются на событие и отписываются от него?»
Вопрос вполне конкретно и чётко поставлен, поэтому я даже не сомневался в том, что ответ на него можно дать так же чётко и однозначно. Но я очень сильно ошибался. Оказалось, что это крайне популярная, избитая, но до сих пор открытая тема. А еще заметил не очень приятную особенность — в русскоязычных ресурсах этому вопросу уделяется очень мало внимания (и хабр не исключение), поэтому я решил собрать всю найденную и переваренную мной информацию по данному вопросу.
До Джона Скита и Джеффри Рихтера тоже дойдем, они то, собственно, и сыграли ключевую роль в моем общем понимании проблемы работы событий в многопоточной среде.
Особо внимательный читатель сможет найти в статье два комикса в стиле xkcd.
(Осторожно, внутри две картинки примерно по 300-400 кб)
Продублирую вопрос, на который надо ответить:
«Как организовать потокобезопасный вызов события в C# с учетом того, что большое количество потоков постоянно подписываются на событие и отписываются от него?»
У меня было предположение, что часть вопросов опирается на книгу CLR via C#, тем более что в моей любимой C# 5.0 in a Nutshell подобный вопрос вообще не рассматривался, поэтому начнем с Джеффри Рихтера (CLR via C#).
Путь Джеффри Рихтера
Небольшая выдержка из написанного:
Долгое время рекомендованным способом вызова событий была примерно следующая конструкция:
Вариант 1:
public event Action MyLittleEvent;
...
protected virtual void OnMyLittleEvent()
{
if (MyLittleEvent != null) MyLittleEvent();
}
Проблема подобного подхода в том, что в методе OnMyLittleEvent
один поток может увидеть, что наше событие MyLittleEvent
не равно null
, а другой поток, сразу после этой проверки, но перед вызовом события, может убрать свой делегат из списка подписчиков, и таким образом сделать из нашего события MyLittleEvent null
, что приведет к выбросу NullReferenceException
в месте вызова события.
Вот небольшой комикс в стиле xkcd, который наглядно иллюстрирует эту ситуацию (два потока работают параллельно, время идет сверху вниз):
В целом всё логично, у нас обычное состояние гонки (здесь и далее — race condition). И вот как эту проблему решает Рихтер (и этот вариант встречается чаще всего):
Добавим в наш метод вызова события локальную переменную, в которую будем копировать наше событие на момент «захода» в метод. Поскольку делегаты — это неизменяемые объекты (здесь и далее — immutable), у нас получится «замороженная» копия события, от которой никто уже не сможет отписаться. При отписке от события создается новый объект делегата, который заменяет объект в поле MyLittleEvent
, в то время как у нас остается локальная ссылка на старый объект делегата.
Вариант 2:
protected virtual void OnMyLittleEvent()
{
Action tempAction = MyLittleEvent; // "Заморозили" наше событие для текущего метода
// На наш tempAction теперь никто не может повлиять, он никогда не станет равен null ни при каких условиях
if (tempAction != null) tempAction ();
}
Далее у Рихтера описывается, что JIT компилятор вполне может просто опустить создание локальной переменной ради оптимизации, и сделать из второго варианта первый, то есть пропустить «заморозку» события. В итоге рекомендуется делать копирование через Volatile.Read(ref MyLittleEvent)
, то есть:
Вариант 3:
protected virtual void OnMyLittleEvent()
{
// Ну теперь уж точно заморозили
Action tempAction = Volatile.Read(ref MyLittleEvent);
if (tempAction != null) tempAction ();
}
Про Volatile
можно долго говорить отдельно, но в общем случае это «просто позволяет избавиться от нежелательной оптимизации JIT компилятора». По этому поводу еще будут уточнения и подробности, но мы пока остановимся на общей идее текущего решения Джеффри Рихтера:
Для обеспечения потокобезопасного вызова события нужно «заморозить» текущий список подписчиков, скопировав событие в локальную переменную, после чего, в случае если полученный список не пустой, вызвать все обработчики из «замороженного» списка. Таким образом мы избавляемся от возможного
NullReferenceException.
Меня сразу смутило то, что мы вызываем события у уже отписавшихся объектов/потоков. Вряд ли кто-то отписался просто так — вполне вероятно, что кто-то сделал это во время общей «чистки» следов — вместе с закрытием потоков записи / чтения (например логгер, который по событию должен был писать данные в файл), закрытием соединений и т.п., то есть внутреннее состояние объекта-подписчика на момент вызова его обработчика может быть непригодно для дальнейшей работы.
Для примера представим, что наш подписчик реализует метод IDisposable
, и следует конвенции, определяющей что при попытке вызвать любой метод у освобожденного (здесь и далее — disposed) объекта, он должен выбросить ObjectDisposedException
. Так же условимся, что мы отписываемся от всех событий в методе Dispose
.
А теперь представте такой сценарий — мы вызываем у этого объекта метод Dispose
ровно после того момента, когда другой поток «заморозил» список своих подписчиков. Поток успешно вызывает обработчик у отписавшегося объекта, а тот во время попытки обработки события объект рано или поздно понимает, что он уже был освобожден, и выбрасывает ObjectDisposedException
. Скорее всего это исключение в самом обработчике никак не ловится, потому что вполне логично предположить: «Если наш подписчик отписался и был освобожден, то его обработчик никогда не будет вызван». Тут либо будет краш приложения, либо утечка неуправляемых ресусов, либо вызов события прервется при первом появлении ObjectDisposedException
(если мы ловим исключение при вызове), но до нормальных «живых» обработчиков событие так и не доберется.
Вернемся к комиксу. История та же — два потока, время идет сверху вниз. Вот что происходит на самом деле:
Эта ситуация, по-моему, намного серьезней, чем возможное NullReferenceException
при вызове события.
Что интересно, советы по реализации потокобезопасного вызова событий на стороны Наблюдаемого объекта есть, а советов по реализации потокобезопасных Обработчиков — нет.
О чем говорит StackOverflow
На SO можно найти подробную «статью» (да, вопрос этот тянет на целую небольшую статью), посвященную данному вопросу.
В целом там разделяется моя точка зрения, но вот что добавляет этот товарищ:
Мне кажется, что вся эта шумиха с локальными переменными — ничто иное, как Карго-культ программирование (Cargo Cult Programming). Большое количество людей решает проблему потокобезопасных событий именно таким способом, в то время как для полноценной потокобезопасности нужно сделать намного больше. Я могу с уверенностью сказать, что те люди, которые не добавляют в свой код подобные проверки, могут вполне обойтись без них. Этой проблемы просто не существует в однопоточном окружении, да и учитывая что в онлайн примерах с кодом редко можно встретить ключевое слово
volatile
, эта дополнительная проверка вполне может быть бессмысленной. Если нашей задачей является отслеживаниеNullReferenceException
, нельзя ли обойтись вообще без проверки наnull
, присвоив пустойdelegate { }
нашему событию во время инициализации объекта класса?
Это подводит нас к еще одному варианту решения проблемы.
public event Action MyLittleEvent = delegate {};
MyLittleEvent
никогда не будет равно null
, и лишнюю проверку можно просто не делать. В многопоточной среде нужно только синхронизировать добавление и удаление подписчиков события, но вызывать его можно без опасения получения NullReferenceException
:
Вариант 4:
public event Action MyLittleEvent = delegate {};
protected virtual void OnMyLittleEvent()
{
// Собственно, это все
MyLittleEvent();
}
Единственный минус такого подхода по сравнению с предыдущим — небольшой оверхед на вызов пустого события (оверхед оказался равен примерно 5 наносекунд на вызов). Можно так же подумать, что в случае большого количества разных классов с разными событиями, эти пустые «затычки» для событий будут занимать много места в оперативной памяти, но если верить Джону Скиту в ответе на SO, начиная с версии C# 3.0 компилятор использует один и тот же объект пустого делегата для всех «затычек». От себя добавлю, что при проверке получившегося IL кода это утверждение не подтверждается, пустые делегаты создаются по штуке на событие (проверял с помощью LINQPad и ILSpy). В крайнем случае можно сделать общее на проект статическое поле с пустым делегатом, к которому можно обращаться из всех участков программы.
Путь Джона Скита
Раз уж мы добрались до Джона Скита, стоит отметить его реализацию потокобезопасных событий, которую он описал в C# in Depth в разделе Delegates and Events (статья онлайн и перевод товарища Klotos)
Суть в том, чтобы закрыть add
, remove
и локальную «заморозку» в lock
, что позволит избавиться от возможных неопределенностей с одновременной подпиской на событие нескольких потоков:
SomeEventHandler someEvent;
readonly object someEventLock = new object();
public event SomeEventHandler SomeEvent
{
add
{
lock (someEventLock)
{
someEvent += value;
}
}
remove
{
lock (someEventLock)
{
someEvent -= value;
}
}
}
protected virtual void OnSomeEvent(EventArgs e)
{
SomeEventHandler handler;
lock (someEventLock)
{
handler = someEvent;
}
if (handler != null)
{
handler (this, e);
}
}
Несмотря на то, что этот метод считается устаревшим (внутренняя реализация событий начиная с C# 4.0 выглядит совершенно по-другому, см. список источников в конце статьи), он наглядно показывает, что нельзя просто обернуть вызов событий, подписку и отписку в lock
, поскольку это с очень большой вероятностью может привести к взаимоблокировке (здесь и далее — deadlock). В lock
находится только копирование в локальную переменную, сам вызов события происходит вне этой конструкции.
Но это совершенно не решает проблему вызова обработчиков у уже отписавшихся событий.
Вернемся к вопросу на SO. Дэниель, в ответ на все наши способы предотвращения NullReferenceException
высказывает очень интересную мысль:
Да, я действительно разобрался с этим советом о попытках предотвращения
NullReferenceException
любой ценой. Я говорю о том, что в нашем конкретном случаеNullReferenceException
может возникнуть только если другой поток отписывается от события. И делает он это только для того, чтобы больше никогда не получать события, чего мы, собственно, не добиваемся при использовании проверок локальных переменных. Там, где мы скрываем состояние гонки мы можем открыть его и исправить последствия.NullReferenceException
позволяет определить момент неправильного обращения с вашим событием. В общем я утверждаю, что эта техника копирования и проверки — простое Карго-культ программирование, которое добавляет неразбериху и шум в ваш код, но совершенно не решает проблему многопоточных событий.
Среди прочих, на вопрос ответил и Джон Скит, и вот что он пишет.
Джон Скит против Джеффри Рихтера
JIT компилятор не имеет права оптимизировать локальную ссылку на делегат, поскольку там присутствует условие. Эту информацию «вбросили» некоторое время назад, но это неправда (я уточнял этот вопрос то ли у Джо Даффи, то ли у Ванса Мориссона). Без модификатора
volatile
просто возникает возможность, что локальная ссылка на делегат будет немного устаревшей, но в целом это все. Это не приведет кNullReferenceException
.И да, у нас определенно возникает состояние гонки, вы правы. Но оно будет присутствовать всегда. Допустим, мы уберем проверку на
null
и просто напишемMyLittleEvent();
А теперь представьте, что наш список подписчиков состоит из 1000 делегатов. Вполне возможно, что мы начнем вызывать событие перед тем, как один из подписчиков отпишется от него. В этом случае он все равно будет вызван, поскольку он останется в старом списке (не забывайте, что делегаты неизменяемы). Насколько я понимаю, это совершенно неизбежно.
Использование пустогоdelegate {};
избавляет нас от необходимости проверять событие наnull
, но это не спасет нас от очередного состояния гонки. Более того, этот способ не гарантирует, что мы будем использовать наиболее свежую версию события.
Теперь надо отметить, что этот ответ был написан в 2009м году, а CLR via C# 4th edition — в 2012. Так кому в итоге верить?
На самом деле я не понял, зачем Рихтер описывает случай с копированием в локальную переменную через Volatile.Read
, поскольку дальше он подтверждает слова Скита:
Хоть и рекомендуется использовать версию с
Volatile.Read
как наилучшую и технически верную, можно обойтись и Вариантом 2, поскольку JIT компилятор знает о том, что он может случайно натворить, оптимизируя локальную переменнуюtempAction
. Чисто теоретически, в будущем это может измениться, поэтому рекомендуется использовать Вариант 3. Но на самом деле Microsoft вряд ли пойдет на такие изменения, поскольку это может сломать огромное количество уже готовых программ.
Всё становится совершенно запутанным — оба варианты равнозначны, но тот, что с Volatile.Read
более равнозначен. И ни один вариант не спасет от состояния гонки при вызове отписавшихся обработчиков.
Может потокобезопасного способа вызова событий вообще не существует? Почему на предотвращение маловероятного NullReferenceException
тратится столько сил и времени, а на предотвращение не менее вероятного вызова отписавшегося обработчика — нет? Этого я так и не понял. Но зато в процессе поиска ответов я понял много всего другого, и вот небольшой итог.
Что имеем в итоге
- Наиболее популярный способ не является потокобезопасным из-за возможности превращения делегата в
null
после поверки на неравенство. Появляется опасность возникновенияNullReferenceException
public event Action MyLittleEvent; ... protected virtual void OnMyLittleEvent() { if (MyLittleEvent != null) // Опасность NullReferenceException MyLittleEvent(); }
- Методы Скита и Рихтера помогают избежать возникновения
NullReferenceException
, но не являются потокобезопасными, поскольку остается вероятность вызова уже отписавшихся обработчиков.Метод СкитаSomeEventHandler someEvent; readonly object someEventLock = new object(); public event SomeEventHandler SomeEvent { add { lock (someEventLock) { someEvent += value; } } remove { lock (someEventLock) { someEvent -= value; } } } protected virtual void OnSomeEvent(EventArgs e) { SomeEventHandler handler; lock (someEventLock) { handler = someEvent; } if (handler != null) { handler (this, e); } }
Метод Рихтераprotected virtual void OnMyLittleEvent() { // Ну теперь уж точно заморозили Action tempAction = Volatile.Read(ref MyLittleEvent); if (tempAction != null) tempAction (); }
- Метод пустого
delegate {};
позволяет избавиться отNullReferenceException
благодаря тому, что событие никогда не обращается вnull
, но не является потокобезопасным поскольку остается вероятность вызова уже отписавшихся обработчиков. Более того, без модификатораvolatile
у нас есть возможность получить не самую свежую версию делегата при вызове события. - Нельзя просто обернуть добавление, удаление и вызов события в
lock
, поскольку это создаст опасность взаимоблокировки. Технически, это может спасти от вызова отписавшихся обработчиков, но мы не можем быть уверенными в том, какие действия сделал объект-подписчик перед тем, как отписаться от события, поэтому мы все еще можем напороться на «испорченный» объект (см. пример сObjectDisposedException
). Этот метод также не является потокобезопасным. - Попытка поймать отписавшиеся делегаты после локальной «заморозки» события бессмысленна — при большом количестве подписчиков вероятность вызова отписавшихся обработчиков (после начала вызова события) даже выше, чем при локальной «заморозке».
Технически, ни один из представленных вариантов не является потокобезопасным способом вызова события. Более того, добавление метода проверки делегата с помощью локальных копий делегатов создает ложное чувство защищенности. Единственным способом, позволяющим полностью обезопасить себя — заставить обработчики событий проверять, не отписались ли они уже от конкретного события. К сожалению, в отличие от общих практик предотвращения NullReferenceException
при вызове событий, по поводу обработчиков нет никаких предписаний. Если вы делаете отдельную библиотеку, то чаще всего вы никак не можете повлиять на её пользователей — не можете заставить клиентов предполагать, что их обработчики не будут вызваны после отписки от события.
После осознания всех этих проблем у меня остались смешанные чувства по поводу внутренней реализации делегатов в C#. С одной стороны, поскольку они являются неизменяемыми, нет шансов получить InvalidOperationException
как в случае перебора изменяющейся коллекции через foreach
, но с другой — нет никакой возможности проверить, отписался ли кто-то от события во время вызова или нет. Единственное что можно сделать со стороны держателя события — обезопаситься от NullReferenceException
и надеяться, что подписчики ничего не испортят. В итоге на поставленный вопрос можно ответить так:
Невозможно обеспечить потокобезопасный вызов события в многопоточном окружении, поскольку всегда остается вероятность вызова обработчиков уже отписавшихся подписчиков. Эта неопределенность противоречит определению термина «потокобезопасность», в частности пункту
Implementation is guaranteed to be free of race conditions when accessed by multiple threads simultaneously.
Дополнительное чтение
Разумеется, я не мог просто скопировать / перевести всё, что нашел. Поэтому оставлю список источников, которые были прямо или косвенно использованы.
- CLR via C# (Jeffrey Richter)
- Вопрос на SO, в котором Джон Скит рассказывает про неизбежность состояния гонки. Еще там есть результаты сравнительных тестов на время вызова событий
- Небольшая статья, в которой четко и взвешено объясняются недостатки различных методов вызова событий (устаревшая)
- C# in Depth — Delegates and Events (Jon Skeet)
- Полезная статья с утверждением того, что обеспечение потокобезопасности — это работа обработчиков
- Еще один сравнительный анализ времени вызовов событий с помощью различных подходов
- Подробности о пустых делегатах
- Немного о Memory Barrier и Volatile (Joe Albahari)
- Еще немного о volatile
- Об атомарности операций со ссылочными типами
- Методы расширений для псевдо-потокобезопасного вызова событий
- C# 5.0 in a Nutshell (пока любимая книга по C#, очень рекомендую)
Автор: KumoKairo