【问题标题】:What's the standard approach to making calculations thread-safe?使计算线程安全的标准方法是什么?
【发布时间】:2009-10-09 14:31:26
【问题描述】:

最初看似简单的解决方案的问题已被证明是一个非常有趣的挑战。

我有一个类维护一个内部固定大小、线程安全的集合(通过在所有插入和删除操作上使用lock)并通过其属性提供各种统计值。

一个例子:

public double StandardDeviation {
    get {
        return Math.Sqrt((Sum2 - ((Sum * Sum) / Count)) / Count);
    }
}

现在,我已经彻底测试了这个计算,在集合中运行了 10,000 个值,并检查了每次更新的标准偏差。它工作正常......在单线程场景中。

然而,在我们的开发和生产环境的多线程上下文中出现了一个问题。似乎这个数字以某种方式有时会在快速变回实数之前返回NaN。当然,这一定是由于传递给Math.Sqrt 的负值所致。我只能想象在计算过程中,计算中使用的值之一被单独的线程更新时会发生这种情况。

我可以先缓存这些值:

int C = this.Count;
double S = this.Sum;
double S2 = this.Sum2;

return Math.Sqrt((S2 - (S * S) / C) / C);

但随后Sum2 可能仍会更新,例如,在设置S = this.Sum 之后,再次影响计算。

我可以在代码中更新这些值的所有点周围放置一个lock

protected void ItemAdded(double item) {
    // ...

    lock (this.CalculationLock) {
        this.Sum += item;
        this.Sum2 += (item * item);
    }
}

如果我在计算StandardDeviation 时在同一对象上lock,我认为最终会解决问题。它没有。该值仍以 NaN 的形式出现,只是转瞬即逝,很少发生。

坦率地说,即使上述解决方案 奏效了,它也很混乱,对我来说似乎不太容易管理。 是否有标准和/或更直接的方法可以在计算值中实现线程安全?


编辑:原来这里有一个问题示例,起初似乎只有一种可能的解释,但毕竟问题完全出在其他问题上。

我一直一丝不苟地以各种可能的方式实现线程安全,并且尽可能不牺牲巨大的性能——锁定对共享值(例如,SumCount)的读取和写入,缓存值在本地,并使用相同的锁对象来修改集合和更新共享值......老实说,这一切似乎都是多余的。

没有任何效果;那个邪恶的NaN 不断出现。所以我决定在StandardDeviation 返回NaN 时将集合中的所有值打印到控制台...

我立刻注意到,当集合中的所有值都相同时,它似乎总是发生

这是官方的:我被浮点运算烧坏了。 (所有的值都是相同的,所以StandardDeviation 中的radicand - 即被取平方根的数字 - 被评估为一个非常小的负数。)

【问题讨论】:

    标签: c# .net multithreading thread-safety


    【解决方案1】:

    我可以锁定代码中更新这些值的所有点:

    protected void ItemAdded(double item) {
        // ...
    
        lock (this.CalculationLock) {
            this.Sum += item;
            this.Sum2 += (item * item);
        }
    }
    

    然后,如果我在计算 StandardDeviation 时锁定同一个对象,我认为这最终会解决问题。它没有。该值仍然以 NaN 的形式出现,只是转瞬即逝。

    正是你应该做的正确性。如果这对您不起作用,我建议您要么错过更新场景 - 要么您有其他问题(例如 Sum 或 Sum2 偶尔为 NaN 或由于某些other而出现意外值竞争条件)。

    【讨论】:

    • 你是对的,马克。现有的线程安全措施实际上工作正常;是浮点算术让我很头疼。谢谢你的加固。
    • 旧线程,但对于新来者,请考虑使用 ReaderWriterLockSlim,以便多个线程当前可以读取属性值而不会相互阻塞。
    【解决方案2】:

    Sum 和 Count 是跨多个线程共享的状态。因此,所有访问都必须同步,除非您 (a) 有一些原子变量原语并且 (b) 非常非常小心。

    最简单和最“标准”的方法是使用与同步插入和删除相同的锁。添加或删除时,更新 Sum 和 Count。然后使用相同的锁来同步访问 StandardDeviation 函数中的 Sum 和 Count。

    【讨论】:

      【解决方案3】:

      您可以缓存整个列表,然后使用缓存的版本进行操作。像这样的:

      var copy = currentList.ToArray();
      var sum = Sum(copy)
      var sum2 = Sum2(copy)
      return sum * sum2... whatever
      

      这样,您只需要在执行复制(示例中为.ToArray)时持有锁,并且您有一组一致的数据可供操作。当然,根据数据的大小,内存需求或性能损失可能会很大。

      【讨论】:

      • 我很欣赏这个建议,但我没有提到的是这个集合旨在尽可能快。制作列表的副本然后从该副本计算这些值将比执行直接计算(例如我的问题中的计算)慢几个数量级。
      【解决方案4】:

      如果,正如您在 cmets 中提到的,性能是这里的高优先级,您应该考虑将所有对对象底层数据的访问与ReaderWriterLockSlim(或旧的ReaderWriterLock,如果您使用的是 2.0框架)而不是Monitor(通过lock 语句),这样计算就不会相互阻塞。

      您还应该考虑测试一个缓存数据的实现,正如一些人建议的那样:它实际上可能比同步访问变量的版本更快。

      【讨论】:

        【解决方案5】:

        您可以在 StandardDeviation 方法中添加一个锁,以确保值不会更改。

        public double StandardDeviation
        {
          get
          {
            lock (_lockObject)
            {
               return Math.Sqrt((Sum2 - ((Sum * Sum) / Count)) / Count);
            }
          }
        }
        

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 2020-09-26
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多