关于同步方法里面调用异步方法的探究

前言

我在写代码的时候(.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,所以产生了死锁。

原文出处:博客园【Shendu.CC 】

原文链接:https://www.cnblogs.com/dacc123/p/12796578.html

本文观点不代表Dotnet9立场,转载请联系原作者。

发表评论

登录后才能评论