在高流量场景中使用ThreadPool.QueueUserWorkItem在ASP.NET中

113
我一直以为,在ASP.NET中,即使是非关键的短期后台任务,使用线程池也被认为是最佳实践,但是后来我看到了这篇文章,似乎表明了相反的观点 - 认为你应该让线程池处理与ASP.NET相关的请求。
所以,到目前为止,我一直是这样处理小的异步任务的:
ThreadPool.QueueUserWorkItem(s => PostLog(logEvent))

这篇文章则建议明确地创建一个线程,类似于:
new Thread(() => PostLog(logEvent)){ IsBackground = true }.Start()

第一种方法的优点是管理和限制,但是如果文章是正确的,后台任务可能会与ASP.NET请求处理程序竞争线程。第二种方法释放了线程池,但代价是不受限制,因此可能使用过多的资源。
所以我的问题是,文章中的建议是否正确?
如果您的网站流量如此之大,以至于线程池已满,那么是更好地采用非同步方式,还是一个满线程池意味着您已经接近资源的极限,这种情况下您不应该尝试启动自己的线程?
澄清一下:我只是在小型非关键异步任务的范围内提问(例如,远程日志记录),而不是昂贵的工作项,这些工作项需要单独的进程(在这些情况下,我同意您需要更强大的解决方案)。

