C#并行编程(1)
一.基于任务的程序设计
共享内存多核OS和分布式内存OS
共享内存多核OS-一个微处理器由多个内核组成,且每个内核共享一段私有内存;
分布式内存OS-- 由多个微处理器组成,每个微处理器可以有自己的私有内存,微处理器可以位于不同的计算机上,每个计算机可以有不同的通信信道消息传递接口(MPI):运行在分布式内存计算机系统上的并行应用程序所使用的最流行的通信协议;
并行程序设计和多核程序设计
并行程序设计是指同一时刻运行多条指令,编写的代码能够充分利用底层硬件提供的并行执行能力;
多核程序设计能够充分利用多个执行内核并行运行多个指令;
硬件线程和软件线程
物理内核(physical core)--真正独立的处理单元,多个物理内核使多条指令能够同时并行的运行。每一个物理内核可提供多个硬件线程(亦称逻辑内核或逻辑处理器);
对称多线程(simultaneous multithreading,SMT):使用超线程计技术(HT)使微处理器在每个物理内核上提供多份架构状态,从而获得了 物理内核数X架构状态数 个硬件线程;
软件线程:每一个软件线程与其父进程分享一个私有的唯一的内存空间,但每一个软件线程有自己的栈、寄存器和私有局部存储区域;可以将硬件线程比作泳道,软件线程比作游泳者;
负载均衡:将软件线程的任务分发在多个硬件线程的操作,通过负载均衡,工作负载可以公平地分配在硬件线程之间。实现负载均衡取决于应用程序的并行程度、工作负载、软件线程数、可用的硬件线程以及负载均衡策略;
Amdahl法则
预测多处理器系统的最大理论性能提升(加速比)
公式:最大加速比=1/((1-P)+(p/N))
P指能够完成并行运行的代码比例
N指可用的计算单元数(处理器或物理内核数)
Gustafson法则
通过问题的大小来测量在固定时间内的可以执行的工作量;
总工作量(单元数)=S+(N*P);
S表示一次顺序执行完成的工作单元数;
P表示每一部分能够完全并行执行的工作单元数;
N表示可用的执行单元数(处理器数或物理内核数)
重量级并发模型和轻量级并发模型
重量级并发模型(多线程编程模型):编写复杂的多线程代码;将算法分解为多个线程、协调各个代码单元、在代码单元之间共享信息以及收集运算结果等任务;并且多线程模型过于复杂,难以应对多核革命;由于框架层次缺乏对多线程范围的支持,多线程需要做大量处理,这会导致代码复杂难以理解;
轻量级并发模型:减少了在不同逻辑内核上创建和执行代码所需要的总开销,并不只是关注不同逻辑内核之间的作业调度,还在框架级别添加对多线程访问的支持;.net framework 4.0实现了该模型;
交错并发,并发和并行
交错并发(interleaved concurrency):一次执行一个线程的指令,两个线程的指令交错执行
并发(concurrency) :两个线程的指令同时执行
并行化要求:对需要完成的工作进行划分、并发的运行处理划分的部分、并且能够整合运行结果;对一个问题进行并行化就会产生并发性;
多核并行程序设计原则
按照并行的方式思考;
使用抽象编程(TPL任务并行库);
按照任务(事情)编程,而不是按照线程(CPU内核)线程--通过TPL,可以编写代码实现基于任务的的设计,而不用关注底层的线程;
设计的时候要考虑关闭并发的情形;
避免使用锁--TPL在很多复杂的情况下使得避免使用重量级的锁更加简单,TPL还提供了新的轻量级的同步机制;
利用为帮助并发而设计的工具和库;
使用可扩展的内存分配器;
设计的时候要考虑增长的工作负载而扩展;
CoreInfo工具 --查看处理器信息程序
二.命令式数据并行
TPL支持数据并行(对每一份数据执行相同的操作)、任务并行(并发的运行不同的操作)和流水线(任务并行和数据并行的结合体);
Parallel.Invoke ----对给定的独立任务提供潜在的并行执行;
需要传入一个要并行执行的Action委托的参数数组;方法没有特定的执行顺序,只有在所有方法都执行完之后才会返回;
优势:并行运行很多方法的简单方式,不用考虑任务和线程的问题;
循环并行化----Parallel.For和ForEach,不支持浮点数和步进。无法保证迭代执行的顺序;
Parallel.For ----为固定数目的独立For循环迭代提供了负载均衡的潜在的并行执行;负载均衡的执行会尝试将工作分发在不同任务中,这样所有的任务在大部分时间内都可以保持繁忙。负载均衡总是试图减少任务的闲置时间。
Parallel.ForEach----为固定数目的独立For循环迭代提供了负载均衡的潜在的并行执行;支持自定义分区器,让你可以完全掌控数据并发;提供了20种重载方法,source参数表示分区器;
利用一个范围整数作为一组数据,通过一个分区器,把数据分成一组数据块。每一块的数据都通过循环的方式处理,而这些循环都是并行的。
在并行循环中使用分区:Partitioner.Create(1, NUM_AES_KEYS + 1);
根据内核数目优化分区:Environment.ProcessorCount 获取逻辑内核的个数;
Partitioner.Create(1,NUM_AES_KEYS, ((int)(NUM_AES_KEYS / Environment.ProcessorCount) + 1))
使用IEnumerable接口的数据源作为分区器;
从并行循环中退出:
在参数中使用ParallelLoopState,就可以使用loopState.Break()或者loopState.Stop()进行退出。其中的差别在于,假设调用Break的时候正在处理迭代100,那么可以保证小于100的迭代都被执行,而Stop不保证这个而是告诉并行循环应尽快停止执行。ParallelLoopResult作为返回值,可以知道是否是正常完成或者被Break的;
捕获并行循环的异常:
try
{
loopResult =
Parallel.ForEach(inputData,
(int number, ParallelLoopState
loopState) =>
{ throw new Exception(); });
}
catch (AggregateException ex)
{
foreach (Exception innerEx in ex.InnerExceptions)
{
Debug.WriteLine(innerEx.ToString());
}
}
指定并行度:ParallelOption用于修改最大并行度。
var parallelOptions = new ParallelOptions();
parallelOptions.MaxDegreeOfParallelism = maxDegree;
Parallel类型下的For、 Foreach、Invoke方法都是启动Task来完成并行操作;但不等同于Task的默认行为,Parallel简化了Task操作,调用者线程在调用Parallel类方法时会被阻塞,调用者线程会一直等到线程池的相关工作全部完成。而Task不会;并行编程虽然在后台使用Task进行管理,但并不意味着它等同于异步。
//主线程被阻滞
static void Main()
{
//在这里也可以使用Invoke方法
Parallel.For(0, 1, (i) =>
{
while (true) { }
});
Console.WriteLine("主线程即将结束");
Console.ReadKey();
}
Parallel中为For和Foreach方法提供多种重载,它们允许我们在每个任务启动时执行一些初始化操作,在每个任务结束后,又执行一些后续操作,同时允许监视任务的状态;
Public static ParallelLoopResult For<TLocal>(int fromInclusive, int toExclusive, ParallelOptions parallelOptions, Func<TLocal> localInit, Func<int, ParallelLoopState, TLocal, TLocal> body, Action<TLocal> localFinally);
public static ParallelLoopResult ForEach<TSource, TLocal>(IEnumerable<TSource> source, Func<TLocal> localInit, Func<TSource, ParallelLoopState, TLocal, TLocal> body, Action<TLocal> localFinally);
重点理解 localInit 和localFinally两个参数:
localInit:如果Parallel新起一个线程去执行任务,它就会执行该委托方法;注是新起一个线程而不是执行一个新的任务;
localFinally:每一个线程结束时,执行一些收尾工作;
三.命令式任务并行
任务是使用底层线程(软件线程)运行的,但任务和线程不是一对一的关系;CLR会创建必要的线程来支持任务执行的需求;默认的任务调度器依赖于底层的线程池引擎;在创建一个任务时,调度器会使用工作窃取队列(work-stealing queue)找到一个合适的线程,然后将任务加入队列;一个Task表示一个异步操作;
TaskStatus状态:初始状态——>TaskStatus.Running状态————>最终状态(如果Task实例有关联的子任务,Task将转变为TaskStatus.WaitingForChildrenToComplete,当子任务都完成后才会进入最终状态)
Task.Delay是一个异步静态方法,用于释放线程供其它任务使用。
创建一个Task的四种方式:
1.使用TaskFactory类的实例对象调用其StartNew方法,会立即启动任务;
2.Task.Factory.StartNew()方法,和第一种方法类似;
3.实例化一个Task对象,利用Task的构造函数,任务不会立即运行,而是指定Created状态。然后调用Start()方法来启动任务;
4..Net4.5新增,调用Task.Run静态方法,立即启动任务;会在线程池创建一个线程来执行;
创建一个Task时,通过TaskCreationOptions标志来控制Task的执行方式或行为;
// 摘要: 指定可控制任务的创建和执行的可选行为的标志。
[Flags]
public enum TaskCreationOptions
{
// 摘要: 指定应使用默认行为。
None = 0,
//摘要:提示TaskScheduler以一种尽可能公平的方式安排任务,这意味着较早安排的任务将更可能较早运行,而较晚安排运行的任务将更可能较晚运行。
PreferFairness = 1,// 将任务放到全局队列中,而不是放到一个工作者线程的本地队列中
// 摘要:指定某个任务可能是运行时间长、粗粒度的操作。它会向TaskScheduler提示,过度订阅可能是合理的,并创建一个新线程而不是使用线程池中的线程。
LongRunning = 2,
// 摘要:指定将任务附加到任务层次结构中的某个父级。
AttachedToParent = 4,
//当一个任务内启动一个任务时,指定该任务是普通任务,而不是子任务
DenyChildAttach = 8,
//强制子任务使用默认的任务调度器而不是父任务的调度器
HideScheduler = 16
}
同步任务:任务不一定要使用线程池的线程,也可以使用其它线程。任务也可以同步运行,以相同的线程作为主调线程。RunSynchronously方法;
private static void RunSynchronousTask()
{
var t1 = new Task(TaskMethod, "run sync");
t1.RunSynchronously();
}
Task.WaitAll静态方法:它阻塞调用线程,直到Task对象数组所有Task对象都完成,其重载方法可以指定要等待的毫秒数,返回一个bool值,表明任务是否在指定时间内完成;也可以使用task.Wait实例方法实现等待;
Task.WaitAny静态方法:它阻塞调用线程,直到Task对象数组任何一个Task对象完成,其重载方法可以指定要等待的毫秒数,返回一个Int32数组索引值,指明完成的是哪一个Task对象。如果发生超时,方法将返回-1。
取消标记(cancellation token):主线程调用CancellationTokenSource的Cancel方法进行中断任务的执行,会使异步操作方法中CancellationToken实例参数的ThrowIfCancellationRequested方法抛出OperationCanceledException异常,使Task实例进入TaskStatus.Canceled状态,并使IsCanceled=true;
Task.Result 获取任务的异步操作返回的结果;Task.Result属性会内部调用Wait;
链式任务:任务t1产生一个结果,任务t2需要t1的结果作为输入才能开始处理异步操作;
延续(continuation):如果链式任务重串联了多个任务,为了避免编写太多代码来检查前一个任务是否成功完成并且调度了一个新的任务;
可以在任何任务实例上调用ContinueWith方法创建一个延续,该方法在这个任务成功结束执行后执行。
在创建一个Task作为另一个Task的延续时,可以指定一个TaskContinuationOptions参数,用于控制延续另一个任务的任务调度和执行的可选行为;
在使用多任务延续时,不能使用的标志:NotOnRanToCompletion,NotOnFaulted,NotOnCanceled,OnlyOnCanceled,OnlyOnFaulted,OnlyOnRanToCompletion;
任务层次结构:一个任务启动一个新任务时(一般新任务在当前任务的内部创建),就启动了一个父/子层次结构;取消父任务,也会取消子任务;