channelread0会被调用两次_值得一看:C#同步方法中如何调用异步方法?

41ad98f44f561dc2584c932664f18d01.png

前言

我在写代码的时候(.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(),咱们来运行一遍。

15d520bb275a17c54dfb9073d1b49f67.png

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

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。 接下来我们再执行下,看看结果:

8bd26a6fb3101a6cdfb00b804291817b.png

没有CPU消耗,但是线程数一直增加,直到突破一台电脑的最大线程数,导致服务器宕机。 这明显出现问题了,线程肯定发生了死锁,而且还在不断产生新的线程。

至于为什么只执行了两次Task,我们可以猜测是因为程序中初始的TreadPool 中只有两个线程,所以执行了两次Task,然后就发生了死锁。

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

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

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

d8716d6d22a829179e314e979ac55a1c.png

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

044e7214cd067093c7da14b4e55b9878.png

线程数一直稳定在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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值