事因

最近公司线上消费队列出现消息堆积的现象,立马看了眼代码,是由调度任务开了十条线程进消费,当时就迷惑了,怎样的数据量大到十条线程都承受不起.
翻看了下相关消费操作的Log,发现每条消息消费需要1s时间,而每分钟只有120条Log数据,这意味着只有两条线程在消费,这跟配置的线程数量严重对不上,难怪按照设计预期的消息量都消费不过来

程序排查

最开始第一个怀疑的是控制线程的类有Bug,立刻进行了排查

线程的创建和管理方法大致如下


int maxCount = 10;
void Run(Action action)
{
    Task _currentTask = null;
    _currentTask = System.Threading.Tasks.Task.Run(() =>
    {
        action();
        lock (_obj)
            tasks.Remove(_currentTask);
    });

    lock (_obj)
        tasks.Add(_currentTask);

    if (tasks.Count < _maxCount)
        return;

    Tasks.Task.WaitAny(tasks.ToArray());
}

其配置了最多开启10条线程,看着没啥问题,跑起来,跟预期一致,十条线程,没问题
image.png

这就奇怪了,难不成线上的Docker或Linux环境做了限制?

然后将程序放到线上其中一条机器上跑了下,居然只有八条线程?!
image.png

有点不信邪,我将控制线程类的限制开到50条线程,在本地跑了一下,发现本地居然也存在限制了
这好点,可以直接本地测试,不用搞线上的测试服务器了

image.png

而这个限制的数量有点奇怪,线上是8,本地是12(我本地额外阻塞了一条线程)

看着数字跟核数相似,确认下本地和线上测试用的服务器,分别是12核和8核(出问题的服务器只有4)
可是,线程不是可以乱开的嘛?想开多少就多少,怎么存在这种限制了?

当时我想到之前用到一个ThreadPool.SetMinThreads可以设置线程池来加大并发,但那会没想着立刻尝试,而是去看下GPT会不会给些线程方面,我不知道的内容

查资料

image.png

意外也不意外,给了一个我类似想法的方向。

解决问题

那就直接用ThreadPool.SetMinThreads尝试,发现程序开启的时候,立刻就开了50条线程,达到maxCount的限制
image.png

那到这里,问题就解决了。

但,为什么呢?

不如,问一下神奇的GPT?
image.png
image.png

这里GPT提到了线程池和TaskCreationOptions.LongRunning,而刚才解决问题的,就是设置了线程池最小线程数量

那先从看下,默认情况下,线程池的设置是多少 作为开始点

ThreadPool.GetMinThreads(out workerThreads, out completionPortThreads);
Console.WriteLine($"Min:{workerThreads}-{completionPortThreads}");
// Min:12-12

数字很明细是核数,正是刚才被限制线程数的数量,那么问题很大就是因为线程池的原因了

然后细想一下,Task本来就是基于线程池封装的一个简易线程调用类
那么这里就有个问题,如果想突破线程池的限制,那怎么办?
而且线程池是进程全局的设置,单单为了一个地方就去设置,这种东西是大忌

那再来看看GPT提供的另一个办法TaskCreationOptions.LongRunning
将原来的Task.Run换成 new Task(action,TaskCreationOptions.LongRunning)
运行效果与使用了SetMinThreads一致,这里就不放图了

这东西,英文看着就懂,长任务
可是不也使用了Task吗?为什么这个就不受线程池的限制呢?
GPT:这些任务将不会占用线程池中的工作线程
嘶,我懂,但为啥呢? 那来问问神奇的Stack Overflow吧!

image.png

小总结

好了,对这段话来做一个理解总结
1.设计上,通过LongRunning开启线程时,为了满足开发者不同的需求,它会额外新建线程,而不是从线程池里面获取一个线程,就如同new Thread一样
(就是因为历史和环境原因,才出现后面多使用Task,而Task出现前使用Thread,导致有些人分不清这两者的区别)
2.什么时候用?长时间运行的任务使用,对于后端来说,IO就是经典常见的长时间任务,而的定义是相对于CPU的,我们都知道CPU很快,半秒能计算很多很多东西,所以对于计算机来说,半秒是很长的
3.知道了LongRunning如同new Thread一样,那么意味着是针对单个线程的,不会像SetMinThreads会影响到整个程序,非常适合像我这种特别怂的程序员

总结

好了,到这里,感觉可以做个整体的总结了

1.Task是基于线程池封装的一个类,因此一般情况下,他会受线程池数量的约束,导致实际使用上,运行的线程可能会比我们预期的少
2.在使用Task的时候,可以使用TaskCreationOptions.LongRunning来避免新建线程会受到线程池的限制(是否使用该配置还可能涉及到线程池启动新线程得延迟情况 线程池)
将所有线程池线程分配给任务后,线程池不会立即开始创建新的空闲线程。为了避免不必要地为线程分配堆栈空间,它会每隔一段时间创建新的空闲线程。该间隔目前为半秒

3.使用ThreadPool.SetMinThreads可以直接设置进程最小就绪线程数,因为一般情况下,线程池若初始化为8,你新建了100个Task,线程池会逐渐新建新的线程,直至达到MaxThreads(默认32767).那么使用SetMinThreads可以解决开始的时候,线程数太少的问题(主要是避开线程池新线程启动得延迟)

那么,额外的一问,如果我必须设置ThreadPool.SetMinThreads,值设置太高,会有什么负面的影响吗?
有,但大多情况下,影响不大.线程建多了,大多情况下,都是占用较多内存和CPU资源
可是现在的服务器大部分都不缺这点资源,反而是业务方面更重要
所以比起线程少了影响到业务,更多的硬件资源占用根本不值得一提

额外的猜想?

线程池的不够用,表明了存在线程阻塞.
测试程序在消费的时候我使用了Sleep进行模拟阻塞,所以Task一多,它会就大量占用线程池资源

那,有没一种可能,我可以使用异步?避免阻塞引起的线程池资源耗尽问题?
Sleep改为await Task.Delay
image.png

效果与预想的一样,执行消费数量达到了50(maxCount限制),其实就是因为异步的时候,会释放线程到线程池,从而避免线程池耗尽,一直都有线程可用
效果中可以看到,好几条消息都是使用了同一条线程处理的

从点可以看到,对于线程阻塞,异步真的能带来非常好的线程效果,特别是并发高和IO瓶颈的场景下,尤为重要.

比如WebApi不少场景就需要大量的线程,若将整个接口的内容都转换为异步,那从线程池方面来说,就能大大提高站点的并大瓶颈

Q.E.D.


随意游世