Orleans:构建高性能分布式Actor服务
上QQ阅读APP看书,第一时间看更新

3.2.1 .NET任务调度器与内置线程池

.NET Core运行时提供了一个使用.NET内置线程池(即ThreadPool类所代表的线程池对象)进行任务执行的默认的任务调度器,在默认任务调度器中.NET Core运行时提供了基于任务窃取(Work-Stealing)的负载均衡调度算法,并通过监控整体线程池的任务执行吞吐量将工作线程加入/移出运行时线程池以优化系统的全局性能。在.NET内置线程池中,通过任务对象提供的逻辑上下文支持业务逻辑中常见的需要并行执行的查询和运算任务(通常是执行期较短的业务逻辑),如图3-4所示。

•图3-3 .NET运行时中的任务对象、任务调度器与线程

•图3-4 .NET运行时的任务调度过程

1. 全局任务队列与本地任务队列

.NET Core运行时线程池在每一个应用程序域(Application Domain)中维护了一个先进先出(FIFO)的全局任务队列,每当应用程序调用ThreadPool.QueueUserWorkItem(或ThreadPool.UnsafeQueueUserWorkItem)方法时,任务对象将被放置于此全局任务队列的队尾,并最终被下一个空闲线程选出队以执行响应的任务逻辑。从.NET Framework 4之后的.NET运行时将一种已经在ConcurrentQueue<T>类中运用的无锁算法应用于全局任务队列,因此运行时线程池可以更高效地从全局任务队列中实现任务对象入队/出队,从而提高所有依赖于运行时线程池的应用程序效率。

顶层的任务对象(即不是从某一任务对象执行过程/上下文中产生的任务对象)也和其他任务对象一样将在全局任务队列中依次排序等待调度执行,但那些在工作线程执行某一任务过程中所产生的子任务或嵌套任务对象将被区别对待。在工作线程处理任务对象时,将把该任务对象所产生的子任务和嵌套任务按照后进先出(LIFO)的顺序放入工作线程本地的私有任务队列中依次执行。以后进先出的方式对子任务和嵌套任务进行排序是为了提高缓存的命中率。例如,工作线程按照任务逻辑依次创建了A、B、C三个嵌套任务,则在当前任务处理完成后,在本地缓存未被强制刷新的情况下,任务C相关的变量存在于缓存中的概率最高。因此,工作线程遵循子任务和嵌套任务后进先出的顺序排列于本地任务队列中执行,本地任务队列从该工作线程的角度看来即类似于本地任务栈。

使用本地任务队列的优势并不仅限于减轻全局任务队列在出队/入队时的计算压力,还高效地利用了线程本地缓存数据。本地任务队列中的任务对象所引用的对象通常都存储于相邻的物理内存中,在这些情况下某些后续任务所需的数据已经被缓存于线程本地,因此可以加速数据的存取速度。.NET运行时自带的Parallel LINQ(PLINQ)和Parallel类在实现过程中都广泛地使用了子任务和嵌套任务并由此显著提升了整体运行效率。

2. 任务窃取

.NET Framework 4之后的.NET运行时采用了任务窃取算法以提高线程池整体的运行效率。当运行时引入了线程本地任务队列的概念后,很容易出现这样一种情况:线程A的本地任务队列中存在大量待处理任务(都为某一全局任务或顶层任务产生),这些任务按照上述任务排序原则将不会被排入全局任务队列中等待调度执行,而另一线程B则处于空闲(idle)状态,此时线程池中的线程并未被充分利用,而多余的空闲线程也会增加操作系统内核的线程调度压力。

