• 关于同步方法里面调用异步方法引起死锁【转】


    前言

    我在写代码的时候(.net core)有时候会碰到void方法里,调用async方法并且Wait,而且我还看到别人这么写了。而且我这么写的时候,编译器没有提示任何警告。但是看了dudu的文章:一码阻塞,万码等待:ASP.NET Core 同步方法调用异步方法“死锁”的真相 了解了,这样写是有问题的。但是为什么会有问题呢?我又阅读了dudu文章里提到的一篇博文:.NET Threadpool starvation, and how queuing makes it worse 加上自己亲手实验,写下自己的理解,算是对dudu博文的一个补充和丰富吧。

    同步方法里调用异步方法

    同步方法里调用异步方法,一种是wait() 一种是不wait()

    void fun()
    {  
        funAsync.Wait();
        funAsync();
    }
    

    这两种场景都没有编译错误。
    首先我们来看一下,在 void里调用 async 方法,并且要等待async的结果出来之后,才能进行后续的操作。

    using System;
    using System.Threading;
    using System.Threading.Tasks;
    
    namespace ConsoleTool2
    {
        class Program
        {
            static void Main(string[] args)
            {
                Producer();
            }
    
            static void Producer()
            {
                var result = Process().Result;
                //或者
                //Process().Wait();
            }
    
            static async Task<bool> Process()
            {
                await Task.Run(() =>
                {
                    Thread.Sleep(1000);
                });
    
                Console.WriteLine("Ended - " + DateTime.Now.ToLongTimeString());
                return true;
            }
        }
    }
    
    

    咱们看这个Producer,这是一个void方法,里面调用了异步方法Process(),其中Process()是一个执行1秒的异步方法,调用的方式是Process().Result 或者Process().Wait()。咱们来运行一遍。

    没有任何问题。看起来,这样写完全没有问题啊,不报错,运行也是正常的。
    接下来,我们修改一下代码,让代码更加接近生产环境的状态。

    using System;
    using System.Threading;
    using System.Threading.Tasks;
    
    namespace ConsoleTool2
    {
        class Program
        {
            static void Main(string[] args)
            {
                while (true)
                {
                    Task.Run(Producer);
                    Thread.Sleep(200);
                }
            }
    
            static void Producer()
            {
                var result = Process().Result;
            }
    
            static async Task<bool> Process()
            {
                await Task.Run(() =>
                {
                    Thread.Sleep(1000);
                });
    
                Console.WriteLine("Ended - " + DateTime.Now.ToLongTimeString());
                return true;
            }
        }
    }
    
    

    我们在Main函数里加了for循环,并且1秒钟执行5次Producer(),使用Task.Run(),1秒钟有5个Task产生。相当于生产环境的qps=5。
    接下来我们再执行下,看看结果:

    在第一秒里只执行了两次Task,就卡住了。我们再看下进程信息:

    没有CPU消耗,但是线程数一直增加,直到突破一台电脑的最大线程数,导致服务器宕机。
    这明显出现问题了,线程肯定发生了死锁,而且还在不断产生新的线程。
    至于为什么只执行了两次Task,我们可以猜测是因为程序中初始的TreadPool 中只有两个线程,所以执行了两次Task,然后就发生了死锁。

    现在我们定义一个Produce2() 这是一个正常的方法,异步函数调用异步函数。

     static async Task Producer2()
            {
                await Process();
            }
    

    我们再Main函数的循环里,执行Producer2() ,执行信息如下:

    仔细观察这个图,我们发现第一秒执行了一个Task,第二秒执行了三个Task,从第三秒开始,就稳定执行了4-5次Task,这里的时间统计不是很精确,但是可以肯定从某个时间开始,程序达到了预期效果,TreadPool中的线程每秒中都能稳定的完成任务。而且我们还能观察到,在最开始,程序是反应很慢的,那个时候线程不够用,同时应该在申请新的线程,直到后来线程足够处理这样的情况了。咱们再看看这个时候的进程信息:

    线程数一直稳定在25个,也就是说25个线程就能满足这个程序的运行了。
    到此我们可以证明,在同步方法里调用异步方法确实是不安全的,尤其在并发量很高的情况下。

    探究原因

    我们再深层次讨论下为什么同步方法里调用异步方法会卡死,而异步方法调用异步方法则很安全呢?

    咱们回到一开始的代码里,我们加上一个初始化线程数量的代码,看看这样是否还是会出现卡死的状况。
    由于前面的分析我们知道,这个程序在一秒中并行执行5个Task,每个Task里面也就是Producer 都会执行一个Processer 异步方法,所以粗略估计需要10个线程。于是我们就初始化线程数为10个。

    using System;
    using System.Threading;
    using System.Threading.Tasks;
    
    namespace ConsoleTool2
    {
        class Program
        {
            static void Main(string[] args)
            {
                ThreadPool.SetMinThreads(10, 10);
               
                while (true)
                {
                    Task.Run(Producer2);
                    Thread.Sleep(200);
                }
            }
    
            static void Producer()
            {
                var result = Process().Result;
            }
    
            static async Task Producer2()
            {
                await Process();
            }
    
            static async Task<bool> Process()
            {
                await Task.Run(() =>
                {
                    Thread.Sleep(1000);
                });
    
                Console.WriteLine("Ended - " + DateTime.Now.ToLongTimeString());
                return true;
            }
        }
    }
    
    

    运行一下发现,是没问题的。说明一开始设置多的线程是有用的,经过实验发现,只要初始线程小于10个,都会出现死锁。而.net core的默认初始线程是肯定小于10个的。

    那么当初始线程小于10个的时候,发生什么了?发生了大家都听说过的名词,线程饥饿。就是线程不够用了,这个时候ThreadPool生产新的线程满足需求。

    然后我们再关注下,同步方法里调用异步方法并且.Wait()的情况下会发生什么。

    void Producer()
    {
        Process().Wait()
    }
    

    首先有一个线程A ,开始执行Producer , 它执行到了Process 的时候,新产生了一个的线程 B 去执行这个Task。这个时候 A 会挂起,一直等 B 结束,B被释放,然后A继续执行剩下的过程。这样执行一次Producer 会用到两个线程,并且A 一直挂起,一直不工作,一直在等B。这个时候线程A 就会阻塞。

    Task Producer()
    {
       await Process();
    }
    

    这个和上面的区别就是,同时线程A,它执行到Producer的时候,产生了一个新的线程B执行 Process。但是 A 并没有等B,而是被ThreadPool拿来做别的事情,等B结束之后,ThreadPool 再拿一个线程出来执行剩下的部分。所以这个过程是没有线程阻塞的。

    再结合线程饥饿的情况,也就是ThreadPool 中发生了线程阻塞+线程饥饿,会发生什么呢?
    假设一开始只有8个线程,第一秒中会并行执行5个Task Producer, 5个线程被拿来执行这5个Task,然后这个5个线程(A)都在阻塞,并且ThreadPool 被要求再拿5个线程(B)去执行Process,但是线程池只剩下3个线程,所以ThreadPool 需要再产生2个线程来满足需求。但是ThreadPool 1秒钟最多生产2个线程,等这2个线程被生产出来以后,又过去了1秒,这个时候无情又进来5个Task,又需要10个线程了。别忘了执行第一波Task的一些线程应该释放了,释放多少个呢?应该是3个Task占有的线程,因为有2个在等TreadPool生产新线程嘛。所以释放了6个线程,5个Task,6个线程,计算一下,就可以知道,只有一个Task可以被完全执行,其他4个都因为没有新的线程执行Process而阻塞。
    于是ThreadPool 又要去产生4个新的线程去满足4个被阻塞的Task,花了2秒时间,终于生产完了。但是糟糕又来了10个Task,需要20个线程,而之前释放的线程已经不足以让任何一个Task去执行Process了,因为这些不足的线程都被分配到了Producer上,没有线程再可以去执行Process了(经过上面的分析一个Task需要2个线程A,B,并且A阻塞,直到B执行Process完成)。
    所以随着时间的流逝,要执行的Task越来越多却没有一个能执行结束,而线程也在不断产生,就产生了我们上面所说的情况。

    我们该怎么办?

    经过上面的分析我们知道,在线程饥饿的情况下,使用同步方法调用异步方法并且wait结果,是会出问题的,那么我们应该怎么办呢?
    首先当然是应该避免这种有风险的做法。

    其次,还有一种方法。经过实验,我发现,使用专有线程

    Task.Run(Producer);
    改成
    Task.Factory.StartNew(
              Producer,
              TaskCreationOptions.LongRunning
       );
    

    就是TaskCreationOptions.LongRunning 选项,就是开辟一个专用线程,而不是在ThreadPool中拿线程,这样是不会发生死锁的。
    因为ThreadPool 不管理专用线程,每一个Task进来,都会有专门的线程执行,而Process 则是由ThreadPool 中的线程执行,这样TheadPool中的线程其实是不存在阻塞的,因此也不存在死锁。

    结语

    关于ThreadPool 中的线程调用算法,其实很简单,每个线程都有一个自己的工作队列local queue,此外线程池中还有一个global queue全局工作队列,首先一个线程被创建出来后,先看看自己的工作队列有没有被分配task,如果没有的话,就去global queue找task,如果还没有的话,就去别的线程的工作队列找Task。

    第二种情况:在同步方法里调用异步方法,不wait()
    如果这个异步方法进入的是global Task 则在线程饥饿的情况下,也会发生死锁的情况。至于为什么,可以看那篇博文里的解释,因为global Task的优先级很高,所有新产生的线程都去执行global Task,而global task又需要一个线程去执行local task,所以产生了死锁。

    转:https://www.cnblogs.com/dacc123/p/12796578.html

  • 相关阅读:
    14-6 XShell连接远程服务器
    14-5 XManager工具安装
    14-4 本地私有化部署方案介绍
    14-3 阿里云域名购买及备案流程
    14-2 阿里云ECS服务器购买介绍
    13-7 Docker基本使用总结
    初识Redux-Saga
    React Native填坑之旅 -- FlatList
    React Native填坑之旅 -- 使用react-navigation代替Navigator
    React Native填坑之旅 -- 回归小插曲
  • 原文地址:https://www.cnblogs.com/fanfan-90/p/13023757.html
Copyright © 2020-2023  润新知