异步编程:使用线程池管理线程

clip_image001

         从此图中我们会发现 .NET 与C# 的每个版本发布都是有一个“主题”。即:C#1.0托管代码→C#2.0泛型→C#3.0LINQ→C#4.0动态语言→C#5.0异步编程。现在我为最新版本的“异步编程”主题写系列分享,期待你的查看及点评。

 

 

传送门:异步编程系列目录……

 

 

开始《异步编程:使用线程池管理线程》

示例程序:异步编程:使用线程池管理线程.rar

        如今的应用程序越来越复杂,我们常常需要使用《异步编程:线程概述及使用》中提到的多线程技术来提高应用程序的响应速度。这时我们频繁的创建和销毁线程来让应用程序快速响应操作,这频繁的创建和销毁无疑会降低应用程序性能,我们可以引入缓存机制解决这个问题,此缓存机制需要解决如:缓存的大小问题、排队执行任务、调度空闲线程、按需创建新线程及销毁多余空闲线程……如今微软已经为我们提供了现成的缓存机制:线程池

         线程池原自于对象池,在详细解说明线程池前让我们先来了解下何为对象池。

 

对象池

在系统设计中,我们尝尝会使用到“池”的概念。Eg:数据库连接池,socket连接池,线程池,组件队列。“池”可以节省对象重复创建和初始化所耗费的时间。对那些被系统频繁请求和使用的对象,使用此机制可以提高系统运行性能。

“池”是一种“以空间换时间”的做法,我们在内存中保存一系列整装待命的对象,供人随时差遣。与系统效率相比,这些对象所占用的内存空间太微不足道了。

 

流程图:

image

 

         对于对象池的清理通常设计两种方式:

1)         手动清理,即主动调用清理的方法。

2)         自动清理,即通过System.Threading.Timer来实现定时清理。

 

关键实现代码:

    public sealed class ObjectPool<T> where T : ICacheObjectProxy<T>
    {
        // 最大容量
        private Int32 m_maxPoolCount = 30;
        // 最小容量
        private Int32 m_minPoolCount = 5;
        // 已存容量
        private Int32 m_currentCount;
        // 空闲+被用 对象列表
        private Hashtable m_listObjects;
        // 最大空闲时间
        private int maxIdleTime = 120;
        // 定时清理对象池对象
        private Timer timer = null;

        /// <summary>
        /// 创建对象池
        /// </summary>
        /// <param name="maxPoolCount">最小容量</param>
        /// <param name="minPoolCount">最大容量</param>
        /// <param name="create_params">待创建的实际对象的参数</param>
        public ObjectPool(Int32 maxPoolCount, Int32 minPoolCount, Object[] create_params){ }

        /// <summary>
        /// 获取一个对象实例
        /// </summary>
        /// <returns>返回内部实际对象,若返回null则线程池已满</returns>
        public T GetOne(){ }

        /// <summary>
        /// 释放该对象池
        /// </summary>
        public void Dispose(){ }

        /// <summary>
        /// 将对象池中指定的对象重置并设置为空闲状态
        /// </summary>
        public void ReturnOne(T obj){ }

        /// <summary>
        /// 手动清理对象池
        /// </summary>
        public void ManualReleaseObject(){ }

        /// <summary>
        /// 自动清理对象池(对大于 最小容量 的空闲对象进行释放)
        /// </summary>
        private void AutoReleaseObject(Object obj){ }
    }

 

         通过对“对象池”的一个大体认识能帮我们更快理解线程池。

 

线程池ThreadPool类详解

ThreadPool静态类,为应用程序提供一个由系统管理的辅助线程池,从而使您可以集中精力于应用程序任务而不是线程管理。每个进程都有一个线程池,一个Process中只能有一个实例,它在各个应用程序域(AppDomain)是共享的。

在内部,线程池将自己的线程划分工作者线程(辅助线程)和I/O线程。前者用于执行普通的操作,后者专用于异步IO,比如文件和网络请求,注意,分类并不说明两种线程本身有差别,内部依然是一样的。

 

