.net 系列:并发编程之一【并发编程的初步理论】
一、关于并发编程的几个误解
1)并发就是多线程
实际上多线程只是并发编程的一种形式而已,在C#中还有很多其他的并发编程技术,包括异步编程,并行编程,TPL数据流,响应式编程等。
2)只有大型服务器才需要考虑并发
服务器端的大型程序要响应大量客户端的数据请求,当然要充分考虑并发。但是桌面程序和手机、平板等移动端应用同样需要考虑并发编程,因为它们是直接面向最终用户的,而现在用户对使用体验的要求越来越高。程序必须能随时响应用户的操作,尤其是在后台处理时(读写数据、与服务器通信等),这正是并发编程的目的之一。
3)并发编程很复杂,必须掌握很多底层技术
C# 和.NET 提供了很多程序库,并发编程已经变得简单多了。尤其是.NET 4.5 推出了全新的async 和await 关键字,使并发编程的代码减少到了最低限度。并行处理和异步开发已 经不再是高手们的专利,每个开发人员都能写出交互性良好、高 效、可靠的并发程序。
二、并发的几个名称术语
- 并发 :同事做多件事情
- 多线程:并发的一种形式,它采用多个线程来执行处理。
- 并行处理(并行编程):把正在执行的大量任务分割成几个小块,分配给多个同时运行的线程,是多线程的一种表现形式。
- 异步编程:并发的一种形式,它采用future 模块或回调(callback)机制,以避免产生堵塞。
- 响应式编程:一种声明式的编程模式,程序在该模式下对事件做出响应。
三、异步编程简介
异步编程有两大好处。第一个好处是对于面向终端用户的GUI 程序:异步编程提高了响应能力。我们都遇到过在运行时会临时锁定界面的程序,异步编程可以使程序在执行任务时仍能响应用户的输入。第二个好处是对于服务器端应用:异步编程实现了可扩展性。服务器应用可以利用线程池满足其可扩展性,使用异步编程后,可扩展性通常可以提高一个数量级。现代的异步.NET 程序使用两个关键字:async 和await。async 关键字加在方法声明上,它的主要目的是使方法内的await 关键字生效(为了保持向后兼容,同时引入了这两个关键字)。如果async 方法有返回值,应返回Task<T>;如果没有返回值,应返回Task。这些task 类型相当于future,用来在异步方法结束时通知主程序。
我举个例子:
1 async Task DoSomethingAsync() 2 { 3 int val = 13; 4 // 异步方式等待1 秒 5 await Task.Delay(TimeSpan.FromSeconds(1)); 6 val *= 2; 7 8 // 异步方式等待1 秒 9 await Task.Delay(TimeSpan.FromSeconds(1)); 10 Trace.WriteLine(val); 11 }
async 方法在开始时以同步方式执行。在async 方法内部,await 关键字对它的参数执行一个异步等待。它首先检查操作是否已经完成,如果完成了,就继续运行(同步方式)。否则,它会 暂停async 方法,并返回,留下一个未完成的task。一段时间后,操作完成,async 方法就恢复运行。
一个async 方法是由多个同步执行的程序块组成的,每个同步程序块之间由await 语句分隔。第一个同步程序块在调用这个方法的线程中运行,但其他同步程序块在哪里运行呢?情况比较复杂。最常见的情况是,用await 语句等待一个任务完成,当该方法在await 处暂停时,就可以捕捉上下文(context)。如果当前SynchronizationContext 不为空,这个上下文就是当前SynchronizationContext。如果当前SynchronizationContext 为空,则这个上下文为当前TaskScheduler。该方法会在这个上下文中继续运行。一般来说,运行UI 线程时采用UI 上下文,处理ASP.NET 请求时采用ASP.NET 请求上下文,其他很多情况下则采用线程池上下文。
有两种基本的方法可以创建Task 实例。有些任务表示CPU 需要实际执行的指令,创建这种计算类的任务时,使用Task.Run(如需要按照特定的计划运行,则用TaskFactory.StartNew)。其他的任务表示一个通知(notification),创建这种基于事件的任务时,使用TaskCompletionSource<T>。大部分I/O 型任务采用TaskCompletionSource<T>。
使用async 和await 时,自然要处理错误。在下面的代码中,PossibleExceptionAsync 会抛出一个NotSupportedException 异常,而TrySomethingAsync 方法可很顺利地捕捉到这个异常。这个捕捉到的异常完整地保留了栈轨迹,没有人为地将它封装进TargetInvocationException 或AggregateException 类:
1 async Task TrySomethingAsync() 2 { 3 try 4 { 5 await PossibleExceptionAsync(); 6 } 7 catch(NotSupportedException ex) 8 { 9 LogException(ex); 10 throw; 11 } 12 }
一旦异步方法抛出(或传递出)异常,该异常会放在返回的Task 对象中,并且这个Task对象的状态变为“已完成”。当await 调用该Task 对象时,await 会获得并(重新)抛出该异常,并且保留着原始的栈轨迹。因此,如果PossibleExceptionAsync 是异步方法,以下代码就能正常运行:
1 async Task TrySomethingAsync() 2 { 3 // 发生异常时,任务结束。不会直接抛出异常。 4 Task task = PossibleExceptionAsync(); 5 try 6 { 7 //Task 对象中的异常,会在这条await 语句中引发 8 9 await task; 10 } 11 catch(NotSupportedException ex) 12 { 13 LogException(ex); 14 throw; 15 } 16 }
关于异步方法,还有一条重要的准则:你一旦在代码中使用了异步,最好一直使用。调用异步方法时,应该(在调用结束时)用await 等待它返回的task 对象。一定要避免使用Task.Wait 或Task<T>.Result 方法,因为它们会导致死锁。参考一下下面这个方法:
1 async Task WaitAsync() 2 { 3 // 这里awati 会捕获当前上下文…… 4 await Task.Delay(TimeSpan.FromSeconds(1)); 5 // ……这里会试图用上面捕获的上下文继续执行 6 } 7 void Deadlock() 8 { 9 // 开始延迟 10 Task task = WaitAsync(); 11 // 同步程序块,正在等待异步方法完成 12 task.Wait(); 13 }
如果从UI 或ASP.NET 的上下文调用这段代码,就会发生死锁。这是因为,这两种上下文每次只能运行一个线程。Deadlock 方法调用WaitAsync 方法,WaitAsync 方法开始调用delay 语句。然后,Deadlock 方法(同步)等待WaitAsync 方法完成,同时阻塞了上下文线程。当delay 语句结束时,await 试图在已捕获的上下文中继续运行WaitAsync 方法,但这个步骤无法成功,因为上下文中已经有了一个阻塞的线程,并且这种上下文只允许同时运行一个线程。这里有两个方法可以避免死锁:在WaitAsync 中使用ConfigureAwait(false)(导致await 忽略该方法的上下文),或者用await 语句调用WaitAsync 方法(让Deadlock变成一个异步方法)。
四、并行编程简介
如果程序中有大量的计算任务,并且这些任务能分割成几个互相独立的任务块,那就应该使用并行编程。并行编程可临时提高CPU 利用率,以提高吞吐量,若客户端系统中的CPU 经常处于空闲状态,这个方法就非常有用,但通常并不适合服务器系统。大多数服务器本身具有并行处理能力,例如ASP.NET 可并行地处理多个请求。某些情况下,在服务器系统中编写并行代码仍然有用(如果你知道并发用户数量会一直是少数)。但通常情况下,在服务器系统上进行并行编程,将降低本身的并行处理能力,并且不会有实际的好处。并行的形式有两种:数据并行(data parallelism)和任务并行(task parallelim)。数据并行是指有大量的数据需要处理,并且每一块数据的处理过程基本上是彼此独立的。任务并行是指需要执行大量任务,并且每个任务的执行过程基本上是彼此独立的。任务并行可以是动态的,如果一个任务的执行结果会产生额外的任务,这些新增的任务也可以加入任务池。
实现数据并行有几种不同的做法。一种做法是使用Parallel.ForEach 方法,它类似于foreach 循环,应尽可能使用这种做法。
Parallel 类提供Parallel.For 和ForEach方法,这类似于for 循环,当数据处理过程基于一个索引时,可使用这个方法。下面是使用Parallel.ForEach 的代码例子:
1 void RotateMatrices(IEnumerable<Matrix> matrices, float degrees) 2 { 3 Parallel.ForEach(matrices, matrix => matrix.Rotate(degrees)); 4 }
另一种做法是使用PLINQ(Parallel LINQ), 它为LINQ 查询提供了AsParallel 扩展。跟PLINQ 相比,Parallel 对资源更加友好,Parallel 与系统中的其他进程配合得比较好, 而PLINQ 会试图让所有的CPU 来执行本进程。Parallel 的缺点是它太明显。很多情况下,PLINQ 的代码更加优美。
1 IEnumerable<bool> PrimalityTest(IEnumerable<int> values) 2 { 3 return values.AsParallel().Select(val => IsPrime(val)); 4 }
不管选用哪种方法,在并行处理时有一个非常重要的准则只要任务块是互相独立的,并行性就能做到最大化。一旦你在多个线程中共享状态,就必须以同步方式访问这些状态,那样程序的并行性就变差了。
有多种方式可以控制并行处理的输出,可以把结果存在某些并发集合,或者对结果进行聚合。聚合在并行处理中很常见,Parallel 类的重载方法,也支持这种map/reduce 函数。
下面讲任务并行。数据并行重点在处理数据,任务并行则关注执行任务。Parallel 类的Parallel.Invoke 方法可以执行“分叉/ 联合”(fork/join)方式的任务并行。调用该方法时,把要并行执行的委托(delegate)作为传入参数:
1 void ProcessArray(double[] array) 2 { 3 Parallel.Invoke( 4 () => ProcessPartialArray(array, 0, array.Length / 2), 5 () => ProcessPartialArray(array, array.Length / 2, array.Length) 6 ); 7 } 8 void ProcessPartialArray(double[] array, int begin, int end) 9 { 10 // CPU 密集型的操作…… 11 }
数据并行和任务并行都使用动态调整的分割器,把任务分割后分配给工作线程。线程池在需要的时候会增加线程数量。线程池线程使用工作窃取队列(work-stealing queue)。微软公司为了让每个部分尽可能高效,做了很多优化。要让程序得到最佳的性能,有很多参数可以调节。只要任务时长不是特别短,采用默认设置就会运行得很好。
如果任务太短,把数据分割进任务和在线程池中调度任务的开销会很大。如果任务太长,线程池就不能进行有效的动态调整以达到工作量的平衡。很难确定“太短”和“太长”的判断标准,这取决于程序所解决问题的类型以及硬件的性能。根据一个通用的准则,只要没有导致性能问题,我会让任务尽可能短(如果任务太短,程序性能会突然降低)。更好的做法是使用Parallel 类型或者PLINQ,而不是直接使用任务。这些并行处理的高级形式,自带有自动分配任务的算法(并且会在运行时自动调整)。
五、多线程编程简介
线程是一个独立的运行单元,每个进程内部有多个线程,每个线程可以各自同时执行指令。每个线程有自己独立的栈,但是与进程内的其他线程共享内存。对某些程序来说,其中有一个线程是特殊的,例如用户界面程序有一个UI 线程,控制台程序有一个main 线程。
每个.NET 程序都有一个线程池,线程池维护着一定数量的工作线程,这些线程等待着执行分配下来的任务。线程池可以随时监测线程的数量。配置线程池的参数多达几十个,但是建议采用默认设置,线程池的默认设置是经过仔细调整的,适用于绝大多数现实中的应用场景。