冠军

导航

.NET Threadpool 饥渴,以及队列是如何使它更糟的

.NET Threadpool 饥渴,以及队列是如何使它更糟的

.NET Threadpool starvation, and how queuing makes it worse - Criteo Engineering

已经有一些对 threadpool 饥渴的讨论

这是什么呢?如果你使用异步等待任务,它是一种导致异步代码破坏的方式。

为了演示这个问题,我们考虑有一个网站在执行如下的代码。

你启动了一个异步操作 DoSomethingAsync,然后阻塞当前的线程。此时,异步操作需要另一个线程来完成执行任务。所以,它将向线程池请求一个新的线程。最终对这个应该只需要一个线程的操作操作需要 2 个线程。一个将会在 Wait() 方法上等待,而另一个来继续执行。在多数情况下,这种方式没有问题。但是,对于猝发的请求来说就会变成问题:

  1. 请求 #1 到达服务器,ProcessRequest() 方法被从线程池中调用,它启动了一个异步操作,然后等待它完成。
  2. 此时,请求 #2, 请求 #3,请求 #4 和请求 #5 到达服务器
  3. 异步操作完成,它会排队到线程池中
  4. 此时,由于已经有 4 个后继的请求到达服务,这 4 个请求也会调用 ProcessRequest() 方法,它们已经在 #3 之前排队到了线程池中
  5. 每个请求也会启动一个异步操作,并阻塞自己当前的线程

实际情况是,线程池中线程数量的增长是非常缓慢的 ( 大约每秒 1 个左右 )。所以,很容易理解为什么猝发的请求会导致系统进入线程饥渴状态。但是,这里缺失了一些东西:猝发会导致临时的系统锁定,除非负载是持续增长的,线程池应该最终达到足够的数量。

是的,这并不匹配我们在自己服务器上看到的状况。我们通常在一旦出现饥渴的时候就重新启动我们的实例,但是有一种情况不是这样的,线程池一直增长,直到到达其上限位置 ( 64 位情况下,32767 线程,32 情况下,1023 线程 ),然后,系统再也不能恢复。

如果你计算一下,32767 个线程应该足够处理服务器的 1000 - 2000 QPS,即使每个请求需要 10 个线程!

看来还有其它的问题。

使情况更糟的部分

我们考虑下面的代码,花点时间考虑会发生什么?

Producer 每秒入队 5 个调用到 Process,在 Process 中,我们使用 yield 来避免阻塞调用者,然后,我们启动一个等待 1 秒钟的任务,并等待它完成。总起来说,我们每秒启动 5 个任务,每个任务都需要一个附加的额外任务。所以,我们需要 10 个线程来处理稳定的负载。线程池被配置位从 8 个线程开始,所以,我们总共缺少 2 个线程。我的预期是程序会有 2 秒的过渡期,直到线程池达到负载。然后,它还需要更多一点来处理这个 2 秒中增加的负载,在几秒钟之后,状态应该达到稳定。

using System;
using System.Threading;
using System.Threading.Tasks;

namespace Starvation
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(Environment.ProcessorCount);

            ThreadPool.SetMinThreads(8, 8);

            Task.Factory.StartNew(
                Producer,
                TaskCreationOptions.None);
            Console.ReadLine();
        }

        // 程序入口
        static void Producer()
        {
            while (true)
            {
                Process();

                Thread.Sleep(200);
            }
        }

        static async Task Process()
        {
            // 释放当前执行,请求调度其它线程
            await Task.Yield();

            // 生成 Task 的另一种方法
            var tcs = new TaskCompletionSource<bool>();

            // 1 秒钟之后,此 Task 完成
            Task.Run(() =>
            {
                Thread.Sleep(1000);
                tcs.SetResult(true);
            });

            // 等待完成
            tcs.Task.Wait();

            Console.WriteLine("Ended - " + DateTime.Now.ToLongTimeString());
        }
    }
}

但是,如果你运行该该程序,你将会看到程序死掉了,再也不会继续了。

注意,该代码假设你的机器上的 Environment.ProcessorCount 是少于或者等于 8 ,如果不是的话,线程池会启动更多线程可用,你需要降低在 Producer() 中 Thread.Sleep() 中的延迟值来达到相同的条件。

如果查看任务管理器,你可以看到 CPU 的利用率是 0,但是每秒钟线程的数量都在增加。