情节越来越复杂了 - 我发现了这篇文章(http://blogs.msdn.com/nicd/archive/2007/04/16/dissection-of-an-asp-net-2-0-request-processing-flow.aspx),但我无法完全理解。一方面,它似乎在说IIS 6.0+总是在线程池工作线程上处理请求(而早期版本可能会这样做),但接着又说:"然而,如果你使用新的.NET 2.0异步页面(Async="true")或ThreadPool.QueueUserWorkItem(),那么处理的异步部分将在[完成端口线程]内完成。" 处理的异步部分 - Jeff Sternal
还有一件事 - 可以通过检查线程池的可用工作线程数是否低于最大工作线程数,然后在排队的工作项中执行相同操作,在IIS 6.0+安装上轻松测试(我现在没有该环境)。 - Jeff Sternal
11个回答

106

这里的其他答案似乎遗漏了最重要的一点:

除非你试图在低负载网站上并行执行CPU密集型操作以加快完成速度,否则根本没有必要使用工作线程。

这适用于通过 new Thread(...) 创建的空闲线程以及响应 QueueUserWorkItem 请求的 ThreadPool 中的工作线程。

是的,没错,如果排队太多的工作项,你可以使ASP.NET进程中的ThreadPool饿死。 这将防止ASP.NET处理更进一步的请求。 这篇文章中提供的信息在这方面是准确的;使用QueueUserWorkItem 的同一个线程池也用于为请求提供服务。

但是,如果你实际上正在排队足够多的工作项以导致这种饥饿,那么你就应该让线程池饿死! 当同时运行数百个CPU密集型操作时,当机器已经超载时,再增加另一个工作线程来服务ASP.NET请求有什么好处呢? 如果你遇到这种情况,你需要完全重新设计!

大多数时候我看到或听到关于在ASP.NET中不适当使用多线程代码的情况,都不是用于排队CPU密集型工作。 它们用于排队I/O绑定工作。 如果你想执行I/O工作,则应该使用I/O线程(I/O完成端口)。

具体而言,你应该使用所使用的库类支持的异步回调。 这些方法始终非常清楚地标记出来; 它们以单词 BeginEnd 开头,例如: Stream.BeginReadSocket.BeginConnectWebRequest.BeginGetResponse 等等。

这些方法确实使用了ThreadPool,但它们使用的是IOCPs,这些不会干扰ASP.NET请求的特殊轻量级线程。它们可以被I/O系统的中断信号“唤醒”。在ASP.NET应用程序中,通常每个工作线程都有一个I/O线程,因此每个请求都可以有一个异步操作排队。这意味着成百上千个异步操作而没有任何显著的性能下降(假设I/O子系统跟得上)。这比你需要的还要多得多。
请记住,异步委托不是这样工作的——它们最终会像ThreadPool.QueueUserWorkItem一样使用工作线程。只有.NET Framework库类的内置异步方法才能做到这点。您可以自己做,但这很复杂、有点危险,可能超出了本讨论的范围。
在我看来,对于这个问题,最好的答案是不要在ASP.NET中使用ThreadPool或后台Thread实例。这与在Windows窗体应用程序中启动线程以保持UI响应并不相同,你并不关心它的有效性。在ASP.NET中,你关心的是吞吐量,所有那些工作线程上的上下文切换绝对会使你的吞吐量下降,无论你是否使用ThreadPool
如果您发现自己在ASP.NET中编写线程代码,请考虑它是否可以重写为使用现有的异步方法,如果不能,请再三考虑您是否真正需要在后台线程中运行该代码。在大多数情况下,您可能只是增加了复杂性,而没有任何净收益。

首字母缩写应该是IOCP而不是IOPC,有足够积分的人可以更正答案。 - John Simons
2
I/O完成端口(IOCP)。 IOCP的描述不太正确。在IOCP中,您有一组静态的工作线程,它们轮流处理所有待处理任务。不要将其与线程池混淆,线程池的大小可以是固定或动态的,但每个任务都有一个线程 - 无法扩展。与异步不同,您没有一个线程对应一个任务。 IOCP线程可能会在任务1上工作一段时间,然后切换到任务3,任务2,然后再回到任务1。任务会话状态被保存并在线程之间传递。 - user585968
1
数据库插入怎么办?有没有异步 SQL 命令(例如 Execute)?由于锁定的原因,数据库插入是最慢的 I/O 操作之一,让主线程等待行插入只是浪费 CPU 循环。 - Ian Thompson
@IanThompson:我鼓励你阅读你正在使用的任何数据库驱动程序/库的文档。对于那个问题,没有一个单一的答案,而且它可能随着时间的推移而变化。例如,Oracle最近才开始支持异步,并且可能仍不支持TPL-style异步。 - Aaronaught
@Aaronaught 谢谢。ODBCCommand 不支持它,但 SQLCommand 有一个 ASYNC BeginExecuteNonQuery,我已经成功地使用了它。 - Ian Thompson
显示剩余3条评论

46

据微软ASP.NET团队的Per Thomas Marquadt表示,使用ASP.NET线程池(QueueUserWorkItem)是安全的。

文章中写道:

问:如果我的ASP.NET应用程序使用CLR线程池线程,那么我不会让ASP.NET饥饿,因为它也使用CLR线程池来执行请求吗?...

答:总之,不要担心饥饿ASP.NET线程的问题,如果您认为有问题,请告诉我们,我们会处理。

问:我应该创建自己的线程(new Thread)吗?这样做对ASP.NET来说不是更好吗,因为它使用CLR ThreadPool。

答:请不要。或者换句话说,不!如果你确实比我聪明得多,可以创建自己的线程;否则,根本不要考虑。以下是一些不应频繁创建新线程的原因:

  1. 相比于QueueUserWorkItem,它非常昂贵......顺便说一下,如果您能编写比CLR更好的 ThreadPool,我鼓励您申请微软的工作,因为我们绝对需要像您这样的人!

4

网站不应该随意创建线程。

通常,您需要将此功能移出到Windows服务中,然后与之进行通信(我使用MSMQ进行通信)。

--编辑

我在这里描述了一种实现方式:ASP.NET MVC Web应用程序中的基于队列的后台处理

--编辑

为了解释为什么这比仅使用线程更好:

使用MSMQ,您可以与另一台服务器通信。您可以跨计算机写入队列,因此,如果您确定由于某种原因,后台任务过多地使用了主服务器的资源,您可以轻松地将其转移到其他服务器。

它还允许您批量处理您尝试完成的任何任务(发送电子邮件/等等)。


4
我不同意这个笼统的说法总是正确的——特别是对于非关键任务而言。仅仅为了异步记录日志而创建Windows服务似乎确实有些过头了。此外,这种选择并不总是可用的(例如无法部署MSMQ和/或Windows服务)。 - Michael Hart
当然可以,但这是从网站实现异步任务的“标准”方式(将主题队列与其他进程对立)。 - Noon Silk
2
并非所有的异步任务都是相同的,这就是为什么 ASP.NET 存在异步页面的原因。如果我想要从远程 Web 服务获取结果以进行显示,我不会通过 MSMQ 来完成。在这种情况下,我将使用远程发布来写入日志。编写 Windows 服务或连接 MSMQ 并不适用于此问题(而且由于此特定应用程序位于 Azure 上,我也无法这样做)。 - Michael Hart
1
考虑一下:你正在向远程主机编写代码?如果该主机宕机或无法访问怎么办?您是否想要重试写入操作?也许您想,也许您不想。但是使用服务很容易就能实现重试,而使用自己的实现则比较困难。我理解您可能做不到这一点,我会让其他人回答从网站创建线程的具体问题(例如,如果您的线程不是后台线程等),但我将概述“正确”的方法来完成它。虽然我不熟悉Azure,但我已经使用过EC2(您可以在其中安装操作系统,因此任何OS都可以)。 - Noon Silk
@silky,感谢您的评论。我之前说“非关键性”是为了避免使用更加复杂(但更加耐用)的解决方案。我已经澄清了问题,以便明确我并不是在询问有关排队工作项的最佳实践。Azure支持这种类型的场景(它有自己的队列存储),但是对于同步日志记录来说,排队操作太昂贵了,因此我需要一个异步解决方案。在我的情况下,我知道失败的风险,但我不会添加更多基础设施,以防止特定的日志记录提供程序失败 - 我还有其他日志记录提供程序。 - Michael Hart
没关系,如果我听起来过于粗鲁,我道歉 :) - Noon Silk