public static class ThreadPool
{
    // 将操作系统句柄绑定到System.Threading.ThreadPool。
    public static bool BindHandle(SafeHandle osHandle);

    // 检索由ThreadPool.GetMaxThreads(Int32,Int32)方法返回的最大线程池线程数和当前活动线程数之间的差值。
    public static void GetAvailableThreads(out int workerThreads
            , out int completionPortThreads);

    // 设置和检索可以同时处于活动状态的线程池请求的数目。
    // 所有大于此数目的请求将保持排队状态,直到线程池线程变为可用。
    public static bool SetMaxThreads(int workerThreads, int completionPortThreads);
    public static void GetMaxThreads(out int workerThreads, out int completionPortThreads);
    // 设置和检索线程池在新请求预测中维护的空闲线程数。
    public static bool SetMinThreads(int workerThreads, int completionPortThreads);
    public static void GetMinThreads(out int workerThreads, out int completionPortThreads);

    // 将方法排入队列以便执行,并指定包含该方法所用数据的对象。此方法在有线程池线程变得可用时执行。
    public static bool QueueUserWorkItem(WaitCallback callBack, object state);
    // 将重叠的 I/O 操作排队以便执行。如果成功地将此操作排队到 I/O 完成端口,则为 true;否则为 false。
    // 参数overlapped:要排队的System.Threading.NativeOverlapped结构。
    public static bool UnsafeQueueNativeOverlapped(NativeOverlapped* overlapped);
    // 将指定的委托排队到线程池,但不会将调用堆栈传播到工作者线程。
    public static bool UnsafeQueueUserWorkItem(WaitCallback callBack, object state);

    // 注册一个等待Threading.WaitHandle的委托,并指定一个 32 位有符号整数来表示超时值(以毫秒为单位)。
    // executeOnlyOnce如果为 true,表示在调用了委托后,线程将不再在waitObject参数上等待;
    // 如果为 false,表示每次完成等待操作后都重置计时器,直到注销等待。
    public static RegisteredWaitHandle RegisterWaitForSingleObject(
            WaitHandle waitObject
            , WaitOrTimerCallback callBack, object state, 
            Int millisecondsTimeOutInterval, bool executeOnlyOnce);
    public static RegisteredWaitHandle UnsafeRegisterWaitForSingleObject(
              WaitHandle waitObject
            , WaitOrTimerCallback callBack
            , object state
            , int millisecondsTimeOutInterval
            , bool executeOnlyOnce);
    ……
}

1.         线程池线程数

1)         使用GetMaxThreads()和SetMaxThreads()获取和设置最大线程数

可排队到线程池的操作数仅受内存的限制;而线程池限制进程中可以同时处于活动状态的线程数(默认情况下,限制每个 CPU 可以使用 25 个工作者线程和 1,000 个 I/O 线程(根据机器CPU个数和.net framework版本的不同,这些数据可能会有变化)),所有大于此数目的请求将保持排队状态,直到线程池线程变为可用。

不建议更改线程池中的最大线程数:

a)         将线程池大小设置得太大,可能会造成更频繁的执行上下文切换及加剧资源的争用情况。

b)         其实FileStream的异步读写,异步发送接受Web请求,System.Threading.Timer定时器,甚至使用delegate的beginInvoke都会默认调用 ThreadPool,也就是说不仅你的代码可能使用到线程池,框架内部也可能使用到。

c)         一个应用程序池是一个独立的进程,拥有一个线程池,应用程序池中可以有多个WebApplication,每个运行在一个单独的AppDomain中,这些WebApplication公用一个线程池。

 

2)         使用GetMinThreads()和SetMinThreads()获取和设置最小空闲线程数

为避免向线程分配不必要的堆栈空间,线程池按照一定的时间间隔创建新的空闲线程(该间隔为半秒)。所以如果最小空闲线程数设置的过小,在短期内执行大量任务会因为创建新空闲线程的内置延迟导致性能瓶颈。最小空闲线程数默认值等于机器上的CPU核数,并且不建议更改最小空闲线程数

在启动线程池时,线程池具有一个内置延迟,用于启用最小空闲线程数,以提高应用程序的吞吐量。

