CLR线程总结
一、引言
一个应用软件一般对耗时或需要异步执行的操作开辟单独的线程执行,使用多线程有助于提升软件系统的健壮性和可响应性,使得用户体验得到大的提升。本文主要谈在.NET平台中CLR线程基础技术以及在CLR中如何更优化地使用线程技术。
二、CLR线程基础
早期的Windows操作系统,整个系统只运行一个执行线程(当时操作系统没有提供线程概念),如果某个应用程序长时间运行或者陷入死循环,其他程序只能干巴巴地等待,很容易造成整个系统停止工作,只能绝望地重启电脑,更让人疯狂的是,这种情况会造成正在处理的数据无端丢失。后来微软对操作系统内核进行了大量改进,提出了进程(Process)和线程(Thread)的概念。
进程就是一个可执行的程序一次运行的过程,它是系统进行资源分配和调度的一个独立单位。直观地说,你开启了一个应用程序,就启动一个进程,这个进程被分配了保障应用程序顺利执行的各种资源,如内存,CPU,文件句柄,GDI资源等。系统内核为每个进程分配了独立的虚拟空间,这些地址空间只提供给一个应用程序实例使用,而且这个应用程序实例需要的所有资源都是在这个独立的虚拟空间获取,这样的好处是确保程序实例的健壮性,提升了系统安全性,因为虚拟空间独立也意味着程序实例独立,不用担心一个实例破坏另外一个实例的代码和数据,也不用担心一个程序实例的密码用户信息给另外实例读取。但这个时候一个程序死循环,其他程序只能死等的问题还在,因为如果只有一个CPU,那么CPU会被死循环的程序一直占有。为解决这个问题,微软提出线程概念。如果进程是对内存地址空间的虚拟独立,那么线程就是对CPU的虚拟独立,Windows内核为每个进程提供了该进程专用的线程(功能相当于一个CPU,称为逻辑CPU,实际上单CPU的计算机CPU一次只给一个进程使用,所以Windows必须实现逻辑CPU(线程)共享物理CPU,用的机制就是CPU的“时间片”管理,简单地说就是把一个单位时间内的CPU使用划分为很多个更小时间粒度的时间片,每个时间片的时间内CPU只给一个线程使用,Windows的CPU时间片大约是30毫秒),如果某个程序实例代码进入死循环,与之关联的进程冻结,但其他进程可以继续执行。这个时候,我们可以在“任务管理器”上强制终止冻结的应用程序。
创建一个线程,让这个线程进驻到系统到最后销毁这个线程会产生空间内存和执行时间的开销,如某个进程创建一个新的线程时会初始化线程内核对象,会调用这个进程加载的所有DLL的DllMain方法,终止这个线程又会调用这个进程加载的所有DLL的DllMain方法。现在一些大型软件的DLL动辄有数百个,在这样的软件进程中创建线程和销毁线程都会极大影响性能。一方面为了性能考虑,我们必须尽量少创建线程,另一方面,为了使得应用程序更强壮,反应更灵敏,必须使用多线程。CLR提供了一些机制,可以在保持代码响应能力的同时尽量少创建线程,比如线程池技术。
在CLR中创建一个专用线程非常简单,我们有时候需要软件的时候把一些软件用到的基础数据加载到内存中,我们用新的线程来模拟这一过程:
{
class Program
{
static void Main(string[] args)
{
Thread thread = new Thread(n=>LoadBaseData((int)n));
thread.Start(4);
Console.ReadLine();
}
/// <summary>
/// 加载基础数据
/// </summary>
/// <param name="count"></param>
private static void LoadBaseData(int count)
{
for (; count > 0;count-- )
{
Thread.Sleep(1000);//模拟加载基础数据工作
Console.WriteLine("加载完基础数据 {0} \n", count);
}
}
}
}
这里使用了System.Threading.Thread类的常用的构造器public Thread(ParamterizedThreadStart start)来创建Thread的实例,参数start就是线程要执行的方法,这个方法要和委托 delegate void ParamerizedThreadStart(Object obj);的签名匹配。
实例一个Thread对象,并没有在系统中创建一个线程,只有在调用Start方法后才真正创建一个系统线程。下面展示了Thread的一些常用成员。
属性 | 作用 |
CurrentThread | 静态属性,获取当前正在运行的线程 |
IsBackground | 获取或设置一个值,该值指示是否是后台线程 |
Name | 获取或设置线程的名称 |
Priority | 获取或设置一个值,该值指示线程的调度优先级 |
ThreadState | 获取一个值,该值包含当前线程的状态 |
方法 | 说明 |
start | 开始执行线程 |
Abort | 终止线程 |
Interrupt | 打断处于WaitSleepJoin线程状态的线程,使其继续执行 |
Join | 阻止调用线程,直到被调用线程终止为止,它在被调用线程实际停止执行之前或可选超时间隔结束之前不会返回 |
Sleep | 静态方法,使当前线程停止指定的毫秒数 |
这里强调下IsBackground属性,如果Thread的IsBackground为false则为前台线程,为true则为后台线程,默认为false,前台线程和后台线程的区别是,如果一个进程的所有前台都终止时,所有的后台线程也会终止(CLR会自动调用后台线程的Abort方法终止后台线程),不会抛出异常。前台线程能阻止应用程序的终结,一直到所有的前台线程终止后,CLR才能关闭应用程序。
{
class Program
{
static void Main(string[] args)
{
Thread thread = new Thread(n=>LoadBaseData((int)n));
thread.IsBackground = true;
thread.Start(4);
Console.WriteLine("程序退出");
// Console.ReadLine();
}
private static void LoadBaseData(int count)
{
for (; count > 0;count-- )
{
Thread.Sleep(1000);//模拟加载基础数据工作
Console.WriteLine("加载完基础数据 {0} \n", count);
}
}
}
}
运行上面一段代码,可以发现线程的操作还没有完成,整个程序就退出了,因为创建的专有线程为后台线程(IsBackground为true),则前台线程终止,后台线程就立刻终止了,不管它处于什么运行状态。
如果thread是前台线程,则会等待thread线程里面的方法执行完毕才会终止应用程序。
三、线程池技术
因为创建线程和销毁线程对时间和内存都是昂贵的操作,为了改善这个情况,CLR引入了线程池的概念,可将线程池想象成你的应用程序使用的一个线程集合,也就是线程池会自动管理线程(创建,使用,销毁线程),而不需要你显示来创建专用线程。当CLR初始化时,线程池中不包含线程,当应用程序要请求线程池线程来执行任务,线程池知道后安排线程池中一个线程去处理,如果线程池没有空闲的线程或者没有线程,线程池将创建一个初始线程。该新线程经历的初始化和其他线程一样;但是任务完成后,该线程不会自行销毁。相反,它会以挂起状态返回线程池。如果应用程序再次向线程池发出请求,那么这个挂起的线程将激活并执行任务,而不会创建新线程。这节约了很多开销。只要线程池中应用程序任务的排队速度低于一个线程处理每项任务的速度,那么就可以反复重用同一线程,从而在应用程序生存期内节约大量开销。
ThreadPool类定义了两个静态的方法,可以将需要异步的操作加入到线程池的队列中:
Static bool QueueUserWorkItem(WaitCallback callback);
Static bool QueueUserWorkItem(WaitCallback callback,Object,state);
这里的回调方法callback必须和委托delegate void WaitCallback(Object state)签名相匹配。除了静态方法QueueUserWorkItem外,System.Threading 命名空间定义 Timer 类,也定义了四个构造函数将方法加入线程池队列:
public Timer(TimerCallback callback, Object state, Int32 dueTime, Int32 period);
public Timer(TimerCallback callback, Object state,UInt32 dueTime, UInt32 period);
public Timer(TimerCallback callback, Object state,Int64 dueTime, Int64 period);
public Timer(TimerCallback callback, Object state,Timespan dueTime, TimeSpan period);
回调方法必须与 System.Threading.TimerCallback 委托类型相匹配,其定义如下:
public delegate void TimerCallback(Object state);
下面通过程序验证使用线程池是否可以减少创建线程的数量:
1、使用专用线程处理异步操作
{
class Program
{
static void Main(string[] args)
{
for (int i = 0; i < 10; i++)
{
Thread thread = new Thread(n => LoadBaseData((int)n));
thread.IsBackground = true;
thread.Start(1);
}
Console.ReadLine();
}
private static void LoadBaseData(int count)
{
for (; count > 0;count-- )
{
Thread.Sleep(1000);//模拟加载基础数据工作
Console.WriteLine("ManagedThreadId:{0}", Thread.CurrentThread.ManagedThreadId);
}
}
}
}
运行结果可知,创建了10个线程,结果截图如下:
2、使用线程池处理异步操作
{
class Program
{
static void Main(string[] args)
{
for (int i = 0; i < 10; i++)
{
ThreadPool.QueueUserWorkItem(n => LoadBaseData((int) n), 1);
}
Console.ReadLine();
}
private static void LoadBaseData(int count)
{
for (; count > 0;count-- )
{
Thread.Sleep(1000);//模拟加载基础数据工作
Console.WriteLine("ManagedThreadId:{0}", Thread.CurrentThread.ManagedThreadId);
}
}
}
}
看运行结果,使用线程池处理和1相同的异步操作,线程池只创建了5条线程,运行结果如下:
为什么相同的异步操作,线程池只需要更少的线程就能处理,这就要说说线程池管理线程的机制。专用线程创建后执行操作后就自动销毁了,但是线程池里面的线程执行操作不会被销毁,而是返回线程池中,进入空闲状态,等待下一个请求,下一个请求一来,空闲的线程就会唤醒去处理。但是如果下一个请求一直不来呢?这时候就造成线程占用内存的浪费,面对这种情况,CLR会判断一个线程池空闲一段时间后,让线程自己醒来终止自己以释放资源。线程池终止线程会产生性能损失,但这时候因为已经是很闲了,所以这时候性能损失关系不大。
四、任务Task
有时候我们执行一个异步操作需要这个操作什么时候完成,还可能需要获取操作完成的返回值,但是ThreadPool.QueueUserWorkItem方法并没有提供操作完成时返回值的机制,针对这种需求,.NET 4.0新增了Task功能,CLR里提供了任务Task,在FCL里通过System.Threading.Tasks命名空间下的Task类及其泛型版本Task<TResult>来使用任务。
下面代码创建一个任务,并运行任务,最后打印任务的返回值。
{
class Program
{
static void Main(string[] args)
{
Task<int> task = new Task<int>(n => LoadBaseData((int)n),10);
task.Start();
task.Wait();
Console.WriteLine("总共加载{0}份基础数据",task.Result);
Console.ReadLine();
}
private static int LoadBaseData(int count)
{
int result = count;
for (; count > 0;count-- )
{
Thread.Sleep(1000);//模拟加载基础数据工作
Console.WriteLine("ManagedThreadId:{0}", Thread.CurrentThread.ManagedThreadId);
}
return result;
}
}
}
运行结果如下:
上面代码显示调用了Task的Wait方法,Wait方法是显示等待任务完成,如果此时任务已经开始执行,则调用Wait的线程会阻塞,直到任务的完成。如果没有执行,系统可能使用调用Wait的线程来执行Task。Task的Result属性就是操作的返回类型,上面使用Task的泛型版本Task<TResult>,参数TResult指定了操作的返回类型,也就是Task的Result属性的类型。如果上面代码中没有显示调用Task的Wait方法,那么获取Task的Result属性的时候也会调用Wait方法。如果任务的操作抛出一个未处理的异常时,调用Wait方法或者Result属性时,会抛出一个System.AggregationException对象。
五、ThreadPool.QueueUserWorkItem协作式取消和任务取消
ThreadPool.QueueUserWorkItem和System.Threading.Tasks.Task执行异步操作时,都提供了取消操作的机制。有时候一个耗时异步操作执行,发生了某个事件,我们需要取消操作,以免浪费不必要的资源,所以能够取消异步操作是一件很不错的事情。.Net提供的取消操作模式是协作式的,所谓协作式就是你的异步操作执行的方法需要显示支持取消。
FCL里面有两个类型和协作式取消密切相关,这两个类型分别是:
System.Threading.CancellationTokenSource
System.Threading.CancellationToken
System.Threading.CancellationTokenSource类型对象的Token属性就是一个System.Threading.CancellationToken实例。
下面展示了System.Threading.CancellationTokenSource一些常用成员:
属性 | 说明 |
Token | 获取与CancellationTokenSource关联的CancellationToken |
方法 | 说明 |
Cancel() | 取消操作 |
下面展示了System.Threading.CancellationToken一些常用成员:
属性 | 说明 |
IsCancellationRequested | 关联的CancellationTokenSource是否调用了Cancel方法 |
方法 | 说明 |
Register(Action | 登记一个或多个方法在取消一个CancellationTokenSource时回调,Boolean指定是否需要使用调用线程的SynchronizationContext来调用委托。此方法还有更简单的重载版本 |
ThrowIfCancellationRequested | 取消一个CancellationTokenSource时,调用此方法会抛出一个OprationCancelledException对象,任务对象判断任务取消就是调用此方法 |
1、下面代码演示了在使用ThreadPool.QueueUserWorkItem执行异步操作时,如何协作式取消操作:
{
class Program
{
static void Main(string[] args)
{
CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
cancellationTokenSource.Token.Register(() => Console.WriteLine("操作取消"));
ThreadPool.QueueUserWorkItem(n => { LoadBaseData((Int32)n, cancellationTokenSource.Token); }, 10);
Thread.Sleep(1000);
cancellationTokenSource.Cancel();
Console.ReadLine();
}
private static int LoadBaseData(int count,CancellationToken token)
{
int result = count;
for (; count > 0;count-- )
{
if(token.IsCancellationRequested)
{
break;
}
Console.WriteLine("ManagedThreadId:{0}", Thread.CurrentThread.ManagedThreadId);
Thread.Sleep(300);//模拟加载基础数据工作
}
return result;
}
}
}
运行结果如下:
2、下面演示了用Task来执行异步操作,取消操作的方式
{
class Program
{
static void Main(string[] args)
{
CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
Task<Int32> task = new Task<Int32>(n => LoadBaseData((int)n, cancellationTokenSource.Token), 10);
task.Start();
Thread.Sleep(1000);
cancellationTokenSource.Cancel();
try
{
task.Wait();
Console.WriteLine("Result:{0}", task.Result);
}
catch (AggregateException x)
{
x.Handle(e=>e is OperationCanceledException);
Console.WriteLine("任务已取消");
}
Console.ReadLine();
}
private static int LoadBaseData(int count,CancellationToken token)
{
int result = count;
for (; count > 0;count-- )
{
token.ThrowIfCancellationRequested();
Console.WriteLine("ManagedThreadId:{0}", Thread.CurrentThread.ManagedThreadId);
Thread.Sleep(300);//模拟加载基础数据工作
}
return result;
}
}
}
代码调试的时候,当任务取消时,token.ThrowIfCancellationRequested 方法就会抛出一个OprationCancelledException对象。
继续往下执行的运行结果如下:
上面演示代码是调用CallcellationToken的ThrowIfCancellationRequested方法来判断操作是否已经取消,如果取消,这个方法就会抛出一个OprationCancelledException。而ThreadPool.QueueUserWorkItem取消操作是通过判断CallcellationToken的IsCancellationRequested的属性。为什么两者的判断方式不一样呢,这也是两者的区别所决定的,前面已经说了,任务能够获取异步操作的完成时的返回值,取消任务操作抛出异常的目的就是将已完成任务和出错的任务区分开。抛出异常,我们就知道任务没有完成。