为什么静态构造函数中的异步方法会导致死锁?

Mad*_*din 1 .net c# deadlock nlog async-await

这是我正在研究的真实代码的最小(和简化)重现,因为它导致了死锁。有OuterClass一些嵌套类,它们通过外部类的初始化进行初始化(并加载一些数据)。重要的是,一旦对象初始化,数据就可用。

public class OuterClass
{   
    static ILogger Log = LogManager.GetLogger("OuterClass");

    private static InnerClass _innerClass;
    public static OuterClass Instance { get; private set; }

    private OuterClass()
    {
        Log.Debug("Constructor called");
        _innerClass = new InnerClass();
    }

    static OuterClass()
    {
        Instance = new OuterClass();
    }

    public class InnerClass
    {
        // using it's own logger, it will not dead lock
        //static ILogger Log = LogManager.GetLogger("InnerClass");

        private String _data;
        public InnerClass()
        {
            LoadOrUpdateDataAsync().Wait(); // will make the second Log.Debug call dead lock
            //_ = LoadOrUpdateDataAsync(); // will work
        }

        public async Task LoadOrUpdateDataAsync()
        {
            Log.Debug("This works");

            _data = await DataClass.GetDataAsync("https://someusefulapi.com/api/xyz");

            Log.Debug("If you see this, it did not block"); // this line blocks
        }
    }
}

public static class DataClass
{
    static ILogger Log = LogManager.GetLogger("DataClass");
    public static async Task<string> GetDataAsync(string uri)
    {
        // just example code, HttpClient should not be in a using block
        using (var client = new HttpClient())
        {
            var data = await client.GetStringAsync(uri);
            Log.Debug("Data loaded from {0}", uri);
            return data;
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

上述代码的控制台输出:

Time         Logger  Thread-Id   Message
16:47:39 OuterClass          1   Constructor called
16:47:39 OuterClass          1   This works
16:47:40  DataClass          7   Data loaded from https://someusefulapi.com/api/xyz
Run Code Online (Sandbox Code Playgroud)

第二个日志Log.Debug("If you see this...");块 - 应用程序挂起。我猜测它此时挂起,因为调用LoadOrUpdateDataAsync().Wait();正在阻塞和Logger共享的。由不同的线程(不同的线程id)执行。因此,当等待完成时,该方法将在“新”线程上恢复,但 Logger 被主线程阻塞。或者代码是否真的可以工作,这意味着这是一个 NLog 问题?OuterClassInnerClassawait DataClass.GetDataAsync(...)

NLog 配置非常简约。我尝试了 NLog 4.7.15 和 5.0.4 版本 - 它们的行为相同。选择async="true",也没有什么区别。它是.Net Framework 4.7.2。

<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">

    <targets async="true">
        <target name="console" xsi:type="ColoredConsole" layout="${date:format=HH\:mm\:ss} ${pad:padCharacter= :padding=10:fixedLength=True:inner=${logger}} ${mdlc:item=ProcessDescriptorId}   ${threadid}   ${message} ${exception:format=tostring} ${exception:format=stacktrace}" />
    </targets>
    <rules>
        <logger name="*" minlevel="Debug" writeTo="console" />
    </rules>
</nlog>
Run Code Online (Sandbox Code Playgroud)

该代码实际上仅在LoadOrUpdateDataAsync()从构造函数调用时挂起。对函数进行调用Init()并用 调用它OuterClass.Instance.Init();不会阻塞。

另外,使用丢弃运算符_ = LoadOrUpdateDataAsync();也可以。但这不是一个选择,因为主线程将继续,而没有信心数据确实已加载。

控制台输出_ = LoadOrUpdateDataAsync();

Time         Logger    Thread-Id   Message
16:49:54 OuterClass            1   Constructor called
16:49:54 OuterClass            1   This works
16:49:55  DataClass            9   Data loaded from https://someusefulapi.com/api/xyz
16:49:55 OuterClass            9   If you see this, it did not block
Run Code Online (Sandbox Code Playgroud)

还可以在中使用自己的记录器InnerClass(通过调用.Wait())。

控制台输出带有自己的记录器 inInnerClassLoadOrUpdateDataAsync().Wait();

Time         Logger    Thread-Id   Message
16:52:51 OuterClass            1   Constructor called
16:52:51 InnerClass            1   This works
16:52:52  DataClass            8   Data loaded from https://someusefulapi.com/api/xyz
16:52:52 InnerClass            8   If you see this, it did not block
Run Code Online (Sandbox Code Playgroud)

从最后两个示例可以清楚地看出,第二个示例Log.Debug()与第一个示例运行在不同的线程上。

我知道从构造函数调用异步函数不是最理想的,并且有更好的方法可以做到这一点 - 例如使用 Stephen Cleary 在本博客中描述 的工厂模式

我主要感兴趣的是为什么这段代码会阻塞,并希望了解这里的技术原理。我的假设是正确的还是我走错了路?

其次,上面提到的工厂模式是解决它的“正确”方法,还是有更好的方法?

Gur*_*ron 5

我无法查明导致所描述行为的确切代码,因为这将需要深入 CLR 实现的兔子洞(使用dotnet-dump analyze似乎会导致后续调用中的非托管转换_data = await ...),但这种行为的原因并不是NLog 本身,但运行时保证静态构造函数仅执行一次,并且在您的代码中基本上会发生以下情况:

  1. OuterClassstatic ctor 开始调用
  2. InnerClass演员被称为
  3. InnerClassctor 阻塞LoadOrUpdateDataAsync(并阻塞处理该 ctor 的线程)
  4. LoadOrUpdateDataAsync等待IO完成
  5. IO 完成后尝试从另一个线程LoadOrUpdateDataAsync访问某些内容,但静态构造尚未完成,因此它会阻塞,直到构造完成,从而导致死锁,因为 static ctor 会等待.OuterClassOuterClassLoadOrUpdateDataAsync

重现可以简化为:

public class OuterClass
{   
    // ...

    public static void DoSomething()
    {
        Console.WriteLine("Here Outer");
    }

    public class InnerClass
    {
        private String _data;
        public InnerClass()
        {
            LoadOrUpdateDataAsync().GetAwaiter().GetResult();//.Wait();
        }

        public async Task LoadOrUpdateDataAsync()
        {
            _data = await DataClass.GetDataAsync("https://api.coindesk.com/v1/bpi/currentprice.json").ConfigureAwait(false);
            Console.WriteLine("Here Inner"); // works
            OuterClass.DoSomething(); // blocks
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

这实际上在文档中进行了描述:

运行时在单个应用程序域中调用静态构造函数不超过一次。该调用是根据类的特定类型在锁定区域中进行的。静态构造函数的主体中不需要额外的锁定机制。为了避免死锁的风险,不要在静态构造函数和初始化程序中阻塞当前线程。例如,不要等待任务、线程、等待句柄或事件,不要获取锁,也不要执行阻塞并行操作(例如并行循环和Parallel.Invoke并行 LINQ 查询)。