Как работают события в C#

1,00
р.
Предположим, в одном объекте у нас есть событие, которое инициируется при некоторых условиях. В другом объекте есть множество подписчиков на это одно событие. Когда событие произошло, происходит проход по обработчикам события и определяется тот, который должен отработать.
Будет ли обработчик работать асинхронно? Нужно ли использовать lock-и и блокировать куски кода, чтобы другие обработчики туда не зашли? И вообще, будут ли работать другие обработчики, пока один из них работает?

Ответ
Значит так.
Для начала, событие будет послано всем подписчикам, одному за другим. Это происходит синхронно, как часть отправки события. Порядок вызова подписчиков не определён, следующий подписчик будет запущен только когда предыдущий полностью отработал (за исключением async-методов, разумеется!).
События доставляются в том потоке, в котором они инициируются. Это значит, что если всё происходит в UI-потоке, то блокировки бессмысленны, многопоточного доступа нет.
Это также означает, что код обработчика событий обязан быть быстрым: если ваш обработчик события делает что-то длинное, при этом остальные обработчики будут ждать, пока первый обработчик закончит, и только тогда получат управление. Хуже того, если пока работает обработчик события, этот поток блокируется, и доставка всех других событий приостанавливается.
Тем не менее, есть неприятный момент: если вы (не дай бог!) почему-то в обработчике события запускаете модальный диалог, то в нём запускается внутренний цикл обработки событий, и таким образом при ещё бегущем обработчике событий вполне может прийти следующее событие, и обработчик выполнится рекурсивно! Вы не сможете предотвратить это при помощи блокировок, потому что рекурсия происходит в том же потоке. Просто никогда не делайте ничего длинного блокирующего в обработчике события, иначе неприятностей рано или поздно не избежать.
Если вы пользуетесь современным C#, имеет смысл сделать async-обработчик события, чтобы он «отдавал» выполнение другим обработчикам при первом же await'е. Таким образом, пока один из обработчиков «висит» в await'е, начнёт выполняться следующий. Имейте в виду, что при этом обработчик события станет асинхронным, и его точно так же сможет «догнать» следующий обработчик того же или следующего экземпляра события.
Или если вы не пользуетесь async/await (например, потому, что работаете с версией языка эпохи палеолита), то длинную обработку лучше выгружать в другой поток (например, BackgroundWorker, если вы пользуетесь каким-нибудь UI-фреймворком, или TPL).

Дополнение: Как справедливо замечает в комментариях @Антон Буторин, если один из обработчиков события синхронно выбрасывает исключение, то запуск остальных обработчиков также не происходит, а отправление события завершается (с этим же исключением). Для async void-обработчиков, если исключение выбрасывается в асинхронной части, это может и вовсе привести к падению приложения. Отсюда совет, граничащий по силе с правилом: обработчики событий не должны выбрасывать исключения.

Обновление:
В обычном случае я бы советовал делать так:
Подписка на события, отписка от них и посылка самих событий должны происходить в UI-потоке. Код посылки сообщения в таком случае стандартный: public class UserLoginEventArgs : EventArgs { public User User { get private set } internal UserLoginEventArgs(User user) { this.User = user } }
public event EventHandler UserLogin void RaiseUserLoginEvent(User user) { if (UserLogin != null) UserLogin(this, new UserLoginEventArgs(user)) }
Заметьте, что вам не нужна обычно используемая техника копирования UserLogin в локальную переменную. Асинхронность, если она нужна, достигается на стороне обработчика сообщения: // синхронный обработчик void OnUserLogin(object sender, UserLoginEventArgs args) { activeUsers.Add(args.User) }
// асинхронный обработчик async void OnUserLogin(object sender, UserLoginEventArgs args) { var user = args.User if (!punishedUsers.Contain(user)) return await SendUserMessage(user, NotAllowedToLogInText) await LogoutUser(user) }