65.9K
CodeProject 正在变化。 阅读更多。
Home

线程安全的事件

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (20投票s)

2009年6月20日

CPOL

10分钟阅读

viewsIcon

99245

线程安全的事件

免责声明: 这篇博客文章只讨论实例事件的常见情况;静态事件被忽略。此外,这篇博客文章的内容100%是我自己的观点。然而,这是一个专门从事多线程编程13年的人的观点。

在多线程环境中编写组件时,一个常见的问题是:“如何使我的事件线程安全?”提问者通常关注线程安全的订阅和取消订阅,但线程安全的触发也必须考虑在内。

错误的解决方案 #1,来自 C# 语言规范

C# 语言的作者试图通过默认方式使事件订阅和取消订阅线程安全。为此,他们允许(但不要求)对 this 进行锁定,这通常被认为是糟糕的做法。以下代码

public event MyEventHandler MyEvent;

逻辑上变成以下代码

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

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

Chris Burrows,微软 C# 编译器团队的开发人员,在他的博客文章 Field-like Events Considered Harmful 中解释了为什么这种做法不好。他的博客文章全面阐述了原因,这里不再重复。

小抱怨:Java 语言也陷入了同样的陷阱;请参阅 Practical API Design 的 Java 监视器页面。为什么有些语言设计者认为他们可以通过声明式地解决多线程问题?如果解决方案真的那么简单,为什么其他人还没有发现呢?多线程编程几十年来一直困扰着一些最聪明的人,而且它很困难。语言设计者不能通过撒上一些神奇的仙粉(即使他们将这种粉命名为“MethodImplOptions.Synchronized”)来消除多线程的复杂性。事实上,大多数时候他们只是让事情变得更糟。

假设对 this 进行锁定是没问题的。毕竟,它确实起作用;它只是增加了意外死锁的可能性。未来 C# 编译器也可能锁定一个超级秘密的 private 字段而不是 this。然而,即使实现没问题,设计仍然存在缺陷。当考虑如何以线程安全的方式触发事件时,问题就变得清晰起来。

这是标准、简单且逻辑的事件触发代码

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

如果存在多个线程订阅和取消订阅事件,那么内置的类字段事件锁定只对订阅和取消订阅有效。事件触发代码暴露出一个问题:如果在 if 语句之后但在事件被触发之前,另一个线程取消订阅了该事件,那么这段代码可能会导致 NullReferenceException

所以,结果是“线程安全”事件并非真正线程安全。继续往下看……

错误的解决方案 #2,来自框架设计指南和 MSDN

上述问题的一种解决方案是在测试事件委托之前复制一份。事件触发代码变为

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

这是 MSDN 示例中使用的解决方案,并且由半标准化的 框架设计指南推荐(我的第二版在第 157 页,但该书的相关部分可在网上此处找到)。

这个解决方案简单、显而易见,却是错误的。(顺便说一句,我并不是在贬低《框架设计指南》。它们有很多好的建议,我并不想普遍批评这本书。它们只是在这个特定建议上犯了错误。)

没有扎实多线程编程背景的程序员可能无法立即发现这个解决方案为何是错误的。委托是不可变引用类型,因此局部变量的复制是原子操作;这里没有问题。问题存在于内存模型中:一个处理器缓存中可能持有委托字段的过时值。为了确保读取非 volatile 字段的当前值,必须发出内存屏障或将复制操作封装在锁中(并且必须是事件添加/移除方法所获取的同一个锁),而无需深入痛苦的细节。

简而言之,这个解决方案确实阻止了 NullReferenceException 竞态条件;但它引入了另一个竞态条件(触发一个已取消订阅的事件处理程序)。

错误的解决方案 #3,来自 Jon Skeet