在线程池运行中,对于执行完任务的线程池线程,不会立即销毁,而是返回到线程池,线程池会维护最小的空闲线程数(即使应用程序所有线程都是空闲状态),以便队列任务可以立即启动。超过此最小数目的空闲线程一段时间没事做后会自己醒来终止自己,以节省系统资源。

3)         静态方法GetAvailableThreads()

通过静态方法GetAvailableThreads()返回的线程池线程的最大数目和当前活动数目之间的差值,即获取线程池中当前可用的线程数目

4)         两个参数

方法GetMaxThreads()、SetMaxThreads()、GetMinThreads()、SetMinThreads()、GetAvailableThreads()钧包含两个参数。参数workerThreads指工作者线程;参数completionPortThreads指异步 I/O 线程。

2.         排队工作项

通过调用 ThreadPool.QueueUserWorkItem 并传递 WaitCallback 委托来使用线程池。也可以通过使用 ThreadPool.RegisterWaitForSingleObject 并传递 WaitHandle(在向其发出信号或超时时,它将引发对由 WaitOrTimerCallback 委托包装的方法的调用)来将与等待操作相关的工作项排队到线程池中。若要取消等待操作(即不再执行WaitOrTimerCallback委托),可调用RegisterWaitForSingleObject()方法返回的RegisteredWaitHandle的 Unregister 方法。

如果您知道调用方的堆栈与在排队任务执行期间执行的所有安全检查不相关,则还可以使用不安全的方法 ThreadPool.UnsafeQueueUserWorkItem 和 ThreadPool.UnsafeRegisterWaitForSingleObject。QueueUserWorkItem 和 RegisterWaitForSingleObject 都会捕获调用方的堆栈,此堆栈将在线程池线程开始执行任务时合并到线程池线程的堆栈中。如果需要进行安全检查,则必须检查整个堆栈,但它还具有一定的性能开销。使用“不安全的”方法调用并不会提供绝对的安全,但它会提供更好的性能。

3.         在一个内核构造可用时调用一个方法

让一个线程不确定地等待一个内核对象进入可用状态,这对线程的内存资源来说是一种浪费。ThreadPool.RegisterWaitForSingleObject()为我们提供了一种方式:在一个内核对象变得可用的时候调用一个方法。

使用需注意:

1)         WaitOrTimerCallback委托参数,该委托接受一个名为timeOutBoolean参数。如果 WaitHandle 在指定时间内没有收到信号(即,超时),则为 true,否则为 false。回调方法可以根据timeOut的值来针对性地采取措施。

2)         名为executeOnlyOnceBoolean参数。传true则表示线程池线程只执行回调方法一次;若传false则表示内核对象每次收到信号,线程池线程都会执行回调方法。等待一个AutoResetEvent对象时,这个功能尤其有用。

3)         RegisterWaitForSingleObject()方法返回一个RegisteredWaitHandle对象的引用。这个对象标识了线程池正在它上面等待的内核对象。我们可以调用它的Unregister(WaitHandle waitObject)方法取消由RegisterWaitForSingleObject()注册的等待操作(WaitOrTimerCallback委托不再执行)Unregister(WaitHandle waitObject)WaitHandle参数表示成功取消注册的等待操作后线程池会向此对象发出信号(set()),若不想收到此通知可以传递null

         示例:

        private static void Example_RegisterWaitForSingleObject()
        {
            // 加endWaitHandle的原因:如果执行过快退出方法会导致一些东西被释放,造成排队的任务不能执行,原因还在研究
            AutoResetEvent endWaitHandle = new AutoResetEvent(false);

            AutoResetEvent notificWaitHandle = new AutoResetEvent(false);
            AutoResetEvent waitHandle = new AutoResetEvent(false);
            RegisteredWaitHandle registeredWaitHandle = ThreadPool.RegisterWaitForSingleObject(
                waitHandle,
                (Object state, bool timedOut) =>
                {
                    if (timedOut)
                        Console.WriteLine("RegisterWaitForSingleObject因超时而执行");
                    else
                        Console.WriteLine("RegisterWaitForSingleObject收到WaitHandle信号");
                },
                null, TimeSpan.FromSeconds(2), true
             );

            // 取消等待操作(即不再执行WaitOrTimerCallback委托)
            registeredWaitHandle.Unregister(notificWaitHandle);

            // 通知
            ThreadPool.RegisterWaitForSingleObject(
                notificWaitHandle,
                (Object state, bool timedOut) =>
                {
                    if (timedOut)
                        Console.WriteLine("第一个RegisterWaitForSingleObject没有调用Unregister()");
                    else
                        Console.WriteLine("第一个RegisterWaitForSingleObject调用了Unregister()");

                    endWaitHandle.Set();
                },
                null, TimeSpan.FromSeconds(4), true
             );

            endWaitHandle.WaitOne();
        }

 