这里我已经运行了一会,它已经达到了惊人的 989 个线程。但仍然什么都没有继续发生!考虑应该 10 个线程就可以处理负载,所以,发生了什么呢?

代码中的每个部分都很重要。例如,如果我们删除 Task.Yield 并手动启动任务,而不是在 Producer ( 注释说明了这些修改)。

using System;
using System.Threading;
using System.Threading.Tasks;

namespace Starvation
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(Environment.ProcessorCount);

            ThreadPool.SetMinThreads(8, 8);

            Task.Factory.StartNew(
                Producer,
                TaskCreationOptions.None);
            Console.ReadLine();
        }

        static void Producer()
        {
            while (true)
            {
                // Creating a new task instead of just calling Process
                // Needed to avoid blocking the loop since we removed the Task.Yield
                Task.Factory.StartNew(Process);

                Thread.Sleep(200);
            }
        }

        static async Task Process()
        {
            // Removed the Task.Yield
            var tcs = new TaskCompletionSource<bool>();

            Task.Run(() =>
            {
                Thread.Sleep(1000);
                tcs.SetResult(true);
            });

            tcs.Task.Wait();

            Console.WriteLine("Ended - " + DateTime.Now.ToLongTimeString());
        }
    }
}

此时,我们得到了预期的行为!程序在一开始会等待一下,直到线程池达到足够的数量。然后我们得到稳定的消息状态,线程池的数量变得稳定 ( 在我的机器上是 29 )。

如果我们将这个可以工作的代码,改成运行在自己的自己的线程上?

using System;
using System.Threading;
using System.Threading.Tasks;

namespace Starvation
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(Environment.ProcessorCount);

            ThreadPool.SetMinThreads(8, 8);

            Task.Factory.StartNew(
                Producer,
                TaskCreationOptions.LongRunning); // Start in a dedicated thread
            Console.ReadLine();
        }

        static void Producer()
        {
            while (true)
            {
                Process();

                Thread.Sleep(200);
            }
        }

        static async Task Process()
        {
            await Task.Yield();

            var tcs = new TaskCompletionSource<bool>();

            Task.Run(() =>
            {
                Thread.Sleep(1000);
                tcs.SetResult(true);
            });

            tcs.Task.Wait();

            Console.WriteLine("Ended - " + DateTime.Now.ToLongTimeString());
        }
    }
}

这从线程池中释放了一个线程。所以,我们会期望它工作的更好一点。但是,我们又回到了开始的状况。程序显示了一点信息,但是线程一直在增长。

我们把 Producer() 重新放回到线程池中,但是,在启动 Process() 任务的时候,使用 PreferFairness 标志,

using System;
using System.Threading;
using System.Threading.Tasks;

namespace Starvation
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(Environment.ProcessorCount);

            ThreadPool.SetMinThreads(8, 8);

            Task.Factory.StartNew(
                Producer,
                TaskCreationOptions.None);
            Console.ReadLine();
        }

        static void Producer()
        {
            while (true)
            {
                Task.Factory.StartNew(Process, TaskCreationOptions.PreferFairness); // Using PreferFairness

                Thread.Sleep(200);
            }
        }

        static async Task Process()
        {
            var tcs = new TaskCompletionSource<bool>();

            Task.Run(() =>
            {
                Thread.Sleep(1000);
                tcs.SetResult(true);
            });

            tcs.Task.Wait();

            Console.WriteLine("Ended - " + DateTime.Now.ToLongTimeString());
        }
    }
}

又一次,我们回到开始的状况。程序冻结,而线程数一致增长。

所以,到底是怎么回事呢?

Threadpool 的排队算法

为了理解到底发生了什么,我们需要深入 Threadpool 内部,进一步说,就是任务排队的方式。

