关于多线程:C#事件和线程安全

C# Events and Thread Safety

更新

从C 6开始,这个问题的答案是:

1
SomeEvent?.Invoke(this, e);

我经常听到/阅读以下建议:

在检查null并触发事件之前,请务必复制事件。这将消除线程化的潜在问题,在检查空值和触发事件之间的位置,事件变为null

1
2
3
4
5
// 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不会优化掉副本。

但是同时,为了使这个问题发生,另一个线程必须做如下的事情:

1
2
3
// 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...

实际的顺序可能是这种混合物:

1
2
3
4
5
6
7
8
9
// 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在作者退订后运行,但他们只是专门退订以避免这种情况发生。当然,真正需要的是在addremove访问器中进行适当同步的定制事件实现。此外,如果在触发事件时持有锁,则可能存在死锁问题。

那么,这是货运邪教节目吗?似乎是这样的——很多人必须采取这一步来保护他们的代码不受多线程的影响,而事实上,在我看来,事件在被用作多线程设计的一部分之前需要比这更为小心。因此,那些没有额外注意的人可能会忽略这个建议——这对于单线程程序来说根本不是一个问题,事实上,考虑到大多数在线示例代码中没有volatile,这个建议可能根本没有效果。

(在成员声明上分配空的delegate { }不是很简单吗?这样您就不需要首先检查null

更新:如果不清楚,我确实理解了建议的意图——在任何情况下都避免空引用异常。我的观点是,只有当另一个线程从事件中退出时,才会发生这种特殊的空引用异常,而这样做的唯一原因是确保不会通过该事件接收到任何进一步的调用,而这显然不是通过此技术实现的。你会隐瞒一个种族状况-最好是把它暴露出来!这个空异常有助于检测组件的滥用。如果希望保护组件不受滥用,可以遵循wpf的示例—将线程ID存储在构造函数中,然后在另一个线程试图与组件直接交互时引发异常。或者实现一个真正的线程安全组件(不是一个简单的任务)。

所以我认为仅仅做这个复制/检查的习惯用法就是Cargo Cult编程,为代码添加混乱和噪音。要实际保护其他线程,需要做更多的工作。

回应Eric Lippert的博客帖子更新:

因此,我错过了一个关于事件处理程序的重要内容:"即使在取消订阅事件之后,事件处理程序也必须在被调用时保持健壮",因此,显然,我们只需要关心事件委托是null的可能性。对事件处理程序的要求是否记录在任何地方?

因此:"还有其他方法可以解决这个问题;例如,初始化处理程序使其具有一个从未删除的空操作。但执行空检查是标准模式。"

所以,我的问题剩下的一部分是,为什么显式的空检查是"标准模式"?另一种选择是指派空代表,只需要在活动声明中添加= delegate {},这就消除了活动举办地的每一个地方都会有一堆糟糕的仪式。很容易确保空委托的实例化成本很低。还是我还缺什么?

当然,这一定是(如乔恩·斯基特所建议的)这只是.NET 1.x的建议,并没有像2005年那样消失?


由于这种情况,不允许JIT执行您在第一部分中讨论的优化。我知道这是一个幽灵,但它是无效的。(我刚才和乔·达菲或万斯·莫里森查过,我记不起是哪一个了。)

如果没有volatile修饰符,本地复制可能会过时,但仅此而已。它不会导致NullReferenceException

是的,当然有一个比赛条件-但总会有。假设我们将代码更改为:

1
TheEvent(this, EventArgs.Empty);

现在假设该委托的调用列表有1000个条目。在另一个线程取消订阅列表末尾附近的处理程序之前,很可能已经执行了列表开头的操作。但是,该处理程序仍将被执行,因为它将是一个新列表。(代表是不变的)据我所知,这是不可避免的。

使用空委托当然可以避免无效检查,但不能修复争用条件。它也不能保证您总是"看到"变量的最新值。


我看到很多人都在使用扩展方法…

1
2
3
4
5
6
7
8
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);  
  }  
}

这给了你更好的语法来引发事件…

1
MyEvent.Raise( this, new MyEventArgs() );

并且也会去掉本地副本,因为它是在方法调用时捕获的。


"为什么显式空检查‘标准模式’?"