任务窃取算法旨在解决上述问题,当某一工作线程处理完本地任务列表中的所有任务时,它将首先尝试扫描其他工作线程的本地任务列表,并在其他工作线程的本地任务列表的队尾取得某一任务对象并尝试确保该任务逻辑可以在其本地高效运行,如若可以,该工作线程将使该任务从其他线程的本地任务列表队尾中出队并在本地执行(即从其他工作线程处理队列中“偷窃”任务,如图3-4中线程2所示),由于工作线程总是从本地任务队列的对头获取待执行任务,因此从其他工作线程的任务列表队尾“偷窃”任务将很大程度上降低并发风险,并可以以无锁(Lock-Free)的形式高效运转。当任务窃取算法失败时,该工作线程会尝试从全局任务队列中获取待处理任务并执行。任务窃取算法实际上帮助整个运行时线程池在任务分配不均时达到了动态负载均衡,并显著提高了线程利用率。

3. 任务内联

在实际任务处理中也存在以下情况:某父级/顶层任务A在被线程T1执行的过程中创建了若干子任务B/C/D,并在B/C/D任务对象创建完成之后需要同步等待子任务执行完成,继而继续执行后续逻辑。工作线程T1在处理上述情况时,B/C/D将被顺序地压入线程本地任务队列中等待执行,但由于任务A需要等待子任务B/C/D执行完毕,线程T1在此情况下将被阻塞挂起(Blocking),等待线程池中其他线程T2从T1的本地队列中窃取任务B/C/D并执行完成后,T1才能继续父级任务A的执行。从线程池的角度看来,这种情况下需要更多的工作线程以完成内联任务的处理。

为了进一步提高线程池的执行效率并减少线程池中的线程数量(线程数量增多将增加操作系统的内核调度压力),实际上可以使用当前线程T1直接执行子任务B/C/D以避免额外的线程消耗(如前文所述,得益于本地任务队列的后进先出原则,执行子任务B/C/D所需的数据很大概率上已经存在于高速缓存之中)以提高系统性能。为了防止线程重入所产生的错误,任务内联机制只在同步等待的任务对象存在于本地任务队列中时才会启用。

4. 线程池容量调节

在.NET运行时中,线程池管理器通过监控每个工作线程的任务吞吐量来判断线程池的工作状态,并在适当的时间点将新的线程加入线程池或将空闲线程销毁并回收系统资源。.NET Framework 4之后的.NET运行主要有两种主要的线程池容量调整机制:饥饿规避机制和Hill-Climbing算法。

饥饿规避机制主要是为了避免“死锁”的出现,当线程池中的所有线程都被挂起并同步等待某一处于等待执行队列中的任务执行完毕时,将会引发线程阻塞类“死锁”,若此时线程池内线程容量固定,则所有线程都将被阻塞并陷入“死锁”,此时加入新的线程执行任务将避免此种类型的“死锁”发生。

而启发式Hill-Climbing算法则主要应用于线程池容量的调节以在使用最少线程的情况下最大化当前线程池的吞吐量。HC算法是一种隶属于本地搜索的数学优化技术,它基于迭代算法并从问题的任意一个可行解开始,通过逐步更改解决方案中的单个元素变量取值找到更优解。在线程池容量条件中,HC算法的一个目标是在当线程被I/O操作或其他等待条件阻塞时提高内核利用率。在默认情况下.NET运行时的托管线程池将为每个CPU分配一个工作线程,但某一工作线程被阻塞时,则会造成CPU的利用率不足。但线程池管理器并不能区分线程是被I/O操作阻塞还是正在执行一个较为耗时的CPU密集型任务,只要线程池的全局或本地任务队列中仍存在着待执行任务,且任何正在被执行的任务花费了较长的时间(超过500毫秒)时,都可以触发线程池管理器将新的线程加入线程池中。事实上,.NET托管线程池管理器在每个任务执行结束时或最长间隔500毫秒的时间点内都有机会尝试调节线程池的线程数量,若基于当前所观察到的任务吞吐量,增加线程有益于提高线程池吞吐量,则线程池管理器将对线程池进行扩容,反之将尝试在某个线程处理完当前的任务对象逻辑后对线程池进行缩容。