有一些文章介绍 Threadpool 是如何将任务排队 (http://www.danielmoth.com/Blog/New-And-Improved-CLR-4-Thread-Pool-Engine.aspx) 。简而言之,重要的是 threadpool 有多个队列,对于线程池中的 N 个线程来说,有 N + 1 个线程,每个线程一个本地队列。和 1 个全局队列。从哪个队列中提取线程的规则也简单:

  • 任务会排队到全局队列中
    • 排队线程的线程不是线程池线程
    • 使用了 ThreadPool.QueueUserWorkItem/ThreadPool.UnsafeQueueUserWorkItem
    • 使用了 Task.Factory.StartNew with the TaskCreationOptions.PreferFairness flag
    • 在默认的线程调度器上使用了 Task.Yield
  • 其它情况,任务项将被排队到本地线程的本地队列中

出队的时候是怎样的呢?当线程池线程空闲的时候,它将开始查询本地队列,使用 LIFO 顺序。如果本地队列是空的,那么查询全局队列,使用 FIFO 顺序。如果全局队列也是空的,那么线程将查询其它线程的本地队列,并使用 FIFO 顺序 ( 来减少与队列拥有者之间的冲突,拥有者会使用 LIFO 顺序 )

它又是如何影响我们的呢?让我们回到有问题的代码。

在所有进入饥渴状态的代码中,Thread.Sleep(1000) 会导致排队到本地队列中。因为 Process 总是在线程池中执行的。但是,有时候我们将 Process() 排入全局队列中,有时候在本地队列中:

  • 在第一个版本的代码中,使用 Task.Yield() 排队到全局队列中。
  • 在第二个版本的代码中,使用 Task.Factory.StartNew() 排入本地队列中
  • 在第三个版本的代码中,我们对 Producer 的线程修改位不使用线程池,所以, Task.Factory.StartNew() 排入了全局队列中。
  • 在第四个版本的代码中,Producer 还是线程池线程,但是,我们使用 TaskCreationOptions.PreferFairness 使得还是使用了全局队列。

我们可以看到只有没有使用全局队列的版本是工作的。从这里看,

  • 初始条件,系统进入了饥渴状态
  • 每秒我们排队了 5 个任务到全局队列中。
  • 对于每个工作项,在执行的时候,将其它工作项排队到本地队列中,并等待完成
  • 当线程池创建新的线程出来的时候,该线程首先查看它的本地队列,现在是空的,因为是新创建出来的。然后它从全局队列提取任务。
  • 因为我们排队到全局队列的速度比线程池创建线程的速度快 ( 每秒 5 个,而线程池是 1 个 ),系统完全不可能恢复过来,由于使用全球队列所导致的优先级,我们添加的线程越多,我们给系统施加的压力就越大

当使用本地队列的时候 ( 第二个版本的代码 ),新创建的线程将从其它线程的本地队列中提取任务,因为全局队列是空的。进而,新的线程帮助减轻了系统的压力

这怎么映射到现实世界中呢?

考虑对于一个基于 HTTP 的服务,HTTP 服务栈,不管是使用 Windows 系统的 http.sys 还是其它的 API,基本上会是原生的。当它将新的请求转发给 .NET 用户代码的时候,将任务排入 threadpool。这些任务条目将会在全局队列中执行,因为原生的 HTTP 不能可能使用 .NET 的线程池线程。然后,用户代码开始基于 async/await ,基本上会是使用本地队列执行。这意味着在饥渴状态的时候,线程池新创建的线程将处理新的请求 ( 通过原生代码入队到全局队列中 )。进而,我们会到达前面所述的饥渴状态,此时任何新创建的线程都会增加系统的压力

还有一些其它情况也会导致,例如阻塞的代码作为定时器的回调处理的一部分。定时器回调函数被入队到全局队列中。我相信可以在这里找到一个例子 ( 注意 TimerQueueTimer.Fire 调用,在 1202 线程开始的回调中 https://blogs.msdn.microsoft.com/vsoservice/?p=17665.)

我们可以做什么?

从用户角度来看,不幸的是做不了太多。当然,在理想的世界里,我们使用不会阻塞的代码,并且永远也不会到达线程池饥饿状态。对于阻塞调用使用特定的线程池有助于此,因为你不会对新创建的线程竞争全局队列。拥有一个压力反馈系统也是一个好想法。在 Criteo 我们使用一个压力反馈系统测量从本地队列中花费多长时间来从线程池出队。如果它超过了一些配置的阈值,我们就停止处理进入的请求,直到系统恢复。目前为止,它展示了期望的结果。

从 BCL 的角度,我相信我们应该将全局队列看成与其它本队队列一样。我没有看到有什么原因它的优先级高于所有其它的本地队列。如果我们担心全局队列比其它队列增长过快,我们可以增加一个随机权重到队列。这可能需要一些调整,但是值得的。

终于明白了 C# 中 Task.Yield 的用途 - dudu - 博客园 (cnblogs.com)

posted on 2023-03-17 20:49  冠军  阅读(88)  评论(0编辑  收藏  举报