目录
1.通过TPL使用线程池
2.不使用TPL进入线程池的办法
异步委托
3.线程池优化技术
最小线程数的工作原理
每当启动一个新线程时,系统都需要花费数百微秒来分配资源,例如创建独立的局部变量栈空间。默认情况下,每个线程还会占用约1MB内存。线程池通过共享和回收线程来消除这些开销,使得多线程技术可以应用于非常细粒度的场景而不会造成性能损失。这在利用多核处理器以"分而治之"方式并行执行计算密集型代码时尤为有用。
线程池还会限制同时运行的线程总数。过多的活动线程会给操作系统带来管理负担,并导致CPU缓存失效。一旦达到限制,新任务将进入队列,只有当前线程完成任务后才能启动。这使得高并发应用(如Web服务器)的实现成为可能。(异步方法模式是一种更高级的技术,它能更高效地利用线程池中的线程,我们会在后面文章讲解)。
进入线程池有多种方式:
- Task Parallel Library (Framework 4.0)
- ThreadPool.QueueUserWorkItem
- asynchronous delegates
- BackgroundWorker
以下组件会间接使用线程池:
• WCF、远程处理(Remoting)、ASP.NET和ASMX Web服务应用服务器
• System.Timers.Timer和System.Threading.Timer计时器
• 以Async结尾的框架方法(如WebClient基于事件的异步模式)
• 大多数BeginXXX方法(异步编程模型模式)
• PLINQ并行查询
任务并行库(TPL)和PLINQ功能强大且抽象层次高,即使不考虑线程池优势也值得用于多线程开发。使用线程池时需注意以下事项:
-
无法设置线程池线程的Name属性,这会增加调试难度(但在Visual Studio线程窗口中可附加描述信息)
-
线程池线程默认都是后台线程(通常不影响使用)
-
在应用初期阻塞线程池线程可能导致额外延迟,除非调用ThreadPool.SetMinThreads(参见"优化线程池"章节)
-
可临时修改线程池线程优先级,但释放回池后优先级会自动重置为普通级别
可通过Thread.CurrentThread.IsThreadPoolThread属性检测当前是否运行在线程池线程上。
1.通过TPL使用线程池
使用任务并行库(TPL)中的Task类可以轻松进入线程池。Task类是在.NET Framework 4.0中引入的:如果您熟悉旧的结构,可以将非泛型Task类视为ThreadPool.QueueUserWorkItem的替代品,而泛型Task<TResult>则是异步委托的替代品。新结构比旧结构更快、更方便、更灵活。
要使用非泛型Task类,只需调用Task.Factory.StartNew并传入目标方法的委托即可:
static void Main() // The Task class is in System.Threading.Tasks
{Task.Factory.StartNew (Go);
}static void Go()
{Console.WriteLine ("Hello from the thread pool!");
}
Task.Factory.StartNew函数返回一个Task类型对象,你可以使用这个对象管理这个任务,比如你可以用Waith方法等待任务完成。
注意:当调用任务的Wait方法时,任何未处理的异常都会便捷地重新抛回到宿主线程(如果不调用Wait而是直接放弃任务,未处理的异常将像普通线程一样导致进程关闭)。
泛型Task<TResult>类是非泛型Task的子类,它允许在任务执行完成后获取返回值。在下面的示例中,我们使用Task<TResult>下载网页:
static void Main()
{// Start the task executing:Task<string> task = Task.Factory.StartNew<string>( () => DownloadString ("http://www.linqpad.net") );// We can do other work here and it will execute in parallel:RunSomeOtherMethod();// When we need the task's return value, we query its Result property:// If it's still executing, the current thread will now block (wait)// until the task finishes:string result = task.Result;
}static string DownloadString (string uri)
{using (var wc = new System.Net.WebClient())return wc.DownloadString (uri);
}
当查询任务的Result属性时,任何未处理的异常都会自动重新抛出(封装在AggregateException中)。但如果不查询Result属性(也不调用Wait方法),未处理的异常将会导致进程崩溃。
任务并行库(TPL)功能远不止于此,它特别适合利用多核处理器优势。我会在后续文章专门讨论TPL的更多功能。
2.不使用TPL进入线程池的办法
如果您的开发目标是.NET Framework 4.0之前的版本,则无法使用任务并行库(TPL)。此时必须改用以下传统方式进入线程池:ThreadPool.QueueUserWorkItem和异步委托。两者的主要区别在于:
-
异步委托允许从线程返回数据
-
异步委托还能将异常封送回调用方
QueueUserWorkItem使用方法:
只需调用该方法并传入要在池线程上执行的委托即可:
static void Main()
{ThreadPool.QueueUserWorkItem (Go);ThreadPool.QueueUserWorkItem (Go, 123);Console.ReadLine();
}static void Go (object data) // data will be null with the first call.
{Console.WriteLine ("Hello from the thread pool! " + data);
}
运行结果:
Hello from the thread pool! Hello from the thread pool! 123
目标方法Go必须接受单个object参数(以满足WaitCallback委托)。这提供了传递数据的便捷方式,类似于ParameterizedThreadStart。但与Task不同,QueueUserWorkItem不会返回对象来帮助后续执行管理。此外,您必须显式处理目标代码中的异常——未处理的异常将导致程序崩溃。
异步委托
ThreadPool.QueueUserWorkItem未提供简单机制来获取线程执行完成后的返回值。异步委托调用(简称异步委托)解决了这个问题,允许任意数量的类型化参数双向传递。更重要的是,异步委托上的未处理异常会便捷地重新抛回到原始线程(更准确地说,是调用EndInvoke的线程),因此不需要显式处理。
以下是使用异步委托启动工作任务的步骤:
-
实例化指向要在并行中运行方法的委托(通常使用预定义的Func委托)
-
调用委托的BeginInvoke,保存返回的IAsyncResult值BeginInvoke会立即返回,此时可执行其他操作
-
需要结果时,在委托上调用EndInvoke并传入保存的IAsyncResult对象
下例使用异步委托调用与主线程并发执行一个返回字符串长度的简单方法:
static void Main()
{Func<string, int> method = Work;IAsyncResult cookie = method.BeginInvoke ("test", null, null);//// ... here's where we can do other work in parallel...//int result = method.EndInvoke (cookie);Console.WriteLine ("String length is: " + result);
}static int Work (string s) { return s.Length; }
EndInvoke 主要完成三个关键操作:
-
等待异步委托完成执行(若尚未完成)
-
接收返回值(以及所有ref/out参数)
-
将工作线程中未处理的异常抛回调用线程
技术细节说明:
• 即使异步委托调用的方法没有返回值,严格来说仍需调用EndInvoke
• 实际上这一要求存在争议——毕竟没有"EndInvoke执法者"来惩罚违规者!
• 但若选择不调用EndInvoke,则必须自行处理工作方法的异常,避免静默失败
高级用法:
调用BeginInvoke时还可指定回调委托——即接受IAsyncResult参数的完成回调方法。这种模式允许发起线程"忘记"异步委托,但需要在回调端做一些额外工作:
static void Main()
{Func<string, int> method = Work;method.BeginInvoke ("test", Done, method);// ...//
}static int Work (string s) { return s.Length; }static void Done (IAsyncResult cookie)
{var target = (Func<string, int>) cookie.AsyncState;int result = target.EndInvoke (cookie);Console.WriteLine ("String length is: " + result);
}
BeginInvoke 的最后一个参数是用户状态对象,该对象会填充 IAsyncResult 的 AsyncState 属性。这个参数可以传递任意您需要的数据;在本例中,我们用它向完成回调传递方法委托,以便我们能够对其调用 EndInvoke。
3.线程池优化技术
线程池初始时仅包含一个线程。当任务被分配时,池管理器会"注入"新线程以应对额外的并发工作负载,直至达到最大限制。在持续空闲足够长时间后,如果池管理器判断减少线程能提升吞吐量,则可能"回收"多余线程。
您可以通过ThreadPool.SetMaxThreads设置线程池创建线程的上限,各版本默认值为:
• Framework 4.0(32位环境):1023个线程
• Framework 4.0(64位环境):32768个线程
• Framework 3.5:每个核心250个线程
• Framework 2.0:每个核心25个线程
(具体数值可能因硬件和操作系统而异)。设置较高数量是为了确保当部分线程阻塞时(如等待远程计算机响应),程序仍能继续执行。
通过ThreadPool.SetMinThreads还可设置下限线程数。下限的作用更为精妙:这是一种高级优化技术,指示池管理器在达到下限前不得延迟线程分配。当存在阻塞线程时,提高最小线程数可增强并发性(参见边栏说明)。
默认下限为每个处理器核心1个线程——这是实现CPU完全利用的最低要求。但在服务器环境(如IIS下的ASP.NET)中,下限通常高得多,可达50个甚至更多。
最小线程数的工作原理
将线程池的最小线程数提升至x,并不会立即强制创建x个线程——线程仅在需要时才会创建。实际上,这个设置是指导线程池管理器在需要时可立即创建最多x个线程。那么问题来了:为什么线程池在需要线程时会有意延迟创建呢?
原因在于防止短暂爆发的短期活动导致线程全量分配,从而突然增加应用程序的内存占用。举例来说,假设一台四核计算机运行的客户端应用一次性提交40个任务:
-
若每个任务执行10毫秒的计算
-
假设工作均匀分配到四个核心
-
整个过程将在100毫秒内完成
理想情况下,我们希望40个任务正好运行在4个线程上:
-
少于4个线程无法充分利用所有核心
-
多于4个线程会浪费内存和CPU时间创建不必要的线程
而这正是线程池的实际工作方式。使线程数与核心数匹配,既能保持较小的内存占用,又不会影响性能——前提是线程被高效利用(本例即是如此)。
但当每个任务改为查询网络(等待半秒响应且本地CPU闲置)时,线程池的节约策略就会失效。此时创建更多线程让所有网络查询并发执行反而更高效。
为此线程池准备了备用方案:如果任务队列持续半秒未变化,就会以每半秒一个的速度新增线程,直到达到线程池容量上限。
这半秒延迟是把双刃剑:
-
优势:防止一次性短期活动导致程序突然多占用40MB(或更多)内存
-
劣势:当线程阻塞时(如数据库查询或调用WebClient.DownloadFile)可能造成不必要延迟
因此可以通过SetMinThreads告知线程池不要延迟创建前x个线程,例如:
//(第二个参数指定分配给I/O完成端口的线程数,该机制用于异步编程模型)
ThreadPool.SetMinThreads (50, 50);
默认值为每个处理器核心1个线程。
本小节完......