C#异步编程(一)线程及异步编程基础
最近试着做了几个.NET CORE的demo,看了些源码,感觉异步编程在Core里面已经成为主流,而对这块我还没有一个系统的总结,所以就出现了这篇文字,接下来几篇文章,我会总结下异步编程的思路,主要参考clr via c#及以前看过的优秀博文。第一篇文字,我们一起来就打牢基础,把线程基础知识梳理一遍。
本文完全原创,如果转载请注明原文作者及链接。
一、线程基础
每个线程都有以下要素
线程内核对象(thread kernael object)
os为系统中创建的每个线程都分配并初始化这种数据结构,包含一组对线程进行描述的属性,还包含所谓的线程山下文(thread context)。上下文是包含cpu寄存器集合的内存块。对于x 86,x64和arm cpu架构,线程上下文分别使用700,1240和350字节的内存。
线程环境块(thread environment block,TEB)
TEB是在用户模式(应用程序代码能快速访问的地址空间)中分配和初始化的内存块。Teb耗用1个内存页。TEB包含线程的异常处理链首。线程进入的每个try块都在链首插入一个节点,线程退出try块时从链中删除该节点。此外,还包含线程的“线程本地存储”数据,以及由GDI(graphics device interface,图形设备接口)和opengl图形使用的一些数据结构
用户模式栈(user-mode stack)
用户模式栈存储传给方法的局部变量和实参。他还包含一个地址:指出当前的方法返回时,线程应该从什么地方接着执行。windows默认给每个线程的用户模式栈分配1mb内存。更具体地说,winows只是保留1mb地址空间,在线程实际需要时才会调拨物理内存。
内核模式栈(kernel-mode stack)
所谓的内核模式,主要是核心操作系统组件在内核模式下运行,很多驱动程序在内核模式下运行,内核模式效率更高,如果内核模式驱动程序损坏,则整个操作系统会损坏。
应用程序代码向操作系统中的内核模式函数传递实参时,还会使用内核模式栈。出于对按全额考虑,针对从用户模式的代码传递给内核的任何实参,windows都会把他们从线程的用户模式栈复制到线程的内核模式栈。一经赋值,内核就可以验证实参的值。由于应用程序代码不能访问内核模式栈,所以应用程序无法更改验证后的实参值。32位windows 内核栈大小12kb,64位windows是24kb。
DLL线程连接(attach)和线程分离(detach)通知
windows的一个策略是,任何时候在进程中创建线程,都会调用进程中加载的所有非托管dll的dllmain方法,并向该方法传递dll_thread_attach标志。类似地,任何时候线程终止,都会调用进程中的所有非托管dll的dllmain方法,并向方法传递dll_thread_detach标志。有的dll需要获取这些同志,才能为进程中创建/销毁的每个线程执行特殊的初始化或(资源)清理操作。
1.1 windows系统线程切换
cpu的单个核心同一时间只能进行一个线程的执行(不考虑intel超线程技术),在执行的线程可以运行一个“时间片”(quantum,也叫“量程”)。时间片时间片到期,windows进行线程切换所执行的操作:
1、 将cpu寄存器的值保存到当前正在运行的线程的内核对象内部的一个上下文结构中。
2、 从现有线程集合中选出一个线程进行执行。(如果该线程由另一个进程拥有,windows在开始执行任何代码之前,还必须切换虚拟地址空间到对应的进程)
3、 将所选执行线程上下文结构中的值加载到cpu·寄存器中
虽然我们看到的是以上的三步,但是实际执行中,线程切换对性能的影响可能比以上三步的消耗更多。比如,cpu现在要执行一个不同的线程,而之前的线程的代码和数据还在cpu的告诉缓存(cache)中,这使cpu不必经常访问ram。而一旦进行上下文切换到新的线程,这个新的线程很大概率执行不同的代码,访问不同的数据,这些代码和数据并不在告诉缓存中,因此,cpu必须访问ram来填充他的高速缓存。
1.2 线程调度的优先级
windows之所以被称为抢占式多线程(preemptive multithreaded)操作系统,是因为线程可以在任何时间停止(被抢占)并调度另一个线程。程序员在这方面有一定的控制权,虽然不多。记住一点,你不能保证自己的线程一直在运行,你阻止不了其他线程的运行。
在windows中,每个线程都分配了从0(最低)到31(最高的优先级),系统决定为cpu分配哪个线程时,会以一种轮流的方式调度他。
线程的优先级是进程优先级和线程本身优先级叠加后计算出来的,如下图。
二、异步编程
2.1 clr线程池
创建和销毁线程是一个昂贵的操作,为了改善这个情况,clr包含了代码来管理自己的线程池(thread pool)。每个clr一个线程池:这个线程池由clr控制的所有AppDomain共享。
线程池具体维护多少的线程根据程序的请求频次有关,这个clr有内部的算法,我们这里不进行深入讨论。
2.2 异步操作的取消
异步操作的取消可以使用CancellationTokenSource类
简单的代码实例如下
internal static class CancellationDemo { public static void Go() { CancellationTokenSource cts = new CancellationTokenSource(); ThreadPool.QueueUserWorkItem(o => Count(cts.Token, 1000)); Console.WriteLine("press <enter> to cancel the operation"); Console.ReadLine(); cts.Cancel();//如果count方法已经返回,cancel没有任何效果 Console.ReadLine(); } private static void Count(CancellationToken token, int countTo) { for (int count = 0; count < countTo; count++) { if (token.IsCancellationRequested) { Console.WriteLine("Count is cancelled"); break; } Console.WriteLine(count); Thread.Sleep(200); } Console.WriteLine("Count is done"); } }
2.3 Task
QueueUserWorkItem没有内建机制让你知道操作在什么时候完成,也没有机制在操作完成时获取返回值。为了克服这些限制(并解决其他一些问题),microsoft引入了任务的概念。
调用方式如下:
ThreadPool.QueueUserWorkItem(o => Count(cts.Token, 1000)); Task.Run(() => Count(cts.Token, 1000)); Task.Run(() => { Console.WriteLine(1000); }, cts.Token);
2.3.1 任务内部解密
每个task对象都有一组字段,这些字段构成了任务的状态。其中包括一个Int32 Id(只读)、代表task执行状态的一个int32、对父任务的引用、对task创建时指定的taskScheduler的引用、对回调方法的引用、对要传给回调方法的对象的引用、对ExecutionContext的引用以及对ManualResetEventSlim对象的引用。另外每个task对象都有根据需要创建补充状态的引用。补充状态包含CancellationToken、一个ContinueWithTask对象集合、未抛出未处理异常的子任务而准备的一个task对象集合等。以上这么多,让我们意识到task虽然有用,但是并不是没有代价,如果不需要task的附加功能,那么使用threadpool.QueueUserWorkItem能获得更好的资源利用率。
在一个task对象的存在期间,课查询task的只读status属性了解它在其生存期的什么位置。该属性返回一个taskStatus值,如下
首次构造task对象时,他的状态是created。以后,当任务启动时,他的状态变成waitingToRun。task实际在一个线程上运行时,他的状态变成running。任务停止运行,并等待他的任何子任务时,状态变成waitingForChildrenToComplete。任务完成时进入一下状态之一:RanToCompoletion(运行完成),Canceled(取消)或Faulted(出错)。如果运行完成,可通过task<TResult>的Result属性来查询任务结果。出错时,可查询task的exception属性来获取任务抛出的未处理异常,该属性总是返回一个aggregateException对象,对象的innerException集合包含了所有未处理的异常。
为了简化编码,task提供了几个只读Boolean属性,包括IsCanceled,IsFaulted和IsCompleted。
调用continueWith等方法创建的task对象处于waitingForActivation状态。该状态意味着task的调度由任务基础结构控制,自动启动。
2.3.2 任务工厂
有时候需要创建一组共享相同配置的task对象。为避免机械的赋值,我们可以创建一个任务工厂来封装通用配置,TaskFactory和TaskFactory<TResult>。创建工厂类,需要向构造器传递需要具有的默认值,如CancellationToken、TaskScheduler、TaskCreationOptions及TaskContinuationOptions等。
实例代码如下
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
public static void Go() { Task parent = new Task(() => { var cts = new CancellationTokenSource(); var tf = new TaskFactory<Int32>(cts.Token, TaskCreationOptions.AttachedToParent, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); var childTasks = new[] { tf.StartNew(()=> Sum(cts.Token,10000)), tf.StartNew(()=> Sum(cts.Token,20000)), tf.StartNew(()=> Sum(cts.Token,Int32.MaxValue))//这里执行会抛错 }; //任何子任务抛出异常,就取消其余子任务 for (int task = 0; task < childTasks.Length; task++) { childTasks[task].ContinueWith(t => cts.Cancel(), TaskContinuationOptions.OnlyOnFaulted); } //所有子任务完成后,从未出错/未取消的任务获取返回的最大值, //然后将最大值传给另一个任务来显示最大结果 tf.ContinueWhenAll(childTasks, completedTasks => completedTasks.Where(t => !t.IsFaulted && !t.IsCanceled).Max(t => t.Result), CancellationToken.None).ContinueWith(t => Console.WriteLine("The maximum is :" + t.Result), TaskContinuationOptions.ExecuteSynchronously); }); //子任务完成后,显示任何未处理的异常 parent.ContinueWith(p => { //这里先用stringbuilder收集输入,然后调用一次Console.WriteLine() StringBuilder sb = new StringBuilder("the following exception(s) occurred:" + Environment.NewLine); foreach (var e in p.Exception.Flatten().InnerExceptions) { sb.Append(" " + e.GetType().ToString()); } Console.WriteLine(sb.ToString()); }, TaskContinuationOptions.OnlyOnFaulted); parent.Start(); parent.Wait(); } private static Int32 Sum(CancellationToken ct, Int32 n) { Int32 sum = 0; for (; n>0; n--) { ct.ThrowIfCancellationRequested(); checked { sum += n; } } return sum; }
2.3.3 任务调度
任务基础结构非常灵活,TaskScheduler对象功不可没。TaskScheduler赋值执行任务的调度,同时向visual studio调试器公开任务信息。fcl提供了两个派生自TaskScheduler的类型:线程池任务调度器(thread pool task scheduler),和同步上下文任务调度器(synchronization context task scheduler)。默认情况下都是使用线程池任务调度器。同步上下文任务调度器适合提供了图形用户界面的应用程序,如wpf,uwp等。
参考资料:
《CLR via C#(第四版)》
MSDN
第一篇文章,所以先把基础的东西写出来,后续会深入讨论异步编程的实践。