C# Task.Yield
最近在阅读 .NET Threadpool starvation, and how queuing makes it worse (.NET线程池耗尽,以及队列如何使其变得更糟)这篇博文时发现文中代码中的一种 Task 用法之前从未见过。
下面代码中的 await Task.Yield() :
static async Task Process()
{
await Task.Yield();
var tcs = new TaskCompletionSource<bool>();
Task.Run(() =>
{
Thread.Sleep(1000);
tcs.SetResult(true);
});
tcs.Task.Wait();
}
(注:上面的代码不是示例,只是因为这段代码而初遇 await Task.Yield)
Task.Yield 简单来说就是创建时就已经完成的 Task ,或者说执行时间为0的 Task ,或者说是空任务,也就是在创建时就将Task 的 IsCompeted 值设置为0。
那 await 一个空任务会怎样?我们知道在 await 时会释放当前线程(假设为ID 6),等所 await 的 Task 完成时会从线程池中申请新的线程( 说明一点,新的线程有可能和之前的线程(ID 6)一样)继续执行 await 之后的代码,这本来是为了解决异步操作(比如IO操作)霸占线程实际却用不到线程的问题,而 Task.Yield 却产生了一个不仅没有异步操作而且什么也不干的 Task ,不是吃饱了撑着吗?
今天吃晚饭的时候终于想明白了——吃饱了没有撑。Task.Yield 产生的空任务仅仅是为 await 做嫁衣,而真正的图谋是借助await 实现线程的切换,让 await 之后的操作重新排队从线程池中申请线程继续执行。
这样做有什么好处呢?
线程是非常非常宝贵的资源,千金难买一线程,而且有优先级,提高线程利用率的重要手段之一就是及时将线程分配给最需要的地方,而最奢侈的之一是让一个优先级低执行时间长的操作一直占用着一个线程,await Task.Yield 可以让你巧妙地借助 await的线程切换能力,将不太重要的比较耗时的操作放在新的线程(重新排队从线程池中申请到的线程)中执行。打个比方,很多人排队在外婆家就餐,你来的时候比较巧,正好有位置,但你本来就不着急肚子也不太饿准备慢慢吃慢慢聊,而排队的人当中有些人很饿很着急吃完还有事,这时你如果先点几个招牌菜解解馋,然后将座位让出来,重新排队,并且排队的人当中像你这样的都这么做,那些排队中心急如焚的人真是是幸福感爆棚,外婆家的老板也笑弯了腰。你让出座位重新排队的爱心行为就是await Task.Yield() 。
C# async、await本质
在C#中,async
和await
关键字是用于编写异步代码的特殊语法。它们的本质是基于任务(Task)和状态机的异步编程模式。
首先,使用async
关键字修饰的方法表示一个异步方法,它可以在方法内部使用await
关键字来等待一个异步操作的完成。当遇到await
关键字时,方法会暂时挂起,并将控制权返回给调用方,让后续的代码可以继续执行。await
关键字主要有两个作用:
-
等待异步操作完成:
await
表达式会等待一个实现了Task
或Task<T>
类型的异步操作完成,并获取其结果。在等待期间,方法会暂停执行,直到异步操作完成为止。 -
返回异步操作的结果:
await
表达式会将异步操作的结果返回给调用方,而不是一个封装异步操作的Task
对象。这使得异步方法可以像同步方法一样使用返回值进行处理,而无需显式地处理Task
。
在编译时,编译器会通过生成状态机来管理异步方法的状态和控制流。这个状态机会记录方法的执行上下文,并在异步操作完成后恢复方法的执行。这样,使用await
关键字的方法可以在异步操作完成后继续执行,保持方法执行的顺序和同步代码类似。
总结来说,async
和await
本质上是使用基于任务和状态机的异步编程模式来简化异步代码的编写和管理。它们提供了一种更直观、易于理解的方式来处理异步操作,使得异步编程变得更加简单和可读性更高。
C# yield关键字
在C#中,yield
关键字用于生成可枚举集合或迭代器。它的主要作用是在一个方法或属性中定义一个迭代器块,通过逐步返回序列中的元素来简化集合的遍历。
使用yield
关键字定义的方法或属性被称为迭代器方法(Iterator Methods),它们可以用于创建一个实现了IEnumerable<T>
接口的集合,或者返回一个实现了IEnumerator<T>
接口的迭代器。
以下是yield
关键字的一些特点和用法:
-
延迟执行:
yield
语句使得集合元素按需生成,只有在迭代器通过调用MoveNext()
方法请求下一个元素时,才会执行yield
语句并产生下一个元素。这种延迟执行的特性可以在处理大型数据集或无限序列时提供性能优势。 -
简化编写:使用
yield
关键字可以将集合的遍历逻辑与生成元素的逻辑分离开来,从而简化代码的编写。通过在迭代器方法中使用yield return
语句,可以方便地逐个返回集合中的元素,而不需要显式地维护状态和索引。 -
支持迭代器块:
yield
关键字可以用在循环内部,允许在每次循环迭代时返回一个元素。这使得可以根据需要生成不同的元素,而无需将它们全部存储在内存中。 -
只读访问:生成的集合或迭代器是只读的,只能通过迭代器进行顺序访问,不能修改集合中的元素。
下面是使用yield
关键字定义迭代器方法的示例:
public IEnumerable<int> GetNumbers()
{
yield return 1;
yield return 2;
yield return 3;
}
在上面的示例中,GetNumbers()
方法是一个迭代器方法,通过使用yield return
语句返回三个整数。通过调用该方法并使用foreach
循环,可以逐个访问生成的元素:
foreach (var number in GetNumbers())
{
Console.WriteLine(number);
}
以上代码将依次输出1、2和3。
总而言之,yield
关键字提供了一种简单且灵活的方式来创建可枚举集合或迭代器,简化了集合的遍历过程,并支持延迟执行和只读访问的特性。
C# yield return 的作用简单说明
简单的说就是记录你上一次执行的位置,等你下次再执行这个函数就会跳到上次的记录点继续执行
using System;
using System.Threading.Tasks;
namespace ConsoleApp1
{
class Program
{
static void Main(string[] args)
{
Task.Run(async ()=> {
await foreach (var number in GenerateSequence())
{
Console.WriteLine(number);
}
});
Console.Read();
}
public static async System.Collections.Generic.IAsyncEnumerable<int> GenerateSequence()
{
for (int i = 0; i < 20; i++)
{
await Task.Delay(100);
//yield return这个的作用就是每当代码执行到这里的时候返回
//而当GenerateSequence函数再次被调用的时候会从上一次的yield开始接着往下走
yield return i;
//yield break的作用就是提前结束
//当GenerateSequence函数再次被调用会重新开始循环
if (i == 18)
yield break;
}
}
}
}
JS yield关键字
在 JavaScript 中,yield
关键字与生成器函数(Generator Function)一起使用。生成器函数是一种特殊类型的函数,可以通过 yield
关键字暂停函数的执行,并返回一个迭代器对象。
下面是关于 yield
关键字的一些说明:
-
生成器函数:生成器函数是一种特殊的函数,通过在函数体内使用
yield
关键字可以将函数的执行暂停,同时可以通过调用迭代器对象的next()
方法来继续函数的执行。 -
迭代器对象:生成器函数的调用会返回一个迭代器对象,它具有一个
next()
方法,用于继续函数的执行并返回一个对象,该对象包含了yield
表达式的值和状态信息。 -
暂停和恢复:当生成器函数遇到
yield
关键字时,函数的执行会暂停,并将yield
表达式右侧的值作为迭代器对象的value
属性返回。当再次调用迭代器对象的next()
方法时,函数会从上次暂停的位置恢复执行,并继续执行到下一个yield
表达式或函数结束。 -
多次迭代:生成器函数可以使用多个
yield
关键字来产生一系列的值。每次调用next()
方法时,生成器函数会继续执行,直到遇到下一个yield
关键字或函数结束。
下面是使用 yield
关键字定义生成器函数的示例:
function* generateNumbers() {
yield 1;
yield 2;
yield 3;
}
const iterator = generateNumbers();
console.log(iterator.next().value); // 输出 1
console.log(iterator.next().value); // 输出 2
console.log(iterator.next().value); // 输出 3
在上面的示例中,generateNumbers()
是一个生成器函数,通过使用 yield
关键字分别返回三个数字。通过调用生成器函数并使用迭代器对象的 next()
方法,可以逐个获取生成的值。
需要注意的是,生成器函数以及通过 yield
关键字定义的代码只在调用 next()
方法时才会执行,它们具有惰性求值的特性。这使得生成器函数非常适合处理大型数据集或无限序列,因为它只在需要的时候才会产生值。
总结来说,JavaScript 中的 yield
关键字与生成器函数一起使用,提供了一种更灵活的方式来控制函数的执行和产生迭代器对象。它使得迭代过程可以暂停和恢复,并且能够方便地按需生成值,从而简化了异步编程和处理复杂迭代逻辑的场景。
C# 和 javascript yield关键字区别
在 C# 和 JavaScript 中,yield
关键字具有相似的概念和用途,但在语法和实现上有一些区别。下面是 C# 和 JavaScript 中 yield
关键字的主要区别:
-
语法差异:
- C#:在 C# 中,
yield
关键字是作为迭代器块的一部分使用的,用于定义迭代器方法。它以yield return
或yield break
的形式出现,用于返回一个序列中的元素或终止迭代。 - JavaScript:在 JavaScript 中,
yield
关键字是与生成器函数一起使用的,用于暂停函数的执行并返回一个迭代器对象。它以yield
的形式出现,用于产生函数的值。
- C#:在 C# 中,
-
迭代器功能不同:
- C#:在 C# 中,通过使用
yield return
关键字,可以构建一个实现了IEnumerable<T>
接口的可枚举集合或迭代器。这意味着使用foreach
循环或 LINQ 查询等方式可以顺序访问生成的元素。 - JavaScript:在 JavaScript 中,通过使用
yield
关键字,可以创建一个生成器函数,它返回一个迭代器对象。通过调用迭代器对象的next()
方法,可以逐步执行生成器函数,并从yield
表达式获取值。
- C#:在 C# 中,通过使用
-
问题领域不同:
- C#:在 C# 中,
yield
关键字常用于处理集合的遍历,可以按需生成元素,提供延迟执行和只读访问的能力,适用于处理大型数据集或无限序列等场景。 - JavaScript:在 JavaScript 中,
yield
关键字常用于处理异步编程,它可以将函数的执行暂停和恢复,通过生成器函数和迭代器对象的组合,可以便捷地处理复杂的迭代逻辑,适用于处理异步操作、状态机或惰性求值等场景。
- C#:在 C# 中,
需要注意的是,尽管 yield
关键字在 C# 和 JavaScript 中有一些差异,但它们都提供了一种简化代码和处理迭代逻辑的方法。无论是在 C# 还是 JavaScript 中,yield
关键字都使得迭代过程更加灵活和高效。
.NET Threadpool starvation, and how queuing makes it worse
There has been plenty of talk lately about threadpool starvation in .NET:最近有很多关于。net中线程池短缺的讨论
- https://blogs.msdn.microsoft.com/vancem/2018/10/16/diagnosing-net-core-threadpool-starvation-with-perfview-why-my-service-is-not-saturating-all-cores-or-seems-to-stall
- Azure DevOps Service
- or even on our own blog: Monitor Finalizers, contention and threads in your application - Criteo Engineering
What is it about? This is one of the numerous ways asynchronous code can break if you wait synchronously on a task.是关于什么的?这是异步代码在同步等待任务时中断的众多方式之一
To illustrate that, consider a web server that would execute this code:为了说明这一点,考虑一个将执行此代码的web服务器
You start an asynchronous operation (DoSomethingAsync) then block the current thread. At some point, the asynchronous operation will need a thread to finish executing, so it’ll ask the threadpool for a new one. You end up using two threads for an operation that could be done with just one: one waiting actively on the Wait() method call and another one performing the continuation. In most cases this is fine. But it can become a problem if you deal with a burst of requests:
你启动一个异步操作(DoSomethingAsync),然后阻塞当前线程。在某些时候,异步操作将需要一个线程来完成执行,因此它将向线程池请求一个新的线程。您最终使用了两个线程来执行一个操作,而这个操作只需要一个线程就可以完成:一个线程主动等待Wait()方法调用,另一个线程执行延续。在大多数情况下,这是可以的。但如果你处理大量的请求,这就会成为一个问题:
- Request 1 arrives to the server. ProcessRequest is called from a threadpool thread. It starts the asynchronous operation then waits on it 请求1到达服务器。ProcessRequest从线程池线程调用。它启动异步操作,然后等待它
- Requests 2, 3, 4, and 5 arrive to the server 请求2、3、4和5到达服务器
- The asynchronous operation completes and its continuation is enqueued to the threadpool异步操作完成,它的延续被排队到线程池中
- In the meantime, since 4 requests have arrived, 4 calls to ProcessRequest have been enqueued before your continuation同时,由于有4个请求到达,在继续之前,对ProcessRequest的4个调用已经进入队列
- Each of those requests will in turn start an asynchronous operation and block their threadpool thread这些请求将依次启动异步操作并阻塞它们的线程池线程
Combined with the fact that the threadpool grows very slowly (one thread per second or so), it’s easy to understand how a burst of requests can push a system into a situation of thread starvation.结合线程池增长非常缓慢(每秒一个线程左右)的事实,很容易理解请求的爆发如何将系统推入线程饥饿的情况 But there’s something missing in the picture: while the burst could temporarily lock the system, unless the workload is continuously increasing, the threadpool should be capable of growing enough to eventually recover.但是这里缺少了一些东西:虽然突发可能暂时锁定系统,但除非工作负载持续增加,线程池应该能够增长到足以最终恢复。
Yet, it does not fit what we observed on our own servers. We usually restart our instances as soon as a starvation happens, but in one case we didn’t. The threadpool grew until its hardcoded limit (32767 threads), and the system never recovered:
然而,它并不符合我们在自己的服务器上观察到的情况。我们通常在发生饥饿时立即重新启动实例,但在一个案例中我们没有这样做。线程池增长到其硬编码限制(32767个线程),并且系统从未恢复:
If you do the math, 32767 threads should be more than enough to handle the 1000-2000 QPS that our servers process, even if every request required 10 threads! 如果你计算一下,32767个线程应该足以处理我们的服务器处理的1000-2000个QPS,即使每个请求需要10个线程!
It seems there’s something else going on.似乎还有别的事情在发生。
The part where things get worse事情变得更糟的部分
Let’s consider the following code. Take a minute to guess what will happen:
让我们考虑下面的代码。花点时间猜猜会发生什么:
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();
var tcs = new TaskCompletionSource<bool>();
Task.Run(() =>
{
Thread.Sleep(1000);
tcs.SetResult(true);
});
tcs.Task.Wait();
Console.WriteLine("Ended - " + DateTime.Now.ToLongTimeString());
}
}
}
Producer enqueues 5 calls to Process every second. In Process, we yield to avoid blocking the caller, then we start a task that will wait 1 second and wait for it. In total, we start 5 tasks per second and each of those tasks will need an additional task. So we need 10 threads to absorb the constant workload. The threadpool is manually configured to start with 8 threads, so we are 2 threads short. My expectations are that the program will struggle for 2 seconds until the threadpool grows to absorb the workload. Then it needs to grow a bit further to process the additional workitems that we enqueued during the 2 seconds. After a few seconds, the situation will stabilize. 生产者每秒为进程排队5个调用。在Process中,我们放弃以避免阻塞调用者,然后我们启动一个将等待1秒的任务并等待它。总的来说,我们每秒启动5个任务,每个任务都需要一个额外的任务。所以我们需要10个线程来吸收恒定的工作负载。线程池被手动配置为从8个线程开始,所以我们少了2个线程。我的预期是,程序将挣扎2秒,直到线程池增长到可以吸收工作负载为止。然后,它需要进一步增长一点,以处理我们在2秒内排队的额外工作项。几秒钟后,情况会稳定下来。
But if you run the program, you’ll see that it managed to display “Ended” a few times in the console, then nothing happens anymore:
但是如果你运行这个程序,你会看到它在控制台中显示了几次“Ended”,然后什么也没发生:
Note that this code assumes that Environment.ProcessorCount is lower or equal to 8 on your machine. If it’s bigger, then the threadpool will start with more thread available, and you need to lower the delay of the Thread.Sleep in Producer() to set the same conditions.注意,这段代码假设Environment。ProcessorCount在您的机器上小于或等于8。如果它更大,那么线程池开始时会有更多可用的线程,您需要降低线程的延迟。在Producer()中休眠以设置相同的条件。
Looking at the task manager, we can see that CPU usage is 0 and the number of threads is growing at about one per second:
查看任务管理器,我们可以看到CPU使用率为0,线程数以每秒一个的速度增长:
Here I’ve let it run for a while and got to a whopping 989 threads, yet still nothing is happening! Even though 10 threads should be enough to handle the workload. So what’s going on?在这里,我让它运行了一段时间,得到了惊人的989个线程,但仍然没有发生任何事情!即使10个线程应该足以处理工作负载。那么这是怎么回事呢?
Every bit is important in that code. For instance, if we remove Task.Yield and manually start new tasks instead in Producer (the comments indicate the changes):
代码中的每一点都很重要。例如,如果我们删除Task。生成并在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());
}
}
}
Then we get the predicted behavior! The application struggles a bit at first, until the threadpool grows enough. Then we have a steady stream of messages, and the number of threads is stable (29 in my case).然后我们得到了预测的行为!应用程序一开始会遇到一些困难,直到线程池足够大。然后我们有一个稳定的消息流,线程的数量是稳定的(在我的例子中是29)。
What if we take that working code but start Producer in its own thread?
如果我们使用工作代码,但在自己的线程中启动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.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());
}
}
}
This frees one thread from the threadpool, so we should expect it to work slightly better. Yet, we end up with the first case: the application displays a few messages before locking up, and the number of threads grows indefinitely.这将从线程池中释放一个线程,因此我们应该期望它工作得稍微好一些。然而,我们最终得到了第一种情况:应用程序在锁定之前显示了一些消息,并且线程的数量无限制地增长。
Let’s put Producer back to a threadpool thread, but use the PreferFairness flag when starting the Process tasks:让我们把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());
}
}
}
Then once again we end up with the first situation: the application locks up, and the number of threads increases indefinitely.然后,我们再次以第一种情况结束:应用程序锁定,线程数量无限增加。
So, what is really going on?那么,到底发生了什么?
The threadpool queuing algorithm
To understand what’s happening, we need to dig into the internals of the threadpool. More specifically, into the way the workitems are queued.为了理解发生了什么,我们需要深入了解线程池的内部。更具体地说,进入工作项排队的方式。
There are a few articles out there explaining how the threadpool queuing works (The Moth - New and Improved CLR 4 Thread Pool Engine). In a nutshell, the important part is that the threadpool has multiple queues. For N threads in the threadpool, there are N+1 queues: one local queue for each thread, and one global queue. The rules for picking in which queue your item will go are simple:有一些文章解释了线程池排队是如何工作的(the Moth -新的和改进的CLR 4线程池引擎)。简而言之,重要的部分是线程池有多个队列。对于线程池中的N个线程,有N+1个队列:每个线程一个本地队列,一个全局队列。选择你的物品放入哪个队列的规则很简单
- The item will be enqueued to the global queue:
- if the thread that enqueues the item is not a threadpool thread
- if it uses ThreadPool.QueueUserWorkItem/ThreadPool.UnsafeQueueUserWorkItem
- if it uses Task.Factory.StartNew with the TaskCreationOptions.PreferFairness flag
- if it uses Task.Yield on the default task scheduler
- In pretty much all other cases, the item will be enqueued to the thread’s local queue
How are the items dequeued? Whenever a threadpool thread is free, it will start looking into its local queue, and dequeue items in LIFO order. If the local queue is empty, then the thread will look into the global queue and dequeue in FIFO order. If the global queue is also empty, then the thread will look into the local queues of other threads and dequeue in FIFO order (to reduce the contention with the owner of the queue, which dequeues in LIFO order).项目是如何脱离队列的?每当线程池线程空闲时,它将开始查看其本地队列,并按照后进先出的顺序取出队列项。如果本地队列为空,则线程将查看全局队列并按FIFO顺序退出队列。如果全局队列也是空的,那么线程将查看其他线程的本地队列,并按FIFO顺序退出队列(以减少与队列所有者的争用,队列所有者按LIFO顺序退出队列)。
How does that impact us? Let’s go back to our faulty code.这对我们有什么影响?让我们回到我们的错误代码。
In all the variations of the code, the Thread.Sleep(1000) is enqueued in a local queue, because Process is always executed in a threadpool thread. But in some cases we enqueue Process in the global queue and in others in the local queues:在所有的代码变体中,thread. sleep(1000)在本地队列中排队,因为Process总是在线程池线程中执行。但在某些情况下,我们将进程加入全局队列,而在其他情况下则加入本地队列:
- In the first version of the code, we use Task.Yield, which queues to the global queue 在代码的第一个版本中,我们使用Task。Yield,将其排队到全局队列
- In the second version, we use Task.Factory.StartNew, which queues to the local queue 在第二个版本中,我们使用Task.Factory。StartNew,它排队到本地队列
- In the third version, we change the Producer thread to not use the threadpool, so Task.Factory.StartNew enqueues to the global queue 在第三个版本中,我们将Producer线程更改为不使用线程池,因此Task.Factory.StartNew进入全局队列
- In the fourth version, Producer is a threadpool thread again but we use TaskCreationOptions.PreferFairness when enqueuing Process, thus using the global queue again 在第四个版本中,Producer仍然是一个线程池线程,但是我们使用了TaskCreationOptions。对进程进行排队时的优先公平性,从而再次使用全局队列
We can see that the only version that worked was the one not using the global queue. From there, it’s just a matter of connecting the dots我们可以看到,唯一有效的版本是没有使用全局队列的版本。从那里开始,就是把这些点连起来的问题了:
- Initial condition: we put our system in a state where the threadpool is starved (i.e. all the threads are busy)初始条件:我们将系统置于线程池饥渴的状态(即所有线程都很忙)。
- We enqueue 5 items per second into the global queue 我们每秒将5个项目放入全局队列
- Each of those items, when executing, enqueues another item into the local queue and waits for it 每个项目在执行时都会将另一个项目排队到本地队列中并等待它
- When a new thread is spawned by the threadpool, that thread will first look into its own local queue which is empty (since it’s newborn). Then it’ll pick an item from the global queue 当线程池生成一个新线程时,该线程将首先查看自己的本地队列,该队列为空(因为它是新生的)。然后它会从全局队列中挑选一个项目
- Since we enqueue into the global queue faster than the threadpool grows (5 items per second versus 1 thread per second), it’s completely impossible for the system to recover. Because of the priority induced by the usage of the global queue, the more threads we add, the more pressure we put on the system 由于我们加入全局队列的速度要快于线程池的增长速度(每秒5个条目,而不是每秒1个线程),因此系统完全不可能恢复。由于使用全局队列会引起优先级,所以我们添加的线程越多,我们给系统带来的压力就越大
When using the local queue instead (second version of the code), the newborn threads will pick items from the other threads’ local queues since the global queue is empty. Therefore, new threads helps alleviate the pressure on the system.由于我们加入全局队列的速度要快于线程池的增长速度(每秒5个条目,而不是每秒1个线程),因此系统完全不可能恢复。由于全局队列的使用所引起的优先级,我们添加的线程越多,我们给系统带来的压力就越大。当使用本地队列代替(代码的第二个版本)时,新生线程将从其他线程的本地队列中挑选项目,因为全局队列是空的。因此,新线程有助于减轻系统的压力。
How does it translate to a real-world scenario? 它如何转化为现实世界的场景?
Take the case of an HTTP-based service. The HTTP stack, whether it uses Windows’ http.sys or another API, is most likely native. When it forwards new requests to the .NET user code, it’ll queue them in the threadpool. Those items will necessarily end up in the global queue, since the native HTTP stack can’t possibly use .NET threadpool threads. Then the user code relies on async/await, and very likely use the local queues all the way. It means that in a situation of starvation, new threads spawned by the threadpool will process the new requests (enqueued in the global queue by the native code) rather than completing the ones already in the pipe (enqueued in the local queues). Therefore, we end up in the situation previously described where every new thread adds even more pressure to the system. 以基于http的服务为例。HTTP栈,是否使用Windows的HTTP。sys或其他API,很可能是本机的。当它将新请求转发给。net用户代码时,它会将它们放在线程池中排队。由于本地HTTP堆栈不可能使用。net线程池线程,因此这些项必然会在全局队列中结束。然后,用户代码依赖于async/await,并且很可能一直使用本地队列。这意味着在缺乏资源的情况下,线程池生成的新线程将处理新请求(由本地代码在全局队列中排队),而不是完成管道中已经存在的请求(在本地队列中排队)。因此,我们最终会出现前面描述的情况,即每个新线程都会给系统增加更多压力。
Another situation where things can turn ugly is if the blocking code is running as part of the callback of a timer. Timer callbacks are enqueued into the global queue. I believe such a case can be found here (pay a close attention to the TimerQueueTimer.Fire call at the beginning of the callstack for the 1202 threads shown): Azure DevOps Service. 另一种情况是,如果阻塞代码作为计时器回调的一部分运行,事情就会变得很糟糕。计时器回调进入全局队列。我相信这样的情况可以在这里找到(密切关注TimerQueueTimer)。在1202个线程的调用栈开始处触发调用):Azure DevOps服务。
What can we do about that?
From a user-code perspective, unfortunately not much. Of course, in an ideal world we would use non-blocking code and never end up in a threadpool starvation situation. Using a dedicated pool of threads around the blocking calls can help a lot, as you stop competing with the global queue for new threads. Having a back-pressure system is a good idea too. At Criteo we’re experimenting with a back-pressure system that measures how long it takes for the threadpool to dequeue an item from a local queue. If it takes longer than a few configured threshold, then we stop processing incoming requests until the system recovers. So far it shows promising results.从用户代码的角度来看,不幸的是没有太多。当然,在理想情况下,我们将使用非阻塞代码,并且永远不会出现线程池耗尽的情况。在阻塞调用周围使用专用的线程池会有很大帮助,因为您不再与全局队列竞争新线程。有一个背压系统也是一个好主意。在Criteo,我们正在试验一个背压系统,该系统可以测量线程池从本地队列中取出一个项目所需的时间。如果它花费的时间超过几个配置的阈值,那么我们将停止处理传入的请求,直到系统恢复。到目前为止,它显示出可喜的结果。
From a BCL (基础类库) perspective, I believe we should treat the global queue as just another local queue. I can’t really see a reason why it should be treated in priority compared to all other local queues. If we’re afraid that the global queue would grow quicker than the other queues, we could put a weight on the random selection of the queue. It would probably require some adjustments, but this is worth exploring.从BCL的角度来看,我认为我们应该将全局队列视为另一个本地队列。我真的看不出为什么它应该优先于所有其他本地队列。如果我们担心全局队列会比其他队列增长得更快,我们可以对队列的随机选择施加权重。这可能需要一些调整,但这是值得探索的