版权声明:本文为博主原创文章,欢迎转载。
除非另有说明,否则所有的例子都假定以下命名空间被引用:
using System;
using System.Threading;
1 基本概念
在描述多线程之前,首先需要明确一些基本概念。
1.1 进程
进程指一个应用程序所运行的操作系统单元,它是操作系统环境中的基本成分、是系统进行资源分配的基本单位。它最初定义在Unix等多用户、多任务操作系统环境下,用于表示应用程序在内存环境中执行单元的概念。
进程是执行程序的实例。当运行一个应用程序后,就生成了一个进程,这个进程拥有自己的独立内存空间。每一个进程对应一个活动的程序,当进程激活时,操作系统就将系统的资源包括内存、I/O和CPU等分配给它,使它执行。进程在运行时创建的资源随着进程的终止而死亡。
进程间获得专用数据或内存的唯一途径就是通过协议来共享内存块,这是一种协作策略。由于进程之间的切换非常消耗资源和时间,为了提高操作系统的并发性,提高CPU的利用率,在进程下面又加入了线程的概念。
一个Process可以创建多个Thread及子Process(启动外部程序)。
一个进程内部的线程可以共享该进程所分配的资源,线程的创建与撤销、线程之间的切换所占用的资源比进程少很多。
1.2 线程
进程可以分为若干个独立执行流(路径),这些执行流被称为线程。线程是指进程内的一个执行单元,也是进程内的可调度实体。线程是进程的一个实体,是CPU调度和分配时间的基本单位。
线程基本不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器、一组寄存器和栈),但是它可与同一进程的其它线程共享进程所拥有的全部资源。所以线程间共享内存空间很容易做到,多线程协作也很容易和便捷。
一个线程可以创建和撤销另一个线程,同一个进程中的多个线程间可以并发执行。
线程提供了多任务处理的能力。
1.3 线程与进程的异同
地址空间:进程拥有自己独立的内存地址空间;而线程共享进程的地址空间;换句话说就是进程间彼此是完全隔绝的,同一进程的所有线程共享(堆heap)内存;
资源拥有:进程是资源分配和拥有的单位,同一进程内的线程共享进程的资源;
系统粒度:进程是分配资源的基本单位,而线程则是系统(处理器)调度的基本单位;
执行过程:每个独立的进程都有一个程序运行的入口、顺序执行序列和程序的出口;线程不能独立执行,必须依存于进程中;
系统开销:创建或撤销进程时,系统都要为之分配或回收资源(如内存空间、IO设备),进程间的切换也要消耗远大于线程切换的开销。
二者均可并发执行。
一个程序至少有一个进程,一个进程至少有一个线程(主线程)。主线程以函数地址的形式,如Main或WinMain函数,提供程序的启动点,当主线程终止时,进程也随之终止。一个进程中的所有线程都在该进程的虚拟地址空间中,使用该进程的全局变量和系统资源。
1.4 程序域
在.Net中Process由AppDomain对象所取代。
虽然AppDomain在CLR中被视为Process的替代品,但实际上AppDomain跟Process是属于主从关系的,AppDomain被放置在一个Process中,每个Process可以拥有多个AppDomain,每个AppDomain又可拥有多个Thread对象。
Process、AppDomain、Thread的关系如下图所示:
图 1进程、域、线程关系图
AppDomain定义了一些事件供程序员使用。
事件 | 说明 |
AssemblyLoad | 触发于AppDomain载入一个Assembly时 |
DomainUnLoad | 触发于AppDomain卸载时,也就是Unload函数被调用或是该AppDomain被消灭前 |
ProcessExit | 当默认的AppDomain被卸载时触发,多半是应用程序退出时 |
各AppDomain间互不影响。
1.5 并发/并行
在单CPU系统中,系统调度在某一刻只能让一个线程运行,虽然这种调度机制有多种形式(时分/频分),但无论如何,要通过不断切换需要运行的线程,这种运行模式称为并发(Concurrent)。而在多CPU系统中,可以让两个以上的线程同时运行,这种运行模式称为并行(Parallel)。
1.6 异步操作
所有的程序最终都会由计算机硬件来执行,拥有DMA功能的硬件在和内存进行数据交换的时候可以不消耗CPU资源。这些无须消耗CPU时间的I/O操作是异步操作的硬件基础。硬盘、光驱、网卡、声卡、显卡都具有DMA功能。
DMA(DirectMemory Access)是直接内存访问的意思,它是不经过CPU而直接进行内存数据存储的数据交换模式。
I/O操作包括了直接的文件、网络的读写,还包括数据库操作、Web Service、HttpRequest以及.Net Remoting等跨进程的调用。
异步操作可达到避免调用线程堵塞的目的,从而提高软件的可响应性。
1.7 任务管理器
映射名称列:进程并不拥有独立于其所属实例的映射名称;如果运行5个Notepad拷贝,你会看到5个称为Notepad.exe的进程;它们是根据进程ID进行区分的,该进程ID是由系统维护,并可以循环使用。
CPU列:它是进程中线程所占用的CPU时间百分比
每个任务管理器中的进程,其实内部都包含若干个线程,每个时间点都是某个程序进程中的某个线程在运行。
2 多线程基础
2.1 为什么要使用多线程
Ø 并发需要
在C/S或B/S模式下的服务端需要处理来自不同终端的并发请求,使用单线程是不可思议的。
Ø 提高应用程序的响应速度
当一个耗时的操作进行时,当前程序都会等待这个操作结束,此时程序不会响应键盘、鼠标、菜单等操作,程序处于假死状态;使用多线程可将耗时长的操作(Time Consuming)置于一个新的线程,此时程序仍能响应用户的其它操作。
Ø 提高CPU利用率
在多CPU体系中,操作系统会保证当线程数不大于CPU数目时,不同的线程运行于不同的CPU上。
Ø 改善程序结构
一个既长又复杂的进程可以考虑分为多个线程,成为几个独立或半独立的运行部分,这样的程序会利于理解和修改。
Ø 花销小、切换快
线程间的切换时间很小,可以忽略不计
Ø 方便的通信机制
线程间共享内存,互相间交换数据很简单。
多线程的意义在于一个应用程序中,有多个执行部分可以同时执行:一个线程可以在后台读取数据,而另一个线程可以在前台展现已读取的数据。
C#支持通过多线程并行地执行代码,一个线程有它独立的执行路径,能够与其它的线程同时地运行。一个C#程序开始于一个单线程,这个单线程是被CLR和操作系统(也称为“主线程”)自动创建的,并具有多线程创建额外的线程。
2.2 何时使用多线程
多线程程序一般被用来在后台执行耗时的任务:主线程保持运行,而工作线程执行后台工作。
对于Windows Forms程序来说,如果主线程执行了一个冗长的操作,键盘和鼠标的操作会变的迟钝,程序也会失去响应,进入假死的状态,可能导致用户强制结束程序进程而出现错误。有鉴于此,应该在主线程运行一个耗时任务时另外添加一个工作线程,同时在主线程上有一个友好的提示“处理中...”,允许继续接收事件(比如响应鼠标、键盘操作)。同时程序还应该实现“取消”功能,允许取消/结束当前工作线程。BackgroundWorker类就提供这一功能。
在没有用户界面的程序里,比如说WindowsService中使用多线程特别的有意义。当一个任务有潜在的耗时(在等待被请求方的响应——比如应用服务器,数据库服务器),用工作线程完成任务意味着主线程可以在发送请求后立即做其它的事情。
另一个多线程的用途是在需要完成一个复杂的计算工作时。它会在多核的电脑上运行得更快,如果工作量被多个线程分开的话(C#中可使用Environment.ProcessorCount属性来侦测处理芯片的数量)。
一个C#程序成为多线程可以通过2种方式来实现:明确地创建和运行多线程,或者使用.NET Framework中封装了多线程的类——比如BackgroundWorker类。
线程池,Threading Timer,远程服务器,或WebServices或ASP.NET程序将别无选择,必须使用多线程;一个单线程的ASP.NET Web Service是不可想象的;幸运的是,应用服务器中多线程是相当普遍的;唯一值得关心的是提供适当锁机制的静态变量问题。
2.3 何时不用多线程
多线程也同样会带来缺点,最大的问题是它使程序变的过于复杂,拥有多线程本身并不复杂,复杂是的线程的交互作用,无论交互是否是有意的,都会带来较长的开发周期,以及带来间歇性和非重复性的Bugs。因此,要么多线程的交互设计简单一些,要么就根本不使用多线程,除非你有强烈的重写和调试欲望。
当用户频繁地分配和切换线程时,多线程会带来增加资源和CPU的开销。在某些情况下,太多的I/O操作是非常棘手的,当只有一个或两个工作线程要比有众多的线程在相同时间执行任务快的多。
2.4 创建和开始使用多线程
线程可以使用Thread类来创建,通过ThreadStart委托来指明方法从哪里开始运行,下面是ThreadStart委托定义:
public delegate void ThreadStart(); |
调用Start方法后,线程开始运行,线程一直到它所调用的方法返回后结束。下面是一个例子,使用了C#的语法创建TheadStart委托:
Class ThreadTest { static void Main() { Thread t = new Thread(new ThreadStart(Go)); t.Start(); // 在新线程中运行Go() Go(); // 同时在主线程中运行Go() } static void Go() { Console.WriteLine ("hello!"); } |
在这个例子中,线程t执行Go()方法,大约与此同时主线程也调用了Go(),结果是两个几乎同时hello被打印出来:
hello!
hello!
线程有一个IsAlive属性,在调用Start()之后直到线程结束之前一直为true。
一个线程一旦结束便不能重新开始,只能重新创建一个新的线程。
2.5 带参数启动线程
在上面的例子里,我们想更好地区分开每个线程的输出结果,让其中一个线程输出大写字母。我们传入一个状态字到Go中来完成整个任务,但不能使用ThreadStart委托,因为它不接受参数。
2.5.1 ParameterizedThreadStart
.NET framework定义了另一个版本的委托叫做ParameterizedThreadStart,它可以接收一个单独的object类型参数:
public delegate void ParameterizedThreadStart(object obj); |
之前的例子看起来是这样的:
Class ThreadTest { static void Main() { Thread t = new Thread(Go); t.Start (true); // == Go (true) Go (false); } static void Go (object upperCase) { bool upper = (bool) upperCase; Console.WriteLine (upper ? "HELLO!" : "hello!"); }
|
输出结果:
hello!
HELLO!
在整个例子中,编译器自动推断出ParameterizedThreadStart委托,因为Go方法接收一个单独的object参数,就像这样写:
Thread t = new Thread(new ParameterizedThreadStart(Go)); t.Start (true); |
ParameterizedThreadStart的特性是在使用之前我们必需对我们想要的类型(这里是bool)进行装箱操作,并且它只能接收一个参数。
2.5.2 匿名方法
需要接收多个参数的解决方案是使用一个匿名方法调用,方法如下:
static void Main() { Thread t = new Thread(delegate() { WriteText ("Hello"); }); t.Start(); } static void WriteText (stringtext) { Console.WriteLine (text); } |
它的优点是目标方法(这里是WriteText),可以接收任意数量的参数,并且没有装箱操作。不过这需要将一个外部变量放入到匿名方法中,如下示例:
static voidMain() { stringtext = "Before"; Threadt = new Thread(delegate() { WriteText (text); }); text = "After"; t.Start(); } static void WriteText (stringtext) { Console.WriteLine (text); } |
需要注意的是,当外部变量的值被修改,匿名方法可能进行无意的互动,导致一些古怪的现象。一旦线程开始运行,外部变量最好被处理成只读的——除非有人愿意使用适当的锁。
2.5.3 对象实例方法
另一种较常见的方式是将对象实例的方法而不是静态方法传入到线程中,对象实例的属性可以告诉线程要做什么,如下列重写了原来的例子:
Class ThreadTest { Bool upper; static void Main() { ThreadTest instance1 = new ThreadTest(); instance1.upper = true; Thread t = new Thread(instance1.Go); t.Start(); ThreadTest instance2 = new ThreadTest(); instance2.Go(); // 主线程——运行 upper=false } Void Go() { Console.WriteLine (upper ? "HELLO!" : "hello!"); } |
2.6 命名线程
线程可以通过它的Name属性进行命名,这非常有利于调试:可以用Console.WriteLine打印出线程的名字,Microsoft Visual Studio可以将线程的名字显示在调试工具栏的位置上。线程的名字可以在被任何时间设置——但只能设置一次,重命名会引发异常。
程序的主线程也可以被命名,下面例子里主线程通过CurrentThread命名:
Class ThreadNaming { static void Main() { Thread.CurrentThread.Name= "main"; Thread worker = new Thread(Go); worker.Name= "worker"; worker.Start(); Go(); } static void Go() { Console.WriteLine ("Hello from "+ Thread.CurrentThread.Name); } } |
输出
Hellofrom main
Hellofrom worker
图 2 .Net框架中监控线程
上图为.Net框架中监控当前线程,可通过名称找到某个线程,查看它的执行情况。
2.7 前台和后台线程
线程分为两种:用户界面线程(前台线程)和工作线程(后台线程)。
用户界面线程通常用来处理用户的输入并响应各种事件和消息;工作线程用来执行程序的后台处理任务,比如计算、调度、对串口的读写操作等。
线程默认为前台线程,这意味着任何前台线程在运行都会保持程序存活。C#也支持后台线程,当所有前台线程结束后,它们不维持程序的存活。
改变线程从前台到后台不会以任何方式改变它在CPU协调程序中的优先级和状态。
线程的IsBackground属性控制它的前后台状态,如下实例:
Class PriorityTest { static void Main (string[] args) { Thread worker = new Thread(delegate() { Console.ReadLine(); }); if(args.Length > 0) worker.IsBackground= true; worker.Start(); } } |
如果程序被调用的时候没有任何参数,工作线程为前台线程,并且将等待ReadLine语句来等待用户的触发回车,这期间,主线程退出,但是程序保持运行,因为一个前台线程仍然活着。
另一方面如果有参数传入Main(),工作线程被赋值为后台线程,当主线程结束程序立刻退出,终止了ReadLine。
后台线程终止的这种方式,使任何最后操作都被规避了,这种方式是不太合适的。好的方式是明确等待任何后台工作线程完成后再结束程序,可能用一个timeout(大多用Thread.Join)。如果因为某种原因某个工作线程无法完成,可以用试图终止它的方式,如果失败了,再抛弃线程,允许它与进程一起消亡。
拥有一个后台工作线程是有益的,最直接的理由是当提到结束程序它总是可能有最后的发言权。交织以不会消亡的前台线程,保证程序的正常退出。抛弃一个前台工作线程是尤为险恶的,尤其对Windows Forms程序,因为程序直到主线程结束时才退出(至少对用户来说),但是它的进程仍然运行着。在Windows任务管理器它将从应用程序栏消失不见,但却可以在进程栏找到它。除非用户找到并结束它,它将继续消耗资源,并可能阻止一个新的实例的运行从开始或影响它的特性。
对于程序失败退出的普遍原因就是存在“被忘记”的前台线程。
线程类型 | 动作 | 结束 | 后续处理 |
前台线程 | 主程序关闭 | 否 | 显示关闭线程/杀掉当前进程 |
后台线程 | 主程序关闭 | 是 | 无 |
2.8 线程优先级
线程的Priority 属性确定了线程相对于其它同一进程的活动的线程拥有多少执行时间,以下是级别:
enum ThreadPriority{ Lowest, BelowNormal, Normal, AboveNormal, Highest } |
只有多个线程同时为活动时,优先级才有作用。
设置一个线程的优先级为高一些,并不意味着它能执行实时的工作,因为它受限于程序的进程的级别。要执行实时的工作,必须提升在System.Diagnostics 命名空间下Process的级别,像下面这样:
Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High; |
ProcessPriorityClass.High其实是一个短暂缺口的过程中的最高优先级别:Realtime。设置进程级别到Realtime通知操作系统:你不想让你的进程被抢占了。如果你的程序进入一个偶然的死循环,可以预期,操作系统被锁住了,除了关机没有什么可以拯救你了!基于此,High大体上被认为最高的有用进程级别。
如果一个实时的程序有一个用户界面,提升进程的级别是不太好的,因为当用户界面UI过于复杂的时候,界面的更新耗费过多的CPU时间,拖慢了整台电脑。(虽然在写这篇文章的时候,在互联网电话程序Skype侥幸地这么做, 也许是因为它的界面相当简单吧。)降低主线程的级别、提升进程的级别、确保实时线程不进行界面刷新,但这样并不能避免电脑越来越慢,因为操作系统仍会拨出过多的CPU给整个进程。最理想的方案是使实时工作和用户界面在不同的进程(拥有不同的优先级)运行,通过Remoting或共享内存方式进行通信,共享内存需要Win32 API中的 P/Invoking。(可以搜索看看CreateFileMapping 和MapViewOfFile)
2.9 线程异常处理机制
任何线程在创建时使用try/catch/finally语句块都是没有意义的,当线程开始执行便不再与其有任何关系。考虑下面的程序:
public static void Main() { try{ new Thread(Go).Start(); } catch(Exception ex) { // 不会在这得到异常 Console.WriteLine ("Exception!"); } static void Go() { throw null; } } |
示例中的try/catch语句一点用也没有,新创建的线程将引发NullReferenceException异常,而主线程无法捕获到。这是因为每个线程都有独立的执行路径。最好的补救方法是在线程处理的方法内加入异常处理:
public static void Main() { new Thread(Go).Start(); } static void Go() { try{ ... throw null; // 这个异常会被捕捉到 ... } catch(Exceptionex) { 记录异常日志,或通知另一个线程错误发生 ... } |
从.NET 2.0开始,任何线程内未处理的异常都将导致整个程序关闭,这意味着忽略线程异常将是一个灾难。
为了避免由未处理异常引起的程序崩溃,try/catch语句块需要出现在每个线程具体实现的方法内。对于经常使用“全局”异常处理的Windows Forms程序员来说,这将很不习惯,就像下面这样的代码:
Using System; Using System.Threading; Using System.Windows.Forms; static class Program{ static void Main() { Application.ThreadException += HandleError; Application.Run (new MainForm()); } static void HandleError (object sender, ThreadExceptionEventArgs e) { 记录异常或者退出程序或者继续运行... } } |
Application.ThreadException事件在异常被抛出时触发,以一个Windows信息(比如:键盘,鼠标或者 "paint" 等信息)的方式。简而言之,覆盖了一个Windows Forms程序的几乎所有代码异常。这使开发者产生一种虚假的安全感——所有的异常都被主线程异常处理机制捕获。但实际的情况却是,由工作线程抛出的异常不会被Application.ThreadException捕捉到。
.NET framework提供了一个更低级别的异常捕获事件:AppDomain.UnhandledException,这个事件在任何类型的程序(有或没有用户界面)中的任何线程有任何未处理的异常抛出时被触发。尽管它提供了比较完善的异常处理解决机制,但是这并不意味着程序不会崩溃,也不意味着能取消.NET异常对话框。
在产品程序中,明确地使用异常处理在所有线程进入的方法中是必要的,可以使用包装类和帮助类来分解工作来完成任务,比如使用BackgroundWorker类(在第三部分进行讨论) |
2.10 线程是如何工作的
线程被一个线程协调程序管理着——一个CLR委托给操作系统的函数。线程协调程序确保将所有活动的线程被分配适当的执行时间;并且那些等待或阻止的线程——比如说在排它锁中、或在用户输入——都是不消耗CPU时间的。
在单核处理器的电脑中,线程协调程序完成一个时间片之后迅速地在活动的线程之间进行切换执行。这就导致“波涛汹涌”的行为,例如在第一个例子,每次重复的X 或 Y 块相当于分给线程的时间片。在Windows XP中时间片通常在10毫秒内选择要比CPU开销在处理线程切换的时候的消耗大的多。(即通常在几微秒区间)
在多核的电脑中,多线程被实现成混合时间片和真实的并发——不同的线程在不同的CPU上运行。但这仍然会出现一些时间切片,因为操作系统的服务线程、以及一些其他的应用程序都会争夺对CPU的使用权。
线程由于外部因素(比如时间片)被中断被称为被抢占,在大多数情况下,一个线程在被抢占的那一刻就失去了对它的控制权。
2.11 线程安全
当使用线程(Thread)时,程序员必须注意同步处理的问题,理论上每个Thread都是独立运行的个体,由CLR来主导排程,视Thread的优先权的设置,每个Thread会分到特定的运行时间,当某个Thread的运行时间用完时,CLR就会强制将运行权由该Thread取回,转交给下个Thread,这也就意味着Thread本身无法得知自己何时会丧失运行权,所以会发生所谓的race condition(竞速状态)。
当两个线程争夺一个锁的时候(在这个例子里是locker),一个线程等待,或者说被阻止到那个锁变的可用。在这种情况下,就确保了在同一时刻只有一个线程能进入临界区,所以"Done"只被打印了1次。代码以如此方式在不确定的多线程环境中被叫做线程安全。
临时暂停,或阻止是多线程的协同工作,同步活动的本质特征。等待一个排它锁被释放是一个线程被阻止的原因,另一个原因是线程想要暂停或Sleep一段时间:
Thread.Sleep (TimeSpan.FromSeconds (30)); // 阻止30秒 |
一个线程也可以使用它的Join方法来等待另一个线程结束:
Threadt = new Thread(Go); // 假设Go是某个静态方法 t.Start(); t.Join(); // 等待(阻止)直到线程t结束 |
2.12 异步模式对比
线程不是一个计算机的硬件功能,而是操作系统提供的一种逻辑功能,线程本质上是进程中一段并发运行的代码,所以线程需要操作系统投入CPU资源来运行和调度。
异步模式无须额外的线程负担,并且使用回调的方式进行处理,在设计良好的情况下,处理函数可以不必共享变量,减少了死锁的可能。不过,编写异步操作的复杂程度比较高,程序主要使用回调方式进行处理,与人的思维方式有出入,而且难以调试。
计算密集型工作使用多线程(如图形处理、算法);IO密集型工作使用异步机制。
/// <summary> /// 异步调用返回事件 /// </summary> public event OnAsynCallBack OnCallBack = null;
/// <summary> /// 构造函数,用于异步CallBiz /// </summary> /// <param name="onCallBack">异步调用返回事件</param> /// <param name="callId">调用ID</param> /// <param name="timeOutMs">超时(毫秒)</param> /// <param name="bizName">业务名称</param> /// <param name="funName">方法名称</param> /// <param name="argsJson">参数JSON数组</param> public AsynCall(OnAsynCallBack onCallBack, string callId, int timeOutMs,string bizName, string funName,params string[] argsJson) { TimeCall = TimeHelper.GetTicks(); TimeBack = -1; TimeUse = -1;
OnCallBack = onCallBack; ReturnType = null; CallId = callId; TimeOutMs = timeOutMs;
BizName = bizName; FunName = funName; ArgsJson = argsJson;
this.InFiles = OtCom.Thread_ClsInFiles(); } |