4

我认为在ASP.NET中,快速的低优先级异步工作的通用实践是使用.NET线程池,特别是在高流量场景下,因为您希望资源受到限制。

此外,线程的实现是隐藏的 - 如果您开始生成自己的线程,则还必须适当地管理它们。并不是说你不能这样做,但为什么要重新发明轮子呢?

如果性能成为问题,并且您可以确定线程池是限制因素(而不是数据库连接、出站网络连接、内存、页面超时等),则可以调整线程池配置以允许更多的工作线程、更高的排队请求等。

如果您没有性能问题,则选择生成新线程以减少与ASP.NET请求队列的竞争是经典的过早优化。

理想情况下,您不需要使用单独的线程来执行日志记录操作 - 只需尽快使原始线程完成操作,这就是MSMQ和单独的消费者线程/进程的用处。我同意这更加复杂,需要更多的工作来实现,但是在这里您真的需要持久性 - 共享内存队列的易变性很快会被耗尽。


2
你应该使用QueueUserWorkItem,避免像避开瘟疫一样创建新线程。为了解释为什么你不会让ASP.NET饥饿,因为它使用相同的线程池,可以想象一个非常熟练的杂耍者用两只手保持半打保龄球,剑或其他物品在空中飞行。为了说明为什么创建自己的线程是不好的,可以想象在西雅图高峰期,当高度使用的入口匝道允许车辆立即进入交通而不是使用灯并限制每几秒钟一个入口时会发生什么。最后,请参阅此链接以获取详细说明:

http://blogs.msdn.com/tmarq/archive/2010/04/14/performing-asynchronous-work-or-tasks-in-asp-net-applications.aspx

谢谢,托马斯。

那个链接非常有用,谢谢Thomas。我也很想听听你对@Aaronaught的回答有什么看法。 - Michael Hart
我同意Aaronaught的观点,并在我的博客文章中表达了相同的看法。我的表述是这样的:“为了简化这个决定,只有当您在处理空闲时间时会阻塞ASP.NET请求线程时,才应该切换到另一个线程。这是一种过度简化的做法,但我试图让这个决定变得简单明了。”换句话说,不要为非阻塞计算工作而这样做,但如果您正在向远程服务器发出异步网络服务请求,则应该这样做。听取Aaronaught的建议! :) - Thomas

1
您可以使用Parallel.For或Parallel.ForEach,并定义您想要分配的可能线程数量的限制,以使其平稳运行并防止池饥饿。
但是,在ASP.Net Web应用程序中,由于在后台运行,您需要使用纯TPL样式。
var ts = new CancellationTokenSource();
CancellationToken ct = ts.Token;

