我经常听到/阅读以下建议:
在检查 null
并触发它之前,请务必制作一个事件的副本。这将消除线程在您检查 null 和触发事件之间的位置处事件变为 null
的潜在问题:
// Copy the event delegate before checking/calling
EventHandler copy = TheEvent;
if (copy != null)
copy(this, EventArgs.Empty); // Call any handlers on the copied list
更新:我从阅读有关优化的文章中想到,这可能还需要事件成员是易失的,但 Jon Skeet 在他的回答中指出 CLR 不会优化副本。
但与此同时,为了让这个问题发生,另一个线程必须做了这样的事情:
// Better delist from event - don't want our handler called from now on:
otherObject.TheEvent -= OnTheEvent;
// Good, now we can be certain that OnTheEvent will not run...
实际的顺序可能是这种混合:
// Copy the event delegate before checking/calling
EventHandler copy = TheEvent;
// Better delist from event - don't want our handler called from now on:
otherObject.TheEvent -= OnTheEvent;
// Good, now we can be certain that OnTheEvent will not run...
if (copy != null)
copy(this, EventArgs.Empty); // Call any handlers on the copied list
关键是 OnTheEvent
在作者取消订阅之后运行,但他们只是专门取消订阅以避免这种情况发生。当然,真正需要的是在 add
和 remove
访问器中具有适当同步的自定义事件实现。此外,如果在触发事件时持有锁,则可能会出现死锁问题。
这是Cargo Cult Programming吗?看起来是这样 - 很多人必须采取这一步来保护他们的代码免受多线程的影响,而实际上在我看来,事件需要比这更多的小心才能将它们用作多线程设计的一部分.因此,没有采取额外注意的人可能会忽略此建议 - 对于单线程程序来说这根本不是问题,事实上,鉴于大多数在线示例代码中没有 volatile
,该建议可能完全没有效果。
(在成员声明上分配空的 delegate { }
是不是更简单,这样您就不需要首先检查 null
了?)
更新:如果不清楚,我确实掌握了建议的意图——在任何情况下都避免出现空引用异常。我的观点是,这个特定的空引用异常只有在另一个线程从事件中删除时才会发生,这样做的唯一原因是确保不会通过该事件接收到进一步的调用,这显然不是通过这种技术实现的.你会隐瞒比赛条件 - 最好揭示它!该空异常有助于检测组件的滥用。如果您希望您的组件免受滥用,您可以按照 WPF 的示例 - 将线程 ID 存储在您的构造函数中,然后在另一个线程尝试直接与您的组件交互时抛出异常。或者实现一个真正线程安全的组件(不是一件容易的事)。
所以我认为仅仅做这个复制/检查习惯就是货物崇拜编程,给你的代码添加混乱和噪音。要真正防止其他线程需要更多的工作。
更新以响应 Eric Lippert 的博客文章:
所以我错过了关于事件处理程序的一件主要事情:“即使在事件被取消订阅后,事件处理程序也必须在被调用时保持健壮”,因此显然我们只需要关心事件的可能性代表是 null
。 对事件处理程序的要求是否记录在任何地方?
所以:“还有其他方法可以解决这个问题;例如,初始化处理程序以拥有一个永远不会删除的空操作。但是进行空检查是标准模式。”
所以我的问题剩下的一个片段是,为什么显式空检查“标准模式”?另一种方法是分配空委托,只需要将 = delegate {}
添加到事件声明中,这消除了每个举办活动的地方的那些小堆臭气熏天的仪式。很容易确保空委托的实例化成本很低。还是我还缺少什么?
肯定是(正如 Jon Skeet 所建议的)这只是 .NET 1.x 的建议,并没有像 2005 年那样消亡吗?
更新
从 C# 6 开始,这个问题的 the answer 是:
SomeEvent?.Invoke(this, e);
EventName(arguments)
无条件地调用事件的委托,而不是让它只在非空时调用委托(如果为空则不执行任何操作)。
由于条件的原因,不允许 JIT 执行您在第一部分中谈论的优化。我知道这是不久前提出的一个幽灵,但它是无效的。 (我不久前曾与 Joe Duffy 或 Vance Morrison 核对过;我不记得是哪个了。)
如果没有 volatile 修饰符,获取的本地副本可能会过时,但仅此而已。它不会导致 NullReferenceException
。
是的,肯定有竞争条件——但总会有的。假设我们只是将代码更改为:
TheEvent(this, EventArgs.Empty);
现在假设该委托的调用列表有 1000 个条目。在另一个线程取消订阅列表末尾附近的处理程序之前,列表开头的操作很可能已经执行。但是,该处理程序仍将被执行,因为它将是一个新列表。 (代表是不可变的。)据我所知,这是不可避免的。
使用空委托当然可以避免无效性检查,但不能修复竞争条件。它也不能保证您总是“看到”变量的最新值。
我看到很多人都在朝着这样做的扩展方法...
public static class Extensions
{
public static void Raise<T>(this EventHandler<T> handler,
object sender, T args) where T : EventArgs
{
if (handler != null) handler(sender, args);
}
}
这为您提供了更好的语法来引发事件......
MyEvent.Raise( this, new MyEventArgs() );
并且还取消了本地副本,因为它是在方法调用时捕获的。
“为什么显式空检查'标准模式'?”
我怀疑其原因可能是空检查性能更高。
如果您总是在创建事件时为事件订阅一个空委托,则会产生一些开销:
构建空委托的成本。
构建委托链以包含它的成本。
每次引发事件时调用无意义委托的成本。
(请注意,UI 控件通常有大量事件,其中大多数从未订阅过。必须为每个事件创建一个虚拟订阅者然后调用它可能会对性能造成重大影响。)
我做了一些粗略的性能测试以查看 subscribe-empty-delegate 方法的影响,以下是我的结果:
Executing 50000000 iterations . . .
OnNonThreadSafeEvent took: 432ms
OnClassicNullCheckedEvent took: 490ms
OnPreInitializedEvent took: 614ms <--
Subscribing an empty delegate to each event . . .
Executing 50000000 iterations . . .
OnNonThreadSafeEvent took: 674ms
OnClassicNullCheckedEvent took: 674ms
OnPreInitializedEvent took: 2041ms <--
Subscribing another empty delegate to each event . . .
Executing 50000000 iterations . . .
OnNonThreadSafeEvent took: 2011ms
OnClassicNullCheckedEvent took: 2061ms
OnPreInitializedEvent took: 2246ms <--
Done
请注意,对于零个或一个订阅者(对于 UI 控件很常见,其中事件很多),使用空委托预初始化的事件明显较慢(超过 5000 万次迭代......)
有关更多信息和源代码,请访问我在提出此问题前一天发布的 .NET Event invocation thread safety 上的这篇博文(!)
(我的测试设置可能存在缺陷,因此请随时下载源代码并自行检查。非常感谢任何反馈。)
Delegate.Combine
/Delegate.Remove
对计时;如果重复添加和删除相同的委托实例,则案例之间的成本差异将特别明显,因为当参数之一为 null
(仅返回另一个)和 {5 } 当两个参数相等时非常快(只返回 null)。
我真的很喜欢这篇文章——不!即使我需要它来处理称为事件的 C# 功能!
为什么不在编译器中解决这个问题?我知道有 MS 人读过这些帖子,所以请不要喷这个!
1 - Null 问题)为什么不首先将事件设为 .Empty 而不是 null 呢?将保存多少行代码用于空检查或必须将 = delegate {}
粘贴到声明上?让编译器处理 Empty 情况,IE 什么也不做!如果这对事件的创建者来说很重要,他们可以检查 .Empty 并做任何他们关心的事情!否则,所有空检查/委托添加都是围绕问题的黑客攻击!
老实说,我厌倦了必须对每个事件都这样做 - 也就是样板代码!
public event Action<thisClass, string> Some;
protected virtual void DoSomeEvent(string someValue)
{
var e = Some; // avoid race condition here!
if(null != e) // avoid null condition here!
e(this, someValue);
}
2 - 竞争条件问题)我阅读了 Eric 的博客文章,我同意 H(处理程序)在取消引用自身时应该处理,但不能将事件设为不可变/线程安全吗? IE,在其创建时设置一个锁定标志,以便每当它被调用时,它会在执行时锁定所有订阅和取消订阅它?
结论,
现代语言不应该为我们解决这些问题吗?
根据 Jeffrey Richter 在书 CLR via C# 中的说法,正确的方法是:
// Copy a reference to the delegate field now into a temporary field for thread safety
EventHandler<EventArgs> temp =
Interlocked.CompareExchange(ref NewMail, null, null);
// If any methods registered interest with our event, notify them
if (temp != null) temp(this, e);
因为它强制引用副本。有关更多信息,请参阅书中的事件部分。
Interlocked.CompareExchange
传递给 null ref
,它会失败,但这与将 ref
传递到存在且最初 持有的存储位置(例如 NewMail
)不同i> 空引用。
我一直在使用这种设计模式来确保事件处理程序在取消订阅后不会被执行。到目前为止,它运行得很好,尽管我还没有尝试过任何性能分析。
private readonly object eventMutex = new object();
private event EventHandler _onEvent = null;
public event EventHandler OnEvent
{
add
{
lock(eventMutex)
{
_onEvent += value;
}
}
remove
{
lock(eventMutex)
{
_onEvent -= value;
}
}
}
private void HandleEvent(EventArgs args)
{
lock(eventMutex)
{
if (_onEvent != null)
_onEvent(args);
}
}
这些天,我主要使用 Mono for Android,当您在 Activity 发送到后台后尝试更新 View 时,Android 似乎不喜欢它。
这种做法并不是要强制执行特定的操作顺序。它实际上是关于避免空引用异常。
人们关心空引用异常而不是竞争条件的原因需要一些深入的心理学研究。我认为这与修复空引用问题要容易得多这一事实有关。一旦解决了这个问题,他们就会在代码上挂一个大大的“任务完成”横幅,然后解开飞行服的拉链。
注意:修复竞争条件可能涉及使用同步标志跟踪处理程序是否应该运行
Unload
事件)都无法安全地取消对象的订阅其他事件。讨厌。更好的是简单地说事件取消订阅请求将导致事件“最终”被取消订阅,并且事件订阅者应该在调用它们时检查是否有任何对他们有用的事情要做。
所以我在这里聚会有点晚了。 :)
至于使用 null 而不是 null 对象模式来表示没有订阅者的事件,请考虑这种情况。您需要调用一个事件,但构造对象 (EventArgs) 并非易事,而且在通常情况下,您的事件没有订阅者。如果您可以优化您的代码以检查您是否有任何订阅者,然后再提交处理工作以构造参数和调用事件,这对您将是有益的。
考虑到这一点,一个解决方案是说“嗯,零订阅者由 null 表示”。然后在执行昂贵的操作之前简单地执行空检查。我想另一种方法是在 Delegate 类型上使用 Count 属性,因此只有在 myDelegate.Count > 0 时才执行昂贵的操作。使用 Count 属性是一种很好的模式,可以解决原始问题允许优化,并且它还具有能够在不导致 NullReferenceException 的情况下被调用的良好属性。
但是请记住,由于委托是引用类型,因此它们可以为空。也许根本没有什么好的方法可以隐藏这个事实并仅支持事件的空对象模式,因此替代方案可能会迫使开发人员同时检查空订阅者和零订阅者。那将比现在的情况更丑陋。
注意:这是纯粹的猜测。我不涉及 .NET 语言或 CLR。
+=
和 -=
。在该类中,允许的操作还包括调用(使用内置的 null 检查)、针对 null
进行测试或设置为 null
。对于其他任何事情,都必须使用名称为带有特定前缀或后缀的事件名称的委托。
对于单线程应用程序,您是正确的,这不是问题。
但是,如果您正在制作一个公开事件的组件,则无法保证您的组件的使用者不会使用多线程,在这种情况下,您需要为最坏的情况做好准备。
使用空委托确实可以解决问题,但也会导致每次调用事件时性能受到影响,并且可能会产生 GC 影响。
您是对的,消费者尝试取消订阅以使这种情况发生,但如果他们通过临时副本,则考虑该消息已经在传输中。
如果你不使用临时变量,也不使用空委托,并且有人取消订阅,你会得到一个空引用异常,这是致命的,所以我认为这个成本是值得的。
我从来没有真正认为这是一个很大的问题,因为我通常只在我的可重用组件上的静态方法(等)中防止这种潜在的线程错误,并且我不制作静态事件。
我做错了吗?
在施工中连接您的所有活动,不要管它们。 Delegate 类的设计不可能正确处理任何其他用法,我将在本文的最后一段中解释。
首先,当您的事件处理程序必须已经就是否/如何响应通知做出同步决定时,尝试拦截事件通知是没有意义的。
任何可能被通知的事情,都应该被通知。如果您的事件处理程序正确处理通知(即他们可以访问权威的应用程序状态并仅在适当时响应),那么随时通知他们并相信他们会正确响应就可以了。
唯一不应该通知处理程序事件发生的情况是事件实际上没有发生!因此,如果您不希望通知处理程序,请停止生成事件(即禁用控件或首先负责检测和使事件存在的任何东西)。
老实说,我认为 Delegate 类是无法挽救的。向 MulticastDelegate 的合并/转换是一个巨大的错误,因为它有效地将事件的(有用的)定义从发生在某个时刻的事情变成了在一段时间内发生的事情。这种变化需要一种同步机制,可以在逻辑上将其折叠回单个瞬间,但 MulticastDelegate 缺少任何此类机制。同步应该包括事件发生的整个时间跨度或瞬间,以便一旦应用程序做出开始处理事件的同步决定,它就会完全(事务性地)完成处理。使用 MulticastDelegate/Delegate 混合类的黑盒,这几乎是不可能的,因此请坚持使用单订阅者和/或实现您自己的 MulticastDelegate 类型,它具有可以在处理程序链运行时取出的同步句柄正在使用/修改。我推荐这个,因为替代方案是在所有处理程序中冗余地实现同步/事务完整性,这将是荒谬/不必要的复杂。
请在此处查看:http://www.danielfortunov.com/software/%24daniel_fortunovs_adventures_in_software_development/2009/04/23/net_event_invocation_thread_safety 这是正确的解决方案,应始终使用,而不是所有其他解决方法。
“您可以通过使用无操作匿名方法对其进行初始化来确保内部调用列表始终具有至少一个成员。因为没有外部方可以引用匿名方法,所以没有外部方可以删除该方法,因此委托永远不会为空” — 编程 .NET 组件,第 2 版,作者:Juval Löwy
public static event EventHandler<EventArgs> PreInitializedEvent = delegate { };
public static void OnPreInitializedEvent(EventArgs e)
{
// No check required - event will never be null because
// we have subscribed an empty anonymous delegate which
// can never be unsubscribed. (But causes some overhead.)
PreInitializedEvent(null, e);
}
我不相信这个问题仅限于 c#“事件”类型。消除这个限制,为什么不重新发明轮子并按照这些思路做点什么?
Raise event thread safely - best practice
能够在提升期间从任何线程订阅/取消订阅(竞争条件已删除)
+= 和 -= 在类级别的运算符重载。
通用调用者定义的委托
感谢您的有用讨论。我最近正在解决这个问题,并制作了以下课程,它有点慢,但可以避免调用已处置的对象。
这里的要点是即使引发事件也可以修改调用列表。
/// <summary>
/// Thread safe event invoker
/// </summary>
public sealed class ThreadSafeEventInvoker
{
/// <summary>
/// Dictionary of delegates
/// </summary>
readonly ConcurrentDictionary<Delegate, DelegateHolder> delegates = new ConcurrentDictionary<Delegate, DelegateHolder>();
/// <summary>
/// List of delegates to be called, we need it because it is relatevely easy to implement a loop with list
/// modification inside of it
/// </summary>
readonly LinkedList<DelegateHolder> delegatesList = new LinkedList<DelegateHolder>();
/// <summary>
/// locker for delegates list
/// </summary>
private readonly ReaderWriterLockSlim listLocker = new ReaderWriterLockSlim();
/// <summary>
/// Add delegate to list
/// </summary>
/// <param name="value"></param>
public void Add(Delegate value)
{
var holder = new DelegateHolder(value);
if (!delegates.TryAdd(value, holder)) return;
listLocker.EnterWriteLock();
delegatesList.AddLast(holder);
listLocker.ExitWriteLock();
}
/// <summary>
/// Remove delegate from list
/// </summary>
/// <param name="value"></param>
public void Remove(Delegate value)
{
DelegateHolder holder;
if (!delegates.TryRemove(value, out holder)) return;
Monitor.Enter(holder);
holder.IsDeleted = true;
Monitor.Exit(holder);
}
/// <summary>
/// Raise an event
/// </summary>
/// <param name="args"></param>
public void Raise(params object[] args)
{
DelegateHolder holder = null;
try
{
// get root element
listLocker.EnterReadLock();
var cursor = delegatesList.First;
listLocker.ExitReadLock();
while (cursor != null)
{
// get its value and a next node
listLocker.EnterReadLock();
holder = cursor.Value;
var next = cursor.Next;
listLocker.ExitReadLock();
// lock holder and invoke if it is not removed
Monitor.Enter(holder);
if (!holder.IsDeleted)
holder.Action.DynamicInvoke(args);
else if (!holder.IsDeletedFromList)
{
listLocker.EnterWriteLock();
delegatesList.Remove(cursor);
holder.IsDeletedFromList = true;
listLocker.ExitWriteLock();
}
Monitor.Exit(holder);
cursor = next;
}
}
catch
{
// clean up
if (listLocker.IsReadLockHeld)
listLocker.ExitReadLock();
if (listLocker.IsWriteLockHeld)
listLocker.ExitWriteLock();
if (holder != null && Monitor.IsEntered(holder))
Monitor.Exit(holder);
throw;
}
}
/// <summary>
/// helper class
/// </summary>
class DelegateHolder
{
/// <summary>
/// delegate to call
/// </summary>
public Delegate Action { get; private set; }
/// <summary>
/// flag shows if this delegate removed from list of calls
/// </summary>
public bool IsDeleted { get; set; }
/// <summary>
/// flag shows if this instance was removed from all lists
/// </summary>
public bool IsDeletedFromList { get; set; }
/// <summary>
/// Constuctor
/// </summary>
/// <param name="d"></param>
public DelegateHolder(Delegate d)
{
Action = d;
}
}
}
用法是:
private readonly ThreadSafeEventInvoker someEventWrapper = new ThreadSafeEventInvoker();
public event Action SomeEvent
{
add { someEventWrapper.Add(value); }
remove { someEventWrapper.Remove(value); }
}
public void RaiseSomeEvent()
{
someEventWrapper.Raise();
}
测试
我以以下方式对其进行了测试。我有一个线程可以创建和销毁这样的对象:
var objects = Enumerable.Range(0, 1000).Select(x => new Bar(foo)).ToList();
Thread.Sleep(10);
objects.ForEach(x => x.Dispose());
在 Bar
(侦听器对象)构造函数中,我订阅了 SomeEvent
(如上所示实现)并在 Dispose
中取消订阅:
public Bar(Foo foo)
{
this.foo = foo;
foo.SomeEvent += Handler;
}
public void Handler()
{
if (disposed)
Console.WriteLine("Handler is called after object was disposed!");
}
public void Dispose()
{
foo.SomeEvent -= Handler;
disposed = true;
}
我也有几个线程在循环中引发事件。
所有这些操作都是同时执行的:创建和销毁许多侦听器,同时触发事件。
如果有竞争条件,我应该在控制台中看到一条消息,但它是空的。但是如果我像往常一样使用 clr 事件,我会看到它充满了警告消息。因此,我可以得出结论,可以在 c# 中实现线程安全事件。
你怎么看?
disposed = true
可能(理论上)发生在 foo.SomeEvent -= Handler
之前,从而产生误报。但除此之外,您可能还需要更改一些内容。您确实想使用 try ... finally
作为锁 - 这将帮助您使其不仅是线程安全的,而且也是中止安全的。更不用说你可以摆脱那个愚蠢的try catch
。而且您没有检查在 Add
/Remove
中传递的委托 - 它可能是 null
(您应该立即在 Add
/Remove
中丢弃)。