异步编程:线程概述及使用=============C#.Net 篇目录==============
从此图中我们会发现 .NET 与C# 的每个版本发布都是有一个“主题”。即:C#1.0托管代码→C#2.0泛型→C#3.0LINQ→C#4.0动态语言→C#5.0异步编程。现在我为最新版本的“异步编程”主题写系列分享,期待你的查看及点评。 1. 异步编程:线程概述及使用 2. 异步编程:使用线程池管理线程 3. 异步编程:同步基元对象 4. 异步编程:并行任务Task 5. 异步编程:.NET1.0异步编程模型(APM) 6. 异步编程:.NET 2.0基于事件的异步编程模式(EAP) 7. 异步编程:.NET 4.5基于任务的异步编程模式(TAP) 8. 异步编程:憋啊憋啊憋啊(总结)
开始:《异步编程:线程概述及使用》
做交互式客户端应用程序,用户总希望程序能时刻响应UI操作;做高性能服务器开发,使用者总希望服务器能同时处理多个请求……等等,这时我们可以使用多线程技术来保证UI线程可响应、提高服务器吞吐量、提升程序处理速度,设置任务优先级进行调度…… 多线程技术只是多个线程在操作系统分配的不同时间片里执行,并不是程序开12个线程12个线程都在同一个 “时间点”执行,同一“时间点”能执行多少线程由CPU决定,各个执行线程的衔接由操作系统进行调度。 在认识线程前,我们需要了解下CPU,了解下进程。
多核心CPU超线程CPU 1. 多核心处理器(CPU) 指在一块处理器(CPU)中含有多个处理单元,每一个处理单元它就相当于一个单核处理器(CPU)。因此,多核处理器的功能就相当于多台单核处理器电脑联机作战。 2. 超线程处理器(CPU) 指在一块CPU中,用虚拟的方法将一个物理核心模拟成多个核心(一般情况是一个单物理核心,模拟成二个核心,也即所谓的二线程。只有当线程数比物理核心数多才能叫超线程。如四核四线程并不是超线程,而四核八线程才能叫超线程)。 3. 优缺点: 1) 多核心是真正的物理核心,一块多核心的处理器(CPU),就相当于多块单核心的处理器(CPU)相互协作。因此,从理论上说,多核心比超线程具有更高运算能力。虽然多核心比超线程的运算速度快很多,但多核心也有一个明显的缺点,那就是多核心的使用效率比超线程处理器(CPU)低。因为,多核心在处理数据时,它们相互“合作”的并不是很完美,常常某个核心需要等待其他核心的计算数据,从而耽误时间,被迫怠工。另外,由于目前多核心都是采用共享三级缓存,这更使多核心的CPU运算速度减慢不少。 2) 超线程是用虚拟的方法将一个物理核心虚拟成多个核心,它能够最大限度地利用现有的核心资源,具有较高性价比。
操作系统对多核处理器的优化 主要体现在调度和中断上: 1. 对任务的分配进行优化。使同一应用程序的任务尽量在同一个核上执行。 2. 对任务的共享数据优化。由于多核处理器(Chip Multi-Processor,CMP)体系结构共享二级缓存(目前),可以考虑改变任务在内存中的数据分布,使任务在执行时尽量增加二级缓存的命中率。 3. 对任务的负载均衡优化。当任务在调度时,出现了负载不均衡,考虑将较忙处理器中与其他任务最不相关的任务迁移,以达到数据的冲突最小。
进程和线程 1. 进程 进程是应用程序的执行实例,每个进程是由私有的虚拟地址空间、代码、数据和其它各种系统资源组成,进程在运行过程中创建的资源随着进程的终止而被销毁,所使用的系统资源在进程终止时被释放或关闭。 2. 线程 线程是进程内部的一个执行单元。系统创建好进程后,实际上就启动执行了该进程的主执行线程。主执行线程终止了,进程也就随之终止。 每个线程都维护异常处理程序、调度优先级和线程上下文。(线程上下文,当前执行的线程在其时间片结束时被挂起,而另一个线程继续运行。当系统从一个线程切换到另一个线程时,它将保存被抢先的线程的线程上下文,并重新加载线程队列中下一个线程的已保存线程上下文) 3. 关系 操作系统使用进程将它们正在执行的不同应用程序分开,.NET Framework 将操作系统进程进一步细分为System.AppDomain (应用程序域)的轻量托管子进程。 线程是CPU的调度单元,是进程中的执行单位,一个进程中可以有多个线程同时执行代码。
线程Thread类详解
1. 常用属性 1) ExecutionContext 获取一个System.Threading.ExecutionContext对象,该对象包含有关当前线程的各种上下文的信息。 2) IsThreadPoolThread 获取一个值,该值指示线程是否属于托管线程池。 3) ManagedThreadId 获取一个整数,表示此托管线程的唯一标识符。 4) IsBackground 获取或设置一个值,该值指示某个线程是否为后台线程。 前台线程和后台线程并不等同于主线程和工作线程,如果所有的前台线程终止,那所有的后台线程也会被自动终止。 默认情况下:通过Thread.Start()方法开启的线程都默认为前台线程。可以设置IsBackground属性将线程配置为后台线程。 属于托管线程池的线程(即其 IsThreadPoolThread 属性为 true 的线程)是后台线程。从非托管代码进入托管执行环境的所有线程都被标记为后台线程。 5) IsAlive 如果此线程已启动并且尚未正常终止或中止,则为 true;否则为 false。 2. 创建线程
Thread包含使用ThreadStart或ParameterizedThreadStart委托做参数的构造函数,这些委托包装调用Start()时由新线程执行的方法。 线程一旦启动,就不必保留对Thread对象的引用。线程会继续执行直到线程所调用委托执行完毕。 1) 向线程传递数据(见示例) 我们可以直接使用接收ParameterizedThreadStart参数Thread构造函数创建新线程,再通过Start(object parameter)传入参数并启动线程。由于Start方法接收任何对象,所以这并不是一种类型安全的实现。 所以我们可以使用一种替代方案:将线程执行的方法和待传递数据封装在帮助器类中,使用无参的Start()启动线程。必要的时候需在帮助器类中使用同步基元对象避免线程共享数据的死锁和资源争用。 2) 使用回调方法检索数据(见示例) Thread构造函数接收的ThreadStart或ParameterizedThreadStart委托参数,这两个委托的声明都是返回void,即线程执行完后不会有数据返回(实际上主线程也不会等待Thread创建的新线程返回,否则创建新线程就无意义了)。那么如何在异步执行完时做出响应呢?使用回调方法。
示例----关键代码(详见Simple4CallBackWithParam()):
3. 调度线程 使用Thread.Priority属性获取或设置任何线程的优先级。优先级:Lowest <BelowNormal< Normal <AboveNormal< Highest
每个线程都具有分配给它的线程优先级。在公共语言运行库中创建的线程最初分配的优先级为ThreadPriority.Normal。在运行库外创建的线程会保留它们在进入托管环境之前所具有的优先级。 线程是根据其优先级而调度执行的。所有线程都是由操作系统分配处理器时间片的,如果具有相同优先级的多个线程都可用,则计划程序将遍历处于该优先级的线程,并为每个线程提供一个“固定的时间片”来执行,执行完“固定的时间片”后就切换线程,若当前任务还未执行完,则必须等待下一次的调度。 低优先级的线程并不是被阻塞直到较高优先级的线程完成,低优先级的线程只是在相同时间间隔被CPU调度的次数相对较少。
4. 线程状态 Thread.ThreadState属性提供一个位掩码,用它指示线程的当前状态。
由于 Running 状态的值为 0 (枚举的默认值),因此不可能执行位测试来发现此状态。但可以使用此测试(以伪代码表示):if ((state & (Unstarted | Stopped)) == 0){} 线程可以同时处于多个状态中。例如,如果某个线程在 Monitor.Wait 调用被阻止,并且另一个线程对同一个线程调用 Abort,则该线程将同时处于 WaitSleepJoin 和 AbortRequested 状态。在这种情况下,一旦该线程从对 Wait 的调用返回或该线程中断,它就会收到 ThreadAbortException。
5. 线程状态操作方法 操作:Start(),Abort(),Suspend(),Resume(), Join(),Interrupt()以及静态方法Sleep()和ResetAbort()
线程操作与线程状态对应的表和图如下:
1) 开始线程 调用Start()开始一个线程。一旦线程由于调用 Start 而离开 Unstarted 状态,那么它将无法再返回到 Unstarted 状态(最后被销毁)。 2) 线程销毁及取消销毁 调用线程的Abort()实例方法可以销毁目标线程实例,调用Thread.ResetAbort() 来取消线程销毁。() 请注意: a) 异常是在目标线程捕获,而不是主线程的try-catch-finally。 b) 是“可以”销毁目标线程实例,不能保证线程会结束。因为 l 目标线程可捕捉 ThreadAbortException 异常并在此catch块中调用Thread.ResetAbort() 来取消线程销毁,取消后try块外面的代码可正常运行。 l 在finally块中可以执行任意数量的代码(在finally中调用Thread.ResetAbort()不能取消线程的销毁),若不给予超时设置也无法保证线程会结束。 c) 注意Abort()后要在catch或finally中清理对象。 d) 如果您希望一直等到被终止的线程结束,可以调用Thread.Join()方法。Join 是一个模块化调用,它直到线程实际停止执行时才返回。 e) 如果调用线程的 Abort 方法时线程正在执行非托管代码,则运行库将其标记为ThreadState.AbortRequested。待线程返回到托管代码时引发ThreadAbortException异常。 f) 一旦线程被中止ThreadState.Stoped,它将无法重新启动。
示例----关键代码(详见Simple4Abort())
输出: 若在catch中没有调用Thread.ResetAbort(),哪么try块外面的代码就不会输出(详见输出截图的两处红线)。 3) 阻塞线程 调用Sleep()方法来立即挂起(阻塞)当前线程指定时间。 调用Thread.Sleep(Timeout.Infinite)将使线程休眠,直到其他运行线程调用 Interrupt ()中断处于WaitSleepJoin线程状态的线程,或调用Abort()中止线程。 应用实例:轮询休眠 while (!proceed) Thread.Sleep (x); // "轮询休眠!" 4) 线程的挂起和唤醒 可结合Suspend()与Resume()来挂起和唤醒线程,这两方法已过时。 当对某线程调用Suspend()时,系统会让该线程执行到一个安全点,然后才实际挂起该线程(与Thread.Sleep()不同, Suspend()不会导致线程立即停止执行)。无论调用了多少次 Suspend(),调用Resume()均会使另一个线程脱离挂起状态,并导致该线程继续执行。 注意:由于Suspend()和Resume()不依赖于受控制线程的协作,因此,它们极具侵犯性并且会导致严重的应用程序问题,如死锁(例如,如果您在安全权限评估期间挂起持有锁的线程,则AppDomain中的其他线程可能被阻止。如果您在线程正在执行类构造函数时挂起它,则AppDomain中试图使用该类的其他线程将被阻止。很容易发生死锁)。 线程的安全点: 是线程执行过程中可执行垃圾回收的一个点。垃圾回收器在执行垃圾回收时,运行库必须挂起除正在执行回收的线程以外的所有线程。每个线程在可以挂起之前都必须置于安全点。 5) Join() 在线程A中调用线程B的Join()实例方法。在继续执行标准的 COM 和 SendMessage 消息泵处理期间,线程A将被阻塞,直到线程B终止为止。 6) Interrupt() 中断处于WaitSleepJoin线程状态的线程。如果此线程当前未阻塞在等待、休眠或联接状态中,则下次开始阻塞时它将被中断并引发ThreadInterruptedException异常。 线程应该捕获ThreadInterruptedException并执行任何适当的操作以继续运行。如果线程忽略该异常,则运行库将捕获该异常并停止该线程。 如果调用线程的 Interrupt()方法时线程正在执行非托管代码,则运行库将其标记为ThreadState.SuspendRequested。待线程返回到托管代码时引发ThreadInterruptedException异常。 6. SpinWait(int iterations) SpinWait实质上会将处理器置于十分紧密的自旋转中,当前线程一直占用CPU,其循环计数由 iterations 参数指定。 SpinWait并不是一个阻止的方法:一个处于spin-waiting的线程的ThreadState不是WaitSleepJoin状态,并且也不会被其它的线程过早的中断(Interrupt)。SpinWait很少被使用,它的作用是等待一个在极短时间(可能小于一微秒)内可准备好的可预期的资源,而避免调用Sleep方法阻止线程而浪费CPU时间。 优点:避免线程上下文切换的耗时操作。 缺点:CPU不能很好的调度CPU利用率。这种技术的优势只能在多处理器计算机上体现,对单一处理器的电脑,直到轮询的线程结束了它的时间片之前,别的资源无法获得cpu调度执行。 7. 设置和获取线程的单元状态
1) 可使用ApartmentState获取和设置线程的单元状态,次属性已经过时 2) SetApartmentState()+TrySetApartmentState()+GetApartentState() 可以标记一个托管线程以指示它将承载一个单线程或多线程单元。如果未设置该状态,则GetApartmentState返回ApartmentState.Unknown。只有当线程处于ThreadState.Unstarted状态时(即线程还未调用Start()时)才可以设置该属性;一个线程只能设置一次。 如果在启动线程之前未设置单元状态,则该线程被初始化为默认多线程单元 (MTA)。(终结器线程和由ThreadPool控制的所有线程都是 MTA) 要将主应用程序线程的单元状态设置为ApartmentState.STA的唯一方法是将STAThreadAttribute属性应用到入口点方法。 线程使用托管线程本地存储区 (TLS,Thread-Local Storage)来存储线程特定的数据,托管 TLS 中的数据都是线程和应用程序域组合所独有的,其他任何线程(即使是子线程)都无法获取这些数据。 公共语言运行库在创建每个进程时给它分配一个多槽数据存储区数组,数据槽包括两种类型:命名槽和未命名槽。 1) 若要创建命名数据槽,使用 Thread.AllocateNamedDataSlot() 或 Thread.GetNamedDataSlot() 方法。命名数据槽数据必须使用Thread.FreeNamedDataSlot()来释放。 在任何线程调用Thread.FreeNamedDataSlot()之后,后面任何线程使用相同名称调用Thread.GetNamedDataSlot()都将返回新槽。但是,任何仍具有以前通过调用Thread.GetNamedDataSlot()返回的System.LocalDataStoreSlot引用的线程可以继续使用旧槽。 只有当调用Thread.FreeNamedDataSlot()之前获取的所有LocalDataStoreSlot已被释放并进行垃圾回收之后,与名称关联的槽才会被释放。 2) 若要获取对某个现有命名槽的引用,将其名称传递给 Thread.GetNamedDataSlot() 方法。 3) 若要创建未命名数据槽,使用 Thread.AllocateDataSlot() 方法。未命名数据槽数据在线程终止后释放。 4) 对于命名槽和未命名槽,使用 Thread.SetData() 和 Thread.GetData() 方法设置和检索槽中的信息。 命名槽可能很方便,因为您可以在需要它时通过将其名称传递给 GetNamedDataSlot 方法来检索该槽,而不是维护对未命名槽的引用。但是,如果另一个组件使用相同的名称来命名其线程相关的存储区,并且有一个线程同时执行来自您的组件和该组件的代码,则这两个组件可能会破坏彼此的数据。(本方案假定这两个组件在同一应用程序域内运行,并且它们并不用于共享相同数据。) 为了获得更好的性能,请改用以 System.ThreadStaticAttribute特性标记的线程相关的静态字段。 9. 原子操作 由于编译器,或者CPU的优化,可能导致程序执行的时候并不是真正的按照代码顺序执行。在多线程开发的时候可能会引起错误。 在debug模式下,编译器不会做任何优化,而当Release后,编译器做了优化,此时就会出现问题。 1) Thread.MemoryBarrier() ----原子操作实现原理 按如下方式同步内存存取:执行当前线程的处理器在对指令重新排序时,不能采用先执行 Thread.MemoryBarrier()调用之后的内存存取,再执行 Thread.MemoryBarrier() 调用之前的内存存取的方式。 2) Thread.VolatileRead()+Thread.VolatileWrite() (内部使用MemoryBarrier()内存屏障) a) VolatileRead() 读取字段值。无论处理器的数目或处理器缓存的状态如何,该值都是由计算机的任何处理器写入的最新值。 b) VolatileWrite () 立即向字段写入一个值,以使该值对计算机中的所有处理器都可见。 3) 关键字Volatile: volatile关键字,确保JIT编译器对易失字段都以易失读取或者易失写入的方法执行,不用显示调用Thread的VolatileRead和VolatileWrite。 volatile关键字会指导编译器自动的为读写字段加屏障(MemoryBarrier) 10. BeginCriticalRegion()+EndCriticalRegion() (Critical:关键性的) 若要通知宿主代码进入关键区域,调用BeginCriticalRegion。当执行返回到非关键代码区域时,调用EndCriticalRegion。 公共语言运行库 (CLR) 的宿主可在关键代码区域和非关键代码区域建立不同的失败策略。关键区域是指线程中止或未处理异常的影响可能不限于当前任务的区域。相反,非关键代码区域中的中止或失败只对出现错误的任务有影响。 当关键区域中出现失败时,宿主可能决定卸载整个AppDomain,而不是冒险在可能不稳定的状态下继续执行。 例如,假设有一个尝试在占有锁时分配内存的任务。如果内存分配失败,则中止当前任务并不足以确保AppDomain的稳定性,原因是域中可能存在其他等待同一个锁的任务。如果终止当前任务,则可能导致其他任务死锁。 11. BeginThreadAffinity()+EndThreadAffinity() (Affinity:喜爱,密切关系) 使用BeginThreadAffinity和EndThreadAffinity方法通知宿主代码块依赖于物理操作系统线程的标识。 公共语言运行库的某些宿主提供其自己的线程管理。提供其自己的线程管理的宿主可以在任何时候将正在执行的任务从一个物理操作系统线程移至另一个物理操作系统线程。大多数任务不会受此切换影响。但是,某些任务具有【线程关联】 -- 即它们依赖于物理操作系统线程的标识。这些任务在其执行“不应被切换的代码”时必须通知宿主。 例如,如果应用程序调用系统 API 以获取具有【线程关联】的操作系统锁(如 Win32 CRITICAL_SECTION),则必须在获取该锁之前调用BeginThreadAffinity,并在释放该锁之后调用EndThreadAffinity。 还必须在从WaitHandle继承的任何 .NET Framework 类型上发生阻止之前调用BeginThreadAffinity,因为这些类型依赖于操作系统对象。
线程本地存储区和线程相关的静态字段 可以使用托管线程本地存储区 (TLS,Thread-Local Storage) 和线程相关的静态字段来存储某一线程和应用程序域所独有的数据。 a) 如果可以在编译时预料到确切需要,请使用线程相关的静态字段。 b) 如果只能在运行时发现实际需要,请使用数据槽。 为了获得更好的性能,请尽量改用以 System.ThreadStaticAttribute特性标记的线程相关的静态字段。 无论是使用线程相关的静态字段还是使用数据槽,托管 TLS 中的数据都是线程和应用程序域组合所独有的。 a) 在应用程序域内部,一个线程不能修改另一个线程中的数据,即使这两个线程使用同一个字段或槽时也不能。 b) 当线程从多个应用程序域中访问同一个字段或槽时,会在每个应用程序域中维护一个单独的值。 1) 线程相关的静态字段(编译时) 如果您知道某类型的字段【总是某个线程和应用程序域组合】所独有的(即不是共享的),则使用ThreadStaticAttribute修饰静态字段(static)。 需要注意的是,任何类构造函数代码都将在访问该字段的第一个上下文中的第一个线程上运行。在所有其他线程或上下文中,如果这些字段是引用类型,将被初始化为 null;如果这些字段是值类型,将被初始化为它们的默认值。因此,不应依赖于类构造函数来初始化线程相关的静态字段[ThreadStatic]。相反,应总是假定与线程相关的静态字段被初始化为 null 或它们的默认值。 2) 数据槽(运行时)
本节就此结束,谢谢大家查看,一起学习一起进步。 看完后你会发现如果程序任务小而多会造成不断的创建和销毁线程不便于线程管理;你可能还会发现当线程操作共享资源的时候没有控制资源的同步问题……在后续章节中会陆续引入线程池和同步基元对象解决相应问题,敬请查看。
参考资料 扩展知识: |
|