执行上下文

         上一小节中说到:线程池最大线程数设置过大可能会造成Windows频繁执行上下文切换,降低程序性能。对于大多数园友不会满意这样的回答,我和你一样也喜欢“知其然,再知其所以然”。

1.         上下文切换中的“上下文”是什么?

.NET中上下文太多,我最后得出的结论是:上下文切换中的上下文专指“执行上下文”。

执行上下文包括:安全上下文、同步上下文(System.Threading.SynchronizationContext)、逻辑调用上下文(System.Runtime.Messaging.CallContext)。即:安全设置(压缩栈、Thread的Principal属性和Windows身份)、宿主设置(System.Threading.HostExcecutingContextManager)以及逻辑调用上下文数据(System.Runtime.Messaging.CallContext的LogicalSetData()和LogicalGetData()方法)。

2.         何时执行“上下文切换”?

当一个“时间片”结束时,如果Windows决定再次调度同一个线程,那么Windows不会执行上下文切换。如果Windows调度了一个不同的线程,这时Windows执行线程上下文切换。

3.         “上下文切换”造成的性能影响

         当Windows上下文切换到另一个线程时,CPU将执行一个不同的线程,而之前线程的代码和数据还在CPU的高速缓存中,(高速缓存使CPU不必经常访问RAM,RAM的速度比CPU高速缓存慢得多),当Windows上下文切换到一个新线程时,这个新线程极有可能要执行不同的代码并访问不同的数据,这些代码和数据不在CPU的高速缓存中。因此,CPU必须访问RAM来填充它的高速缓存,以恢复高速执行状态。但是,在其“时间片”执行完后,一次新的线程上下文切换又发生了。

上下文切换所产生的开销不会换来任何内存和性能上的收益。执行上下文所需的时间取决于CPU架构和速度(即“时间片”的分配)。而填充CPU缓存所需的时间取决于系统运行的应用程序、CPU、缓存的大小以及其他各种因素。所以,无法为每一次线程上下文切换的时间开销给出一个确定的值,甚至无法给出一个估计的值。唯一确定的是,如果要构建高性能的应用程序和组件,就应该尽可能避免线程上下文切换。

除此之外,执行垃圾回收时,CLR必须挂起(暂停)所有线程,遍历它们的栈来查找根以便对堆中的对象进行标记,再次遍历它们的栈(有的对象在压缩期间发生了移动,所以要更新它们的根),再恢复所有线程。所以,减少线程的数量也会显著提升垃圾回收器的性能。每次使用一个调试器并遇到一个断点,Windows都会挂起正在调试的应用程序中的所有线程,并在单步执行或运行应用程序时恢复所有线程。因此,你用的线程越多,调试体验也就越差。

4.         监视Windows上下文切换工具

Windows实际记录了每个线程被上下文切换到的次数。可以使用像Microsoft Spy++这样的工具查看这个数据。这个工具是Visual Studio附带的一个小工具(vs按安装路径\Visual Studio 2012\Common7\Tools),如图

clip_image002

5.         执行上下文类详解

《异步编程:线程概述及使用》中我提到了Thread的两个上下文,即:

1)         CurrentContext        获取线程正在其中执行的当前上下文。主要用于线程内部存储数据。