我怀疑这可能是因为空检查的性能更高。

如果在创建事件时总是为其订阅空委托,则会产生一些开销:

  • 构造空委托的成本。
  • 构建包含它的委托链的成本。
  • 每次引发事件时调用无意义委托的成本。

(请注意,UI控件通常具有大量的事件,其中大多数事件从未订阅过。必须为每个事件创建一个虚拟订阅服务器,然后调用它,这可能会对性能造成重大影响。)

我做了一些粗略的性能测试,以了解订阅空委托方法的影响,下面是我的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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事件调用线程安全的博客文章,该文章是我在提出此问题的前一天发布的(!)

(我的测试设置可能有缺陷,所以请随意下载源代码并自己检查它。感谢您的任何反馈。)


我真的很喜欢这本书——不是!即使我需要它来处理C功能,称为事件!

为什么不在编译器中修复这个问题?我知道有很多女士读过这些帖子,所以请不要把它烧了!

1-空问题)为什么不首先将事件设为.empty而不是NULL?要保存多少行代码以进行空检查,或者必须将= delegate {}粘贴到声明上?让编译器处理空的情况.如果这一切对事件的创建者都很重要,他们可以检查。清空并做任何他们关心的事情!否则,所有的空检查/委托添加都是围绕问题进行的黑客攻击!

老实说,我已经厌倦了对每一个事件都要这么做了——也就是样板文件代码!

1
2
3
4
5
6
7
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(处理程序)应该在它取消引用自己时处理,但是不能使事件不可变/线程安全?也就是说,在它的创建上设置一个锁标志,这样每当调用它时,它都会在执行时锁定所有订阅和取消订阅?

结论:

现代语言不应该为我们解决这些问题吗?


根据Jeffrey Richter在书clr via c_中的观点,正确的方法是:

1
2
3
4
5
// 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);

因为它强制引用副本。有关更多信息,请参阅本书中他的活动部分。


我一直在使用这种设计模式来确保事件处理程序在取消订阅后不会被执行。虽然我还没有尝试过任何性能分析,但到目前为止它运行得相当好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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);
  }
}

最近我主要是在Android上使用Mono,当你试图在它的活动被发送到后台后更新一个视图时,Android似乎不喜欢它。


使用c 6及以上版本,可以使用新的.? operator简化代码,如下所示:

TheEvent?.Invoke(this, EventArgs.Empty);

参考:https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/operators/null-conditional-operators


所以我来派对有点晚了。:)

至于使用空而不是空对象模式来表示没有订阅服务器的事件,请考虑此方案。您需要调用一个事件,但是构造对象(eventargs)是非常重要的,在通常情况下,您的事件没有订阅服务器。如果您可以在致力于构造参数和调用事件的处理工作之前,对代码进行优化以检查是否有任何订阅服务器,那么这将对您有所帮助。

考虑到这一点,解决方案是说"好吧,零订阅服务器由空表示。"然后在执行昂贵的操作之前,只需执行空检查。我想另一种方法是在委托类型上有一个Count属性,所以只有当mydelegate.count>0时才执行昂贵的操作。使用Count属性是一个很好的模式,它解决了允许优化的原始问题,而且它还有一个很好的属性,即可以在不引起NullReferenceException的情况下调用它。

不过,请记住,由于委托是引用类型,因此允许它们为空。也许根本就没有好的方法可以将这个事实隐藏在封面之下,只支持事件的空对象模式,所以另一种方法可能会迫使开发人员同时检查空订阅服务器和零订阅服务器。这比目前的情况还要糟糕。

注:这纯粹是猜测。我不涉及.NET语言或CLR。


这种做法不是为了执行某种操作命令。实际上是为了避免空引用异常。

关注零参考异常而不是种族状况的人背后的推理需要一些深入的心理学研究。我认为这与修复空引用问题容易得多有关。一旦确定了这一点,他们就会在代码上挂上一个"任务完成"的大横幅,然后解开飞行服的拉链。

注意:修复争用条件可能涉及使用同步标志跟踪处理程序是否应运行


谢谢你的讨论。我最近正在处理这个问题,并使下面的类慢一点,但允许避免调用已处理的对象。

这里的要点是调用列表可以修改,即使事件被引发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
/// <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;
        }
    }
}

