C#.net多线程

15

我正在使用C#.net在Rhino3D的一个名为Grasshopper的软件包中,尝试优化一些数学运算。该操作非常简单,但是需要执行该操作的列表很大,并且可能会变得更大。

我在我的C#脚本中使用Parallel.ForEach和列表,但是我得到的最终结果数量比预期的要少。这很可能是因为list.add不是线程安全的(或者说它不是在我正在构建它的软件中线程安全的)。

  private void RunScript(double z, int x, List<double> y, ref object A)
  {
    List<double> temp = new List<double>();
    double r;
    System.Threading.Tasks.Parallel.ForEach(y, numb =>
      {
      r = Math.Pow((numb * x), z);
      temp.Add(r);
      });
    A = temp;

请帮助我找到一种简单高效的方法,使用CPU多线程(或者如果您有关于GPU CUDA的建议)在几百个值上运行这个简单的数学运算。

我希望这个晦涩而具体的软件不会打扰你,因为据我所知,它与普通的C#.Net/Python/VB.Net表现相同。


是的,很可能List.Add不是线程安全的,并且可能会导致列表内部出现问题。另一个问题是您在执行的线程之间共享了本地变量r,而没有进行任何同步。本地变量定义应该在执行块内部,或者更好的方法是将其直接嵌入到List.Add方法调用中。 - Zoltán Tamási
你可以尝试使用ConcurrentBag代替List,它是线程安全的:MSDN - Thaoden
只是一个建议:尝试编写更具体的问题标题。问题的内容很好且完全有效,但标题也很重要。 - chris
5个回答

15

你的猜测是正确的,List<T> 不是线程安全的。你必须对其任何实例进行同步访问。

一种选择是在每个任务中简单地进行同步:

private void RunScript(double z, int x, List<double> y, ref object A)
{
    List<double> temp = new List<double>();
    object l = new object();
    System.Threading.Tasks.Parallel.ForEach(y, numb =>
    {
      double r = Math.Pow((numb * x), z);
      lock (l) temp.Add(r);
    });
    A = temp;
}

注意:你的代码还有另外一个错误。你在所有任务中共享了相同的r变量,这可能导致相同的值被添加两次或更多次到结果中,而其他值却被留下。我通过将变量声明移动到用于ForEach()调用的匿名方法的主体中来简单地修复了该错误。


另一种选择是认识到你事先知道将会有多少结果,因此可以简单地初始化一个足以包含所有结果的数组:

private void RunScript(double z, int x, List<double> y, ref object A)
{
    double[] results = new double[y.Count];
    System.Threading.Tasks.Parallel.For(0, y.Count, i =>
    {
      // read-only access of `y` is thread-safe:
      results[i] = Math.Pow((y[i] * x), z);
    });
    A = new List<double>(results);
}

没有两个线程会尝试访问results数组中的相同元素,而且这个数组本身也永远不会改变(即不会重新分配内存),因此它是完全线程安全的。

以上假设您确实需要一个List<double>作为输出对象。当然,如果数组足够满意,那么您可以将results分配给A,而不是将其传递给List<T>构造函数以在最后创建一个全新的对象。


循环体周围的“lock”完全破坏了“Parallel.ForEach”的目的。 - Lucas Trzesniewski
@LucasTrzesniewski:是的,你说得对...我希望早点有人注意到这一点。当我去掉r并将计算移到锁内(之前没有)时,它就变成了这样,而我没有仔细考虑。感谢你指出这一点。我会注意到即使做得“正确”,同步仍然是有害的,因此有第二个例子。任何“自由线程”实现都可以轻松击败同步实现,而这种“简单”的方法是最差的。 - Peter Duniho

7

一个更简单的解决方案可能是使用 .AsParallel() 并在生成的 ParallelEnumerable 上进行操作:

private void RunScript(double z, int x, List<double> y, ref object A)
{
    A = y
        .AsParallel().AsOrdered()
        .Select(elem => Math.Pow((elem * x), z))
        .ToList();
}

1
如果您删掉“.AsOrdered()”,计算速度会慢3到4倍,这很有趣。 - Enigmativity
@Enigmativity,你也不能保证A中的项与y中的项对应相同的索引,默认情况下,AsParallel()不需要保持输入顺序相同。 - Scott Chamberlain
2
@ScottChamberlain - 是的,我知道这一点,但我觉得有趣的是,如果我们确实想要排序,那么计算速度会快3到4倍。这对我来说似乎是违反直觉的。 - Enigmativity

2

这里是另一种选项:

    private void RunScript(double z, int x, List<double> y, ref object A) {
        var temp = new System.Collections.Concurrent.BlockingCollection<double>();
        System.Threading.Tasks.Parallel.ForEach(y, numb => {
            double r = Math.Pow((numb * x), z);
            temp.Add(r);
        });
        A = temp; // if needed you can A = temp.ToList();
        }

Peter在概述您的代码问题方面做得很好,我认为他建议的第二个函数可能是您最好的选择。不过,看到其他选择并学习到.NET框架包含并发安全的集合也是不错的。


0

我也在考虑稍微改变输入。将数据分割成独立的分支,使用不同的线程计算每个分支,然后在最后重新组合它们。然而,这种方法得分最差,需要531毫秒。 我明白这个脚本不好,但我认为它很好地展示了我的想法,如果正确编写可能会取得成功。对吧?

  private void RunScript(double z, int x, List<double> y, DataTree<double> u, ref object A)
  {
    System.Threading.Tasks.Task<double[]> th1 = System.Threading.Tasks.Task<double[]>.Factory.StartNew(() => mP(u.Branch(0).ToArray(), x, z));
    System.Threading.Tasks.Task<double[]> th2 = System.Threading.Tasks.Task<double[]>.Factory.StartNew(() => mP(u.Branch(1).ToArray(), x, z));
    System.Threading.Tasks.Task<double[]> th3 = System.Threading.Tasks.Task<double[]>.Factory.StartNew(() => mP(u.Branch(2).ToArray(), x, z));
    System.Threading.Tasks.Task<double[]> th4 = System.Threading.Tasks.Task<double[]>.Factory.StartNew(() => mP(u.Branch(3).ToArray(), x, z));
    System.Threading.Tasks.Task<double[]> th5 = System.Threading.Tasks.Task<double[]>.Factory.StartNew(() => mP(u.Branch(4).ToArray(), x, z));
    System.Threading.Tasks.Task<double[]> th6 = System.Threading.Tasks.Task<double[]>.Factory.StartNew(() => mP(u.Branch(5).ToArray(), x, z));
    System.Threading.Tasks.Task<double[]> th7 = System.Threading.Tasks.Task<double[]>.Factory.StartNew(() => mP(u.Branch(6).ToArray(), x, z));
    System.Threading.Tasks.Task<double[]> th8 = System.Threading.Tasks.Task<double[]>.Factory.StartNew(() => mP(u.Branch(7).ToArray(), x, z));

    List<double> list = new List<double>();

    list.AddRange(th1.Result);
    list.AddRange(th2.Result);
    list.AddRange(th3.Result);
    list.AddRange(th4.Result);
    list.AddRange(th5.Result);
    list.AddRange(th6.Result);
    list.AddRange(th7.Result);
    list.AddRange(th8.Result);


    A = list;


  }

抱歉,我无法向“using”添加内容


这里的问题部分在于计算本身非常简单,任何试图微观管理并发的尝试,特别是如果您必须创建中间对象,_尤其是_如果您必须实际上复制数据,都会倾向于主导计算成本。而且,由于这种微观管理大致与并发程度成比例,它将强烈抵消任何并发的好处。请记住:我的_最差_情况,单线程,0并发解决方案仅为300毫秒,而最佳情况(到目前为止)为120毫秒。 - Peter Duniho

0
非常感谢您的输入!如果您对分析器输出感兴趣,如下所示:
Peter Duniho 第一种选择:330毫秒
Peter Duniho 第二种选择:207毫秒
Dweeberly 选择:335毫秒
Mattias Buelens 选择:376毫秒
这很奇怪,因为 .net 脚本在草图中应该运行得更快(因为它是 .net),但是您的解决方案都没有打败 Python 并行计算的 129 毫秒!
无论如何,感谢您们提供详细的答案!你们太棒了!

很难进行苹果对苹果的比较。然而,其中最大的因素之一是,在你所描述的问题中,并行化并不能带来太多好处。我不知道你的确切输入是什么,但我创建了一个场景,完成时间约为200毫秒。我发现算法成本的约25%是垃圾回收(切换到一个中间分配为0的版本可以将时间提高25%)。在我的2核(超线程)CPU上单线程运行只增加了50%的时间。Python的数学计算可能没有.NET等精确。 - Peter Duniho
说实话,我并不太关心精度......只要在0.001公差范围内就可以了。这是一个草图脚本,所以其中的数学也很粗略。如果必须用一句话来概括,最快的方法是什么,可以将大量值相乘和求幂(假设超过20k个值)。到目前为止,Python选项显示出2-3倍的C#选项改进,而且由于Python在这种环境下本质上应该更慢,我仍然认为有一种更优化的方法可以使用C#完成。 - Dimitar Baldzhiev
1
第一步是确保你进行了苹果对苹果的比较。我在各种实现上运行了一堆不同的测试。测试中最大的因素之一是垃圾回收的管理方式以及运行时间的测量,这两者都与真实世界的情况没有多少关系。.NET和Python具有相当不同的执行环境,因此进行苹果对苹果的测试更加困难。 - Peter Duniho
还要注意:在进行单线程比较 .NET 和 Python 的数学计算性能差异之前,对不同多线程实现的比较特别没有用。 - Peter Duniho
2
“晦涩的库”与手头的问题有什么关系?C#和IronPython之间唯一相关的比较应该是_只_执行此处计算的程序。不应该涉及任何“晦涩的库”,也不应该涉及任何其他代码。这就是我的意思:唯一有效的性能比较是隔离您实际想要测试的计算部分的比较。其他代码很容易干扰结果。 - Peter Duniho
显示剩余6条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接