2)         ExecutionContext    获取一个System.Threading.ExecutionContext对象,该对象包含有关当前线程的各种上下文的信息。主要用于线程间数据共享。

其中获取到的System.Threading.ExecutionContext就是本小节要说的“执行上下文”。

public sealed class ExecutionContext : IDisposable, ISerializable
{
    public void Dispose();
    public void GetObjectData(SerializationInfo info, StreamingContext context);

    // 此方法对于将执行上下文从一个线程传播到另一个线程非常有用。
    public ExecutionContext CreateCopy();
    // 从当前线程捕获执行上下文的一个副本。
    public static ExecutionContext Capture();
    // 在当前线程上的指定执行上下文中运行某个方法。
    public static void Run(ExecutionContext executionContext, ContextCallback callback, object state);

    // 取消执行上下文在异步线程之间的流动。
    public static AsyncFlowControl SuppressFlow();
    public static bool IsFlowSuppressed();
    // RestoreFlow  撤消以前的 SuppressFlow 方法调用的影响。
    // 此方法由 SuppressFlow 方法返回的 AsyncFlowControl 结构的 Undo 方法调用。
    // 应使用 Undo 方法(而不是 RestoreFlow 方法)恢复执行上下文的流动。
    public static void RestoreFlow();
}

ExecutionContext 类提供的功能让用户代码可以在用户定义的异步点之间捕获和传输此上下文。公共语言运行时(CLR)确保在托管进程内运行时定义的异步点之间一致地传输 ExecutionContext。

每当一个线程(初始线程)使用另一个线程(辅助线程)执行任务时,CLR会将前者的执行上下文流向(复制到)辅助线程(注意这个自动流向是单方向的)。这就确保了辅助线程执行的任何操作使用的是相同的安全设置和宿主设置。还确保了初始线程的逻辑调用上下文可以在辅助线程中使用。

但执行上下文的复制会造成一定的性能影响。因为执行上下文中包含大量信息,而收集所有这些信息,再把它们复制到辅助线程,要耗费不少时间。如果辅助线程又采用了更多地辅助线程,还必须创建和初始化更多的执行上下文数据结构。

所以,为了提升应用程序性能,我们可以阻止执行上下文的流动。当然这只有在辅助线程不需要或者不访问上下文信息的时候才能进行阻止。

下面给出一个示例为了演示:

1)         在线程间共享逻辑调用上下文数据(CallContext)。

2)         为了提升性能,阻止\恢复执行上下文的流动。

3)         在当前线程上的指定执行上下文中运行某个方法。

 

        private static void Example_ExecutionContext()
        {
            CallContext.LogicalSetData("Name", "小红");
            Console.WriteLine("主线程中Name为:{0}", CallContext.LogicalGetData("Name"));

            // 1)	在线程间共享逻辑调用上下文数据(CallContext)。
            Console.WriteLine("1)在线程间共享逻辑调用上下文数据(CallContext)。");
            ThreadPool.QueueUserWorkItem((Object obj) 
                => Console.WriteLine("ThreadPool线程中Name为:\"{0}\"", CallContext.LogicalGetData("Name")));
            Thread.Sleep(500);
            Console.WriteLine();
            // 2)	为了提升性能,取消\恢复执行上下文的流动。
            ThreadPool.UnsafeQueueUserWorkItem((Object obj)
                => Console.WriteLine("ThreadPool线程使用Unsafe异步执行方法来取消执行上下文的流动。Name为:\"{0}\""
                , CallContext.LogicalGetData("Name")), null);
            Console.WriteLine("2)为了提升性能,取消/恢复执行上下文的流动。");
            AsyncFlowControl flowControl = ExecutionContext.SuppressFlow();
            ThreadPool.QueueUserWorkItem((Object obj) 
                => Console.WriteLine("(取消ExecutionContext流动)ThreadPool线程中Name为:\"{0}\"", CallContext.LogicalGetData("Name")));
            Thread.Sleep(500);
            // 恢复不推荐使用ExecutionContext.RestoreFlow()
            flowControl.Undo();
            ThreadPool.QueueUserWorkItem((Object obj) 
                => Console.WriteLine("(恢复ExecutionContext流动)ThreadPool线程中Name为:\"{0}\"", CallContext.LogicalGetData("Name")));
            Thread.Sleep(500);
            Console.WriteLine();
            // 3)	在当前线程上的指定执行上下文中运行某个方法。(通过获取调用上下文数据验证)
            Console.WriteLine("3)在当前线程上的指定执行上下文中运行某个方法。(通过获取调用上下文数据验证)");
            ExecutionContext curExecutionContext = ExecutionContext.Capture();
            ExecutionContext.SuppressFlow();
            ThreadPool.QueueUserWorkItem(
                (Object obj) =>
                {
                    ExecutionContext innerExecutionContext = obj as ExecutionContext;
                    ExecutionContext.Run(innerExecutionContext, (Object state) 
                        => Console.WriteLine("ThreadPool线程中Name为:\"{0}\""
                       , CallContext.LogicalGetData("Name")), null); } , curExecutionContext ); }