其用法是:

1
2
3
4
5
6
7
8
9
10
11
    private readonly ThreadSafeEventInvoker someEventWrapper = new ThreadSafeEventInvoker();
    public event Action SomeEvent
    {
        add { someEventWrapper.Add(value); }
        remove { someEventWrapper.Remove(value); }
    }

    public void RaiseSomeEvent()
    {
        someEventWrapper.Raise();
    }

试验

我用以下方式测试了它。我有一个线程可以创建和销毁这样的对象:

1
2
3
var objects = Enumerable.Range(0, 1000).Select(x => new Bar(foo)).ToList();
Thread.Sleep(10);
objects.ForEach(x => x.Dispose());

在一个Bar(一个侦听对象)构造函数中,我订阅SomeEvent(如上图所示实现),在Dispose中取消订阅:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    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中实现线程安全事件是可能的。

你怎么认为?


我不认为问题仅限于"事件"类型。除去这个限制,为什么不重新发明轮子,沿着这些线做点什么呢?

安全提升事件线程-最佳实践

  • 在加薪(比赛)期间从任何线程订阅/取消订阅的能力条件已删除)
  • 类级别上的+=和-=的运算符重载。
  • 通用调用方定义的委托


请看下面的内容:http://www.danielfortunov.com/software/%24danielu fortunovu inventuresu softwareu development/2009/04/23/netu eventu invocationu threadu safety这是正确的解决方案,应始终使用,而不是所有其他解决方法。

"通过使用不做任何事情的匿名方法初始化内部调用列表,可以确保它始终至少有一个成员。因为没有外部参与方可以引用匿名方法,所以没有外部参与方可以删除该方法,因此委托永远不会为空。"-《编程.NET组件》,第二版,Juval L?WY

1
2
3
4
5
6
7
8
9
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);  
}

把你在施工中的所有活动都联系起来,不要打扰他们。委托类的设计不可能正确地处理任何其他用法,正如我将在本文最后一段中解释的那样。

首先,当事件处理程序必须对是否/如何响应通知做出同步决策时,尝试截取事件通知是没有意义的。

任何可能被通知的,都应该被通知。如果事件处理程序正确地处理通知(即,它们可以访问权威应用程序状态,并且只有在适当的时候才响应),那么可以随时通知它们,并相信它们会正确响应。

唯一不应该通知处理程序事件已经发生的时间,是如果事件实际上没有发生!因此,如果不希望通知处理程序,请停止生成事件(即,首先禁用控件或负责检测并使事件存在的任何内容)。

老实说,我认为委托类是不可分割的。合并/过渡到多播委托是一个巨大的错误,因为它有效地改变了事件的(有用的)定义,从一个瞬间发生的事情,到一个时间跨度发生的事情。这种更改需要一种同步机制,该机制可以在逻辑上将其折叠回单个瞬间,但multicastdelegate缺少任何此类机制。同步应该包括事件发生的整个时间跨度或瞬间,这样一旦应用程序做出开始处理事件的同步决定,它就完成了对事件的完全处理(事务性)。由于黑盒是multicastdelegate/delegate混合类,这几乎是不可能的,因此请坚持使用单个订阅服务器和/或实现您自己的multicastdelegate类型,该类型的multicastdelegate具有一个同步句柄,可以在使用/修改处理程序链时取出。我建议这样做,因为另一种选择是在所有处理程序中冗余地实现同步/事务完整性,这将是荒谬的/不必要的复杂。


我从来没有真正认为这是一个很大的问题,因为我通常只保护我的可重用组件上的静态方法(等)中的这种潜在线程错误,而且我不做静态事件。

我做错了吗?


对于单线程应用程序,这不是问题。

但是,如果您正在创建一个公开事件的组件,就不能保证组件的使用者不会进行多线程处理,在这种情况下,您需要做好最坏的准备。

使用空委托确实可以解决问题,但也会导致每次调用事件时性能受到影响,并且可能会产生GC影响。

您是对的,消费者试图取消订阅以便发生这种情况,但如果他们超过了临时副本,那么考虑消息已经在传输中。

如果不使用临时变量,不使用空委托,并且有人取消订阅,则会得到一个空引用异常,这是致命的,因此我认为成本是值得的。