[好吧,我先说:Jon Skeet 是一位出色的程序员。我强烈推荐他的书 C# in Depth所有使用 C# 的人(我拥有第一版,第二版一上市就会购买;他现在正在编写,我非常兴奋!)。我关注他的博客。我非常尊重他,我不敢相信我的博客上第一次提到他是负面的……然而,他确实提出了一个错误的线程安全事件解决方案。不过,值得称赞的是,他的论文最后推荐了正确的解决方案!]

Jon Skeet 在他的论文 Delegates and Events 中对这个主题进行了精彩的论述(您可能想跳到标题为“Thread-safe events”的部分)。他涵盖了我上面描述的所有内容,但随后又提出了另一个错误的解决方案。他不喜欢内存屏障解决方案(我也是),并试图通过将复制操作包装在锁中来解决它。正如 Jon 指出的那样,事件添加/移除方法可能会锁定 this 或者它们可能会锁定其他东西(记住,未来的 C# 编译器可能会选择锁定一个超级秘密的 private 字段)。因此,默认的 add/remove 方法必须替换为执行显式 lock 的方法,如下所示

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,来自没有人(但以防万一你在考虑!)

一个自然的反应是扩展 Jon 代码中的 lock 语句以包含事件的触发。这确实防止了上述所有解决方案中的竞态条件问题,但它引入了一个更严重的问题。

如果使用此解决方案,则事件处理程序无法等待试图订阅或取消订阅同一事件的处理程序的另一个线程。换句话说,这就是原始的“意外死锁”的故事(与锁定 this 不好的原因相同)。Jon 在 Delegates and Events 中确实提到了这一点。

据我所知,没有人提出将其作为解决方案。总的来说,社区似乎更倾向于“大声”失败(带异常)而不是“默默地”失败(带死锁)的解决方案。

为什么所有解决方案都是错误的,作者:Stephen Cleary(就是我!)

“回调”(在 C# 中通常是事件)在多线程编程中一直存在问题。这是因为组件设计的一个经验法则是:尽最大努力允许事件处理程序做任何事情。由于“与试图获取任何锁的另一个线程通信”是“任何事情”的一个例子,因此这条规则的一个自然推论是:在回调期间绝不持有锁。

这就是为什么锁定暴露的对象(如 this)被认为是糟糕实践的原因(请参阅 MSDN:lock 语句)。在触发事件时持有该锁(如解决方案 4 所做)会使这种糟糕实践变得更糟。

回顾一下,上面所有解决方案都在以下两种情况之一中失败。

上面的解决方案1-3都未能通过相同的用例

  • 线程 A 将触发事件。
  • 线程 B 订阅一个事件处理程序。该处理程序代码依赖于一个资源。
  • 线程 A 开始触发事件。就在委托被调用之前,线程 A 被线程 B 抢占。
  • 线程 B 不再需要事件通知,因此它取消订阅事件处理程序并处置资源。
  • 线程 A 继续触发事件(该事件已被取消订阅)。处理程序代码依赖的资源现已被处置。

解决方案 4 在此用例中失败

  • 线程 A 将触发事件。
  • 线程 B 订阅了一个事件处理程序。该处理程序代码与线程 C 通信。
  • 线程 A 开始触发事件。就在委托被调用之前,线程 A 被线程 C 抢占。
  • 线程 C 订阅了一个事件处理程序。线程 C 阻塞。
  • 线程 A 继续触发事件。由于线程 C 已阻塞,处理程序代码无法与线程 C 通信。

通用的“线程安全事件”解决方案不存在——至少,我们目前可用的同步原语无法实现。实现方案必须要么存在竞态条件,要么存在死锁的可能性。锁可以防止竞争(解决竞态条件),但前提是在事件触发期间持有它(可能导致死锁)。或者,一个未经修饰的触发事件不会有死锁的可能性,但会失去锁的保证(导致竞态条件)。

一个通用的解决方案并不存在,但是通过对用户施加特殊要求,有可能解决特定事件的问题。如果对事件处理程序施加限制,上面的一些解决方案可能会奏效。

如果事件处理程序被编写成能够处理在取消订阅后仍被调用的情况,则解决方案 2 或 3 是可行的。以这种方式编写处理程序并不困难;异步回调上下文将有助于实现。缺点是每个事件处理程序都必须包含多线程感知代码,这会使方法复杂化。

如果事件处理程序不阻塞订阅或取消订阅同一事件的线程,则解决方案 4 也可能奏效。为简单起见,采用此路径的 API 通常只声明事件处理程序不得阻塞。缺点是这可能难以保证,因为许多对象对其调用者隐藏了其锁定逻辑。

结论

一个通用解决方案并不存在,所有其他解决方案都存在严重缺陷(对事件处理程序可执行的操作施加了严格限制)。

因此,我推荐 Jon Skeet 在 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);
    }
}

另一个副作用是,这种事件处理方式迫使人们倾向于基于事件的异步编程(或与之非常相似的东西)。EBAP 是异步对象设计的逻辑结论,可实现最大程度的可重用性。EBAP 也更符合正常的并发限制:“此类型的 Public static 成员是线程安全的。不保证任何实例成员是线程安全的。”只能由一个线程访问的事件遵循此常见模式;事件作为实例成员,不保证线程安全。

第三个副作用需要更长的时间才能实现:线程间更正确的通信。与其让各种线程直接订阅事件(无论如何都会在另一个线程上运行),不如实现某种形式的线程通信。这迫使程序员从每个线程的角度更清楚地说明需求,从而减少多线程代码中的错误。通常,会找到更合适的线程通信方式。事件订阅模型作为一种线程通信方法自然会被抛弃(因为它固有的不适用性),转而采用更成熟的设计模式。这最终将导致更正确的并发代码,尽管这个过程需要进行一些小的重新设计。

最后说明

截至本文撰写之时,推广解决方案 2(在触发事件前复制委托)仍然很流行。然而,我强烈不鼓励这种做法;它使代码更加晦涩难懂,并提供了虚假的安全感,因为它并没有解决问题!最好是根本不要有“线程安全事件”。

© . All rights reserved.