ParallelOptions po = new ParallelOptions();
            po.CancellationToken = ts.Token;
            po.MaxDegreeOfParallelism = 6; //limit here

 Task.Factory.StartNew(()=>
                {                        
                  Parallel.ForEach(collectionList, po, (collectionItem) =>
                  {
                     //Code Here PostLog(logEvent);
                  }
                });

1

那篇文章是不正确的。ASP.NET有自己的线程池,即托管工作线程,用于处理ASP.NET请求。这个线程池通常有几百个线程,并且与ThreadPool线程池分开,后者是处理器的一些较小倍数。

在ASP.NET中使用ThreadPool不会干扰ASP.NET工作线程。使用ThreadPool是可以的。

另外,如果只是为了记录日志消息并使用生产者/消费者模式将日志消息传递到该线程,则设置一个仅用于记录消息的单个线程也是可以接受的。在这种情况下,由于线程是长时间运行的,因此应创建一个新线程来运行记录。

为每条消息使用新线程绝对是过度的。

另一种选择(如果您只谈论记录日志)是使用类似log4net的库。它在单独的线程中处理日志记录,并处理可能出现的所有上下文问题。


1
@Sam,我实际上正在使用log4net,但没有看到日志被写入单独的线程中 - 是否有某种选项需要启用? - Michael Hart

1

我认为这篇文章是错误的。如果你运行一个大型的.NET商店,你可以安全地在多个应用程序和多个网站(使用单独的应用程序池)之间使用线程池,仅基于ThreadPool文档中的一条语句:

每个进程都有一个线程池。 线程池的默认大小为每个可用处理器250个工作线程和1000个I/O完成线程。线程池中的线程数可以通过使用SetMaxThreads方法进行更改。每个线程使用默认堆栈大小并以默认优先级运行。


一个在单个进程中运行的应用程序完全有能力将自己关掉!(或至少降低自身性能,使线程池成为失败的建议。) - Jeff Sternal
那么我猜 ASP.NET 请求使用 I/O 完成线程(而不是工作线程)- 这正确吗? - Michael Hart
从我在答案中链接的Fritz Onion的文章中可以看出:“这种范式转变(从IIS 5.0到IIS 6.0)改变了ASP.NET中处理请求的方式。不再是将请求从inetinfo.exe分派到ASP.NET工作进程,而是http.sys直接将每个请求排队到适当的进程中。因此,现在所有的请求都由CLR线程池中的工作线程处理,而不是I/O线程。”(我强调) - Jeff Sternal
嗯,我还不是完全确定...那篇文章是2003年6月的。如果你读一下这篇2004年5月的文章(尽管仍然很旧),它说“Sleep.aspx测试页面可用于使ASP.NET I/O线程保持繁忙状态”,其中Sleep.aspx只是导致当前执行线程休眠:http://msdn.microsoft.com/en-us/library/ms979194.aspx - 等我有机会,我会看看能否编写该示例并在IIS 7和.NET 3.5上进行测试。 - Michael Hart
是的,那段文字很令人困惑。在该部分的更远处,它链接到一个支持主题(http://support.microsoft.com/default.aspx?scid=kb;EN-US;816829),澄清了事情:在I/O完成线程上运行请求是.NET Framework 1.0的问题,已在ASP.NET 1.1 June 2003 Hotfix Rollup Package中得到修复(此后“所有请求现在都在工作线程上运行”)。更重要的是,该示例非常清楚地显示了ASP.NET线程池与System.Threading.ThreadPool公开的线程池相同。 - Jeff Sternal

1
上周我在工作中被问到了类似的问题,我会给你同样的答案。为什么要针对每个请求进行多线程Web应用程序?Web服务器是一个优化非常重的系统,可以及时提供许多请求(即多线程)。想象一下当您请求网页时会发生什么。
1. 请求某个页面 2. 返回HTML 3. HTML告诉客户端发出进一步的请求(js、css、图像等) 4. 返回更多信息
您举了远程日志记录的例子,但这应该是您的记录器关注的问题。应该有一个异步过程来及时接收消息。Sam甚至指出,您的记录器(log4net)应该已经支持此功能。
Sam还正确地指出,在CLR上使用线程池不会导致与IIS中的线程池发生问题。但需要注意的是,您不是从进程中生成线程,而是从IIS线程池线程生成新线程。这是有区别的,这种区别很重要。
线程与进程
线程和进程都是并行化应用程序的方法。 然而,进程是独立的执行单元,包含自己的状态信息,使用自己的地址空间,并通过进程间通信机制(通常由操作系统管理)相互交互。在设计阶段,应用程序通常被划分为进程,当需要在逻辑上分离重要的应用功能时,主进程显式地生成子进程。换句话说,进程是一种架构构建。
相比之下,线程是一种编码构造,不影响应用程序的架构。一个进程可能包含多个线程;进程内的所有线程共享相同的状态和相同的内存空间,并且可以直接相互通信,因为它们共享相同的变量。 来源

3
@Ty,感谢您的贡献,但是我很清楚Web服务器的工作原理,并且这与问题并不相关。正如我在问题中所说,我不是在寻求关于架构问题的指导,而是在寻求具体的技术信息。至于“记录器的关注点”,它应该已经有一个异步处理过程了-您认为记录器实现应该如何编写这个异步过程呢? - Michael Hart

0

我不同意引用文章(C#feeds.com)的观点。虽然创建新线程很容易,但也很危险。在单核上运行的活动线程的最佳数量实际上令人惊讶地低 - 少于10个。如果为次要任务创建线程,则非常容易导致机器浪费时间切换线程。线程是一种需要管理的资源。WorkItem抽象用于处理这些问题。

在减少用于请求的线程数和创建过多的线程以允许任何一个线程高效处理之间存在权衡。这是一个非常动态的情况,但我认为应该积极管理(在这种情况下由线程池),而不是让处理器在线程创建之前保持领先。

最后,这篇文章对使用ThreadPool的危险性提出了一些非常概括的陈述,但它确实需要一些具体的支持。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接