.NET Threadpool 饥渴,以及队列是如何使它更糟的
.NET Threadpool 饥渴,以及队列是如何使它更糟的
.NET Threadpool starvation, and how queuing makes it worse - Criteo Engineering
已经有一些对 threadpool 饥渴的讨论
这是什么呢?如果你使用异步等待任务,它是一种导致异步代码破坏的方式。
为了演示这个问题,我们考虑有一个网站在执行如下的代码。
你启动了一个异步操作 DoSomethingAsync,然后阻塞当前的线程。此时,异步操作需要另一个线程来完成执行任务。所以,它将向线程池请求一个新的线程。最终对这个应该只需要一个线程的操作操作需要 2 个线程。一个将会在 Wait() 方法上等待,而另一个来继续执行。在多数情况下,这种方式没有问题。但是,对于猝发的请求来说就会变成问题:
- 请求 #1 到达服务器,ProcessRequest() 方法被从线程池中调用,它启动了一个异步操作,然后等待它完成。
- 此时,请求 #2, 请求 #3,请求 #4 和请求 #5 到达服务器
- 异步操作完成,它会排队到线程池中
- 此时,由于已经有 4 个后继的请求到达服务,这 4 个请求也会调用 ProcessRequest() 方法,它们已经在 #3 之前排队到了线程池中
- 每个请求也会启动一个异步操作,并阻塞自己当前的线程
实际情况是,线程池中线程数量的增长是非常缓慢的 ( 大约每秒 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)
.NET Threadpool 饥渴,以及队列是如何使它更糟的的更多相关文章
- 如何使代码审查更高效【摘自InfoQ】
代码审查者在审查代码时有非常多的东西需要关注.一个团队需要明确对于自己的项目哪些点是重要的,并不断在审查中就这些点进行检查. 人工审查代码是十分昂贵的,因此尽可能地使用自动化方式进行审查,如:代码 ...
- OGNL(Object-Graph Navigation Language),可以方便地操作对象属性的开源表达式语言,使页面更简洁;
OGNL(Object-Graph Navigation Language),可以方便地操作对象属性的开源表达式语言,使页面更简洁: 支持运算符(如+-*/),比普通的标志具有更高的自由度和更强的功能 ...
- [转帖]传输层安全协议TLS 1.3 RFC 8446使互联网更快、更安全
传输层安全协议TLS 1.3 RFC 8446使互联网更快.更安全 2018-08-12 11:38:19作者:LINUX人稿源:开源社区 https://ywnz.com/linuxyffq/261 ...
- MVVM架构~knockoutjs系列之正则表达式使规则更灵活
返回目录 几乎每种验证架构都会有正则表达式的加盟,一般地,一种验证架构首先会提供一些标准的,常用的验证规则,它们通常是数字验证,电话验证,email验证,长度验证,范围验证,日期验证等,而如果使你的验 ...
- iOS 开发 ZFUI framework控件,使布局更简单
来自:http://www.jianshu.com/p/bcf86b170d9c 前言 为什么会写这个?因为在iOS开发中,界面的布局一直没有Android布局有那么多的方法和优势,我个人开发都是纯代 ...
- i3 窗口管理器使 Linux 更美好
导读 Linux(和一般的开源软件)最美好的一点是自由 —— 可以在不同的替代方案中进行选择以满足我们的需求. 我使用 Linux 已经很长时间了,但我从来没有对可选用的桌面环境完全满意过.直到去年, ...
- 使用docsify并定制以使它更强大
背景 经常在网上看到一些排版非常漂亮的技术手册,左边有目录栏,右边是Markdown格式的文档,整个配色都十分舒服,就像一本书一样,一看就很让人喜欢.就比如Markdown Preview Enhan ...
- 如何使 JavaScript 更高效?
传统的 Web 页面不会包含很多脚本,至少不会太影响 Web 页面的性能.然而,Web 页面变得越来越像应用程序,脚本对其的影响也越来越大.随着越来越多的应用采用 Web 技术开发,脚本性能的提升就变 ...
- 使jQuqer更高效的方法
讨论 jQuery 和 javascript 性能的文章并不罕见.然而,本文我计划总结一些速度方面的技巧和我本人的一些建议,来提升你的 jQuery 和 javascript 代码.好的代码会带来速度 ...
- 2.精通前端系列技术之seajs模块化使工作更简单(二)
drag.js // JavaScript Document //B开发 define(function(require,exports,module){ function drag(obj){ ; ...
随机推荐
- 北京智和信通亮相2023IT运维大会,共话数智浪潮下自动化运维新生态
2023年9月21日,由IT运维网.<网络安全和信息化>杂志社联合主办的"2023(第十四届)IT运维大会"在北京成功举办.大会以"以数为基 智引未来&quo ...
- QQ或者微信可以放昵称的超好看的符号
☪︎⋆ ✯ ⛈ •ᴗ• •ᴥ• ◔.̮◔ ᕱ ᕱ ⸝⸝· ᴥ ·⸝⸝ ʕ·͡ˑ·ཻʔ ʕ•̫͡•ོʔ ˃̣̣̥᷄⌓˂̣̣̥᷅ °꒰'ꀾ'꒱° ⋆ᶿ̵᷄ ˒̼ ᶿ̵᷅⋆ ˙ϖ˙ ⚝ ︎ .˗ˏˋ♡ˎˊ˗ ...
- iOS中搜索框EVNCustomSearchBar使用小结
最近在项目开发中用到了搜索框,之前都是用的系统的searchbar,现有项目中用的是EVNCustomSearchBar,我试了一下还挺方便,下面说一下具体的用法. 第一步:引入添加相关的委托代理EV ...
- 解决 -Code 安装似乎损坏。请重新安装
问题: 1. 安装插件 fix VSCode Checksums 2. ctrl+shift+P打开命令面板 3. 输入 Fix Checksums: Apply 4. 重新启动VSCode
- Springboot --- 使用国内的 AI 大模型 对话
实在是不知道标题写什么了 可以在评论区给个建议哈哈哈哈 先用这个作为标题吧 尝试使用 国内给出的 AI 大模型做出一个 可以和 AI 对话的 网站出来 使用 智普AI 只能 在控制台中输出 对应的信息 ...
- HDU-ACM 2024 Day1
T1009 数位的关系(HDU 7441) 考虑 \(l = r\) 的情况,此时只要计算一个数字,我们将其展开为一个字符串 \(S\).设 \(f_{i, j, k}\) 表示考虑了 \(S\) 的 ...
- Machine Learning Week_1 Linear Algebra Review 7-12
目录 4.7 Video: Matrix Matrix Multiplication unfamiliar words unfamiliar words in transcript 4.8 Readi ...
- att&ck学习笔记1
一.环境搭建 1.1环境搭建测试 最近想要开始学习内网渗透,搜集了一些教程,准备先实验一个vulnstack靶机,熟悉一下内网渗透操作再学习基础知识. 靶场下载地址:http://vulnstack. ...
- 2024SHCTF--Crypto--Week1&Week2--WP
2024SHCTF 注:针对2024SHCTF赛事,写下自己的解题思路以及个别赛题赛后复现对于题目而产生的理解. Week1 d_known task: from Crypto.Util.number ...
- 机器学习框架推理流程简述(以一项部署在windows上的MNN框架大模型部署过程为例子)
一.写在前面 公司正好有这个需求,故我这边简单接受进行模型的部署和demo程序的编写,顺便学习了解整个大模型的部署全流程.这篇博客会简单提到大模型部署的全流程,侧重点在推理这里.并且这篇博客也是结合之 ...