结果如图:

clip_image004

         注意:

1)         示例中“在当前线程上的指定执行上下文中运行某个方法”:代码中必须使用ExecutionContext.Capture()获取当前执行上下文的一个副本

a)         若直接使用Thread.CurrentThread.ExecutionContext则会报“无法应用以下上下文: 跨 AppDomains 封送的上下文、不是通过捕获操作获取的上下文或已作为 Set 调用的参数的上下文。”错误。

b)         若使用Thread.CurrentThread.ExecutionContext.CreateCopy()会报“只能复制新近捕获(ExecutionContext.Capture())的上下文”。

2)         取消执行上下文流动除了使用ExecutionContext.SuppressFlow()方式外。还可以通过使用ThreadPool的UnsafeQueueUserWorkItem 和 UnsafeRegisterWaitForSingleObject来执行委托方法。原因是不安全的线程池操作不会传输压缩堆栈。每当压缩堆栈流动时,托管的主体、同步、区域设置和用户上下文也随之流动。

 

线程池线程中的异常

线程池线程中未处理的异常将终止进程。以下为此规则的三种例外情况:
1. 由于调用了 Abort,线程池线程中将引发ThreadAbortException。
2. 由于正在卸载应用程序域,线程池线程中将引发AppDomainUnloadedException。
3. 公共语言运行库或宿主进程将终止线程。

何时不使用线程池线程

现在大家都已经知道线程池为我们提供了方便的异步API及托管的线程管理。那么是不是任何时候都应该使用线程池线程呢?当然不是,我们还是需要“因地制宜”的,在以下几种情况下,适合于创建并管理自己的线程而不是使用线程池线程:

1.         需要前台线程。(线程池线程“始终”是后台线程)

2.         需要使线程具有特定的优先级。(线程池线程都是默认优先级,“不建议”进行修改)

3.         任务会长时间占用线程。由于线程池具有最大线程数限制,因此大量占用线程池线程可能会阻止任务启动。

4.         需要将线程放入单线程单元(STA)。(所有ThreadPool线程“始终”是多线程单元(MTA)中)

5.         需要具有与线程关联的稳定标识,或使某一线程专用于某一任务。

 

 

  本博文介绍线程池以及其基础对象池,ThreadPool类的使用及注意事项,如何排队工作项到线程池,执行上下文及线程上下文传递问题…… 

线程池虽然为我们提供了异步操作的便利,但是它不支持对线程池中单个线程的复杂控制致使我们有些情况下会直接使用Thread。并且它对“等待”操作、“取消”操作、“延续”任务等操作比较繁琐,可能迫使你从新造轮子。微软也想到了,所以在.NET4.0的时候加入了“并行任务”并在.NET4.5中对其进行改进,想了解“并行任务”的园友可以先看看《(译)关于Async与Await的FAQ》

本节到此结束,感谢大家的观赏。赞的话还请多推荐啊 (*^_^*)

 

 

 

 

参考资料:《CLR via C#(第三版)》

posted on 2012-12-23 20:54  滴答的雨  阅读(24560)  评论(57编辑  收藏  举报