【问题标题】:Apparent deadlock using Console and PLinq使用控制台和 Plinq 的明显死锁
【发布时间】:2015-11-18 20:27:31
【问题描述】:

以下代码运行没有问题:

// This code outputs:
// 3
// 2
// 1
//
// foo
// DotNetFiddle: https://dotnetfiddle.net/wDRD9L
public class Program
{   
    public static void Main() 
    {
        Console.WriteLine("foo");
    }

    static Program() 
    {       
        var sb = new System.Text.StringBuilder();
        var list = new List<int>() { 1,2,3 };
        list.AsParallel().WithDegreeOfParallelism(4).ForAll(item => { sb.AppendLine(item.ToString()); });
        Console.WriteLine(sb.ToString());
    }
}

只要我将 sb.AppendLine 替换为对 Console.WriteLine 的调用,代码就会挂起,就像某处出现死锁一样。

// This code hangs.
// DotNetFiddle: https://dotnetfiddle.net/pbhNR2
public class Program
{   
    public static void Main() 
    {
        Console.WriteLine("foo");
    }

    static Program() 
    {       
        var list = new List<int>() { 1,2,3 };
        list.AsParallel().WithDegreeOfParallelism(4).ForAll(item => { Console.WriteLine(item.ToString()); });
    }
}

起初我怀疑Console.WriteLine 不是线程安全的,但根据文档,它是线程安全的。

这种行为的解释是什么?

【问题讨论】:

  • 这可能与此有关:stackoverflow.com/questions/15143931/… - 您在后台线程中调用 Console.WriteLine() 之前它被正确初始化。
  • 你很幸运sb.AppendLine,这个方法不是线程安全的,如果你有更多更长的字符串,你的字符串会是错误的,甚至有些会丢失。
  • 对我来说,即使没有Console.WriteLineProgram 的静态构造函数中,它也会锁定。在 cctor 中调用 ForAll(带有空的主体)会触发挂起。 (针对 4.5.2,VS 2013)
  • 我只是在这里猜测,但我认为Program的静态构造函数在调用并行循环并且底层代码进入等待状态等待静态构造函数完成初始化时类型(从线程开始的地方)。如果将逻辑移到单独的静态函数中,它不会挂起。
  • 可能重复这个:“stackoverflow.com/questions/5770478/…” Console.WriteLine 与它无关。

标签: c# .net parallel.foreach plinq


【解决方案1】:

简短的版本:永远不要在构造函数中阻塞,尤其是在 static 构造函数中。

在您的示例中,差异与您使用的匿名方法有关。在第一种情况下,您捕获了一个局部变量,该变量导致匿名方法被编译到它自己的类中。但在第二种情况下,没有变量捕获,因此 static 方法就足够了。除了静态方法放在Program 类中。仍在初始化中。

因此,对匿名方法的调用被类的初始化阻塞(您不能从执行静态构造函数的线程以外的线程执行类中的方法,直到该类完成初始化),并且类的初始化被匿名方法的执行所阻塞(ForAll() 方法在所有这些方法都执行之前不会返回)。

死锁。


很难知道一个好的变通方案可能是什么,因为这个例子(正如预期的那样)是你真正正在做的任何事情的简化版本。但最重要的是,您不应该在静态构造函数中进行长时间运行的计算。如果它是一个足够慢的算法来证明使用ForAll() 是合理的,那么它就足够慢以至于它实际上不应该首先成为类初始化的一部分。

在解决该问题的许多可能选项中,您可能会选择 Lazy&lt;T&gt; 类,它可以很容易地将一些初始化推迟到实际需要时进行。

例如,假设您的并行代码不仅仅是写出列表中的元素,而是实际上以某种方式处理它们。 IE。它是列表实际初始化的一部分。然后,您可以将该初始化包装在由Lazy&lt;T&gt; 按需执行的工厂方法中,而不是在静态构造函数中:

public class Program
{   
    public static void Main() 
    {
        Console.WriteLine("foo");
    }

    private static readonly Lazy<List<int>> _list = new Lazy<List<int>>(() => InitList());

    private static List<int> InitList()
    {
        var list = new List<int>() { 1,2,3 };
        list.AsParallel().WithDegreeOfParallelism(4).ForAll(item => { Console.WriteLine(item.ToString()); });

        return list;
    }
}

然后初始化代码甚至不会被执行,直到一些代码需要访问列表,它可以通过_list.Value来完成。


这有点微妙的不同,我觉得它需要一个新的答案(即匿名方法的使用会改变行为),但在 Stack Overflow 上至少还有两个非常密切相关的问题和答案:
@987654321 @
Task.Run in Static Initializer


顺便说一句:我最近了解到,使用新的 Roslyn 编译器,他们已经改变了在这种情况下实现匿名方法的方式,甚至那些可能是静态方法的方法在单独的类中也变成了实例方法(如果我没记错的话)。我不知道这是否是为了减少这种错误的普遍性,但它肯定会改变行为(并且会消除匿名方法作为死锁的来源......当然人们仍然可以用对显式声明的静态命名方法的调用)。

【讨论】:

  • 谢谢,这解释得很好。仍然很高兴指出静态方法调用中的阻塞仅发生在不同线程之间。从静态构造函数运行的同一线程中调用的静态方法可以正常调用。也许不是一个好习惯。
  • " 静态方法调用中的阻塞只发生在不同线程之间" -- 是的,这是正确的。 “也许不是一个好的做法”——不,我认为这很好,只要不违反静态构造函数的其他一些好的做法。 IE。假设初始化适用于静态构造函数但足够冗长以至于将其分解为一个或多个方法可以改进代码,那么这样做是的事情,而不是要避免的事情。我将编辑问题以阐明有关跨线程调用的要点。
猜你喜欢
  • 2020-04-03
  • 2019-03-15
  • 2012-01-25
  • 2021-12-14
  • 2018-08-22
  • 1970-01-01
  • 1970-01-01
  • 2019-03-19
  • 1970-01-01
相关资源
最近更新 更多