首页 文章

C#事件和线程安全

提问于
浏览
224

UPDATE

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

SomeEvent?.Invoke(this, e);

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

在检查事件之前,请务必复制一份事件_1129263_然后开火 . 这将消除线程的潜在问题,其中事件变为 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 在作者取消订阅后运行,但他们只是取消订阅以避免发生这种情况 . 当然,真正需要的是在 addremove 访问器中具有适当同步的自定义事件实现 . 此外,如果在触发事件时保持锁定,则存在可能发生死锁的问题 .

这是Cargo Cult Programming吗?这似乎是这样 - 许多人必须采取这一步骤来保护他们的代码免受多线程的攻击,而实际上在我看来事件需要更多的关注才能被用作多线程设计的一部分 . . 因此,没有采取额外关注的人可能会忽略这一建议 - 这对于单线程程序来说根本不是问题,事实上,鉴于大多数在线示例代码中缺少 volatile ,建议可能是根本没有效果 .

(并且在成员声明上分配空 delegate { } 并不是很简单,这样你就不需要首先检查 null 了吗?)

更新:如果没有隐藏竞争条件 - 最好揭示它!该null异常有助于检测组件的滥用情况 . 如果您希望保护组件免受滥用,可以按照WPF的示例 - 将线程ID存储在构造函数中,然后在另一个线程尝试直接与组件交互时抛出异常 . 或者实现真正的线程安全组件(不是一件容易的事) .

所以我认为仅仅做这个复制/检查成语是货物崇拜编程,为你的代码添加混乱和噪音 . 要实际防范其他线程需要更多的工作 .

Update in response to Eric Lippert's blog posts:

所以's a major thing I'd错过了事件处理程序:"event handlers are required to be robust in the face of being called even after the event has been unsubscribed",显然因此我们只需要关心事件委托的可能性 null . 对事件处理程序的要求是否记录在何处?

所以:“还有其他方法可以解决这个问题;例如,初始化处理程序以使一个永不删除的空操作 . 但是进行空检查是标准模式 . ”

所以我的问题的另一个片段是,为什么显式 - 空 - 检查"standard pattern"?另一种方法是,分配空委托,只需要将 = delegate {} 添加到事件声明中,这样就可以从事件发生的每个地方消除那些一堆臭味的仪式 . 很容易确保空委托实例化很便宜 . 还是我还缺少什么?

肯定一定是(正如Jon Skeet建议的那样)这只是.NET 1.x的建议,并没有像2005年应该做的那样消失吗?

15 回答

  • 1

    我从来没有真正认为这是一个很大的问题,因为我通常只能防止在我的可重用组件上的静态方法(等)中出现这种潜在的线程错误,而且我不会创建静态事件 .

    我做错了吗?

  • 0

    由于条件的原因,JIT不允许在第一部分中执行您正在讨论的优化 . 我知道这是作为一个幽灵提出的,但它无效 . (我刚才和Joe Duffy或Vance Morrison一起检查过;我不记得是哪一个 . )

    如果没有volatile修饰符,那么它就完全没有了 . 它不会导致 NullReferenceException .

    是的,肯定存在竞争条件 - 但总会如此 . 假设我们只是将代码更改为:

    TheEvent(this, EventArgs.Empty);
    

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

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

  • 4

    我看到很多人都在采用这种扩展方法......

    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() );
    

    并且还取消了本地副本,因为它是在方法调用时捕获的 .

  • 0

    “为什么显式 - 空 - 检查'标准模式'?”

    我怀疑这可能是因为空检查更具性能 .

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

    • 构造空委托的成本 .

    • 构造委托链以包含它的成本 .

    • 每次引发事件时调用无意义委托的成本 .

    (请注意,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上的博客文章(!)

    (我的测试设置可能存在缺陷,因此请随意下载源代码并自行检查 . 非常感谢任何反馈 . )

  • 5

    我真的很喜欢这个读 - 不!即使我需要它来使用称为事件的C#功能!

    为什么不在编译器中修复它?我知道有MS人阅读这些帖子,所以请不要点燃这个!

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

    老实说,我已经厌倦了每次活动都必须这样做 - 也就是样板代码!

    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 - the race condition issue )我读过Eric的博客帖子,我同意H(处理程序)应该在它自我解除引用时处理,但是不能使事件变为不可变/线程安全吗? IE,在其创建时设置一个锁定标志,这样无论何时调用它,它都会在执行时锁定所有订阅和取消订阅?

    Conclusion

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

  • 1

    根据杰弗里里希特在书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);
    

    因为它强制引用副本 . 有关更多信息,请参阅本书中的“事件”部分 .

  • 97

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

    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似乎不喜欢它 .

  • 0

    这种做法不是强制执行某种操作顺序 . 它实际上是关于避免空引用异常 .

    人们关心空参考例外而不是竞争条件背后的原因需要一些深入的心理学研究 . 我认为它与修复空引用问题更容易的事实有关 . 一旦修复,他们就会在代码上悬挂一个大的“Mission Accompleished” Banner 并解压缩他们的飞行服 .

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

  • 10

    所以我在这里参加派对有点晚了 . :)

    至于使用null而不是null对象模式来表示没有订阅者的事件,请考虑这种情况 . 您需要调用一个事件,但构造该对象(EventArgs)并不重要,在通常情况下,您的事件没有订阅者 . 如果您可以优化代码以检查在提交构造参数和调用事件的处理工作之前是否有任何订阅者,那将对您有所帮助 .

    考虑到这一点,一个解决方案是说“好吧,零用户是由null表示 . “然后在执行昂贵的操作之前简单地执行空检查 . 我想另一种方法是在Delegate类型上有一个Count属性,所以如果myDelegate.Count你只执行昂贵的操作> 0.使用Count属性是一个有点好的模式,它解决了允许优化的原始问题,并且它还具有可以在不引起NullReferenceException的情况下被调用的良好属性 .

    但请记住,由于委托是引用类型,因此允许它们为null . 也许根本没有好的方法可以隐藏这个事实并只支持事件的空对象模式,因此替代方案可能是强制开发人员检查null和零订阅者 . 那将比目前的情况更加丑陋 .

    注意:这是纯粹的推测 . 我没有参与.NET语言或CLR .

  • 1

    使用C#6及更高版本,可以使用 TheEvent?.Invoke(this, EventArgs.Empty); 中的新 .? operator 简化代码

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

  • 0

    对于单线程应用程序,您是正确的,这不是问题 .

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

    使用空委托确实解决了问题,但是每次调用事件都会导致性能损失,并且可能会影响GC .

    你是对的,消费者试图取消订阅才能发生这种情况,但如果他们通过临时副本,那么请考虑已经传输的消息 .

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

  • 31

    在施工中连接所有活动,不管它们 . Delegate类的设计不可能正确处理任何其他用法,我将在本文的最后一段中解释 .

    首先,当您的事件处理程序必须已经就是否/如何 respond to the notification 做出同步决定时,试图拦截事件 notification 是没有意义的 .

    应通知任何可能通知的内容 . 如果您的事件处理程序正确处理通知(即他们可以访问权威应用程序状态并仅在适当时做出响应),那么可以随时通知他们并相信他们会正确响应 .

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

    老实说,我认为Delegate类是无法实现的 . 合并/转换到MulticastDelegate是一个巨大的错误,因为它有效地将事件的(有用的)定义从在一个瞬间发生的事件改变为在一个时间 Span 内发生的事情 . 这种变化需要一种同步机制,可以在逻辑上将其折回一个瞬间,但MulticastDelegate缺少任何这样的机制 . 同步应该包含整个时间 Span 或事件发生的瞬间,以便一旦应用程序做出同步决定以开始处理事件,它就完成了(事务性地)处理它 . 使用MulticastDelegate / Delegate混合类的黑盒子,这几乎是不可能的,所以 adhere to using a single-subscriber and/or implement your own kind of MulticastDelegate that has a synchronization handle that can be taken out while the handler chain is being used/modified . 我建议这样做,因为替代方案是在所有处理程序中冗余地实现同步/事务完整性,这将是荒谬/不必要的复杂 .

  • 0

    请看这里:http://www.danielfortunov.com/software/%24daniel_fortunovs_adventures_in_software_development/2009/04/23/net_event_invocation_thread_safety这是正确的解决方案,应始终使用而不是所有其他解决方法 .

    “通过使用do-nothing匿名方法初始化内部调用列表,您可以确保内部调用列表始终至少有一个成员 . 因为没有外部方可以引用匿名方法,所以没有外部方可以删除该方法,因此委托将永远不会出现“ - 编程.NET组件,第2版,作者:JuvalLö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);  
    }
    
  • 50

    我不相信这个问题仅限于c#“event”类型 . 除去这个限制,为什么不重新发明轮子并沿着这些线做一些事情?

    Raise event thread safely - best practice

    • 能够在加注中取消/取消订阅任何线程(移除竞争条件)

    • 运算符在类级别为=和 - =重载 .

    • 通用调用者定义的委托

  • 0

    感谢您的有益讨论 . 我最近正在研究这个问题,并使下面的类有点慢,但允许避免调用已处置的对象 .

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

    /// <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();
        }
    

    Test

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

    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#中实现线程安全事件 .

    你怎么看?

相关问题