C# 异步编程

C# 异步编程基础(完结)_哔哩哔哩_bilibili


一、什么是线程Thread?

  • 线程是一个可执行路径,它可以独立于其他线程执行。
  • 每个线程都在操作系统的进程(Process)内执行,而操作系统进程提供了程序运行的独立环境。
  • 单线程应用,在进程的独立环境里只跑一个线程,所以该线程拥有独占权。
  • 多线程应用。单个进程中会跑多个线程,它们会共享当前的执行环境(尤其是内存)
    • 例如,一个线程在后台读取数据,另一个线程在数据到达后进行展示
    • 这个数据就被称作是共享的状态

例子

  • 在单核计算机上,操作系统必须为每个线程分配“时间片”(在Windows中通常未20毫秒)来模拟并发,从而导致x和y块。
  • 在多核或多处理器计算机上,这两个线程可以真正地并行执行(可能受到计算机上其他活动进程的竞争)
    • 在本例中,由于控制台处理并发请求的机制的微妙性,您任然会得到重复的x和y块

术语:线程被抢占

  • 线程在这个时候就可以称为被抢占了:
    • 它的执行与另一个线程上执行的代码交织的那一点。

线程的一些属性

  •  线程一旦开始执行,IsAlive就是True,线程结束就变成False。
  • 线程结束的条件就是:线程构造函数传入的委托结束了执行。
  • 线程一旦结束,就无法再重启。
  • 每个线程都有Name属性,通常用于调试。
    • 线程Name只能设置一次,以后更改会抛出异常。
  • 静态的Thread.CurrentThread属性,会返回当前执行的线程
    • 例子:CurrentThread

 输出结果:

 输出结果不一样,CPU分配的“时间片”有关

二、Thread.Join()&Thread.Sleep()

Thread.Join

  • 调用Join方法,就可以等待另一个线程结束
  • 例子(join)
// See https://aka.ms/new-console-template for more information
using System;

namespace join和Sleep // Note: actual namespace depends on the project name.
{
    internal class Program
    {

        #region Join

        static Thread thread1, thread2;
        static void Main(string[] args)
        {
            thread1 = new Thread(ThreadProc);
            thread1.Name = "Thread1";
            thread1.Start();

            thread2 = new Thread(ThreadProc);
            thread2.Name = "Thread2";
            thread2.Start();
        }

        private static void ThreadProc()
        {
            Console.WriteLine("\nCurrent thread{0}", Thread.CurrentThread.Name);
            if (Thread.CurrentThread.Name == "Thread1" &&
                thread2.ThreadState != ThreadState.Unstarted)
                thread2.Join();

            Thread.Sleep(1000);
            Console.WriteLine("\nCurrent thread{0}", Thread.CurrentThread.Name);
            Console.WriteLine("Thread1: {0}", thread1.ThreadState);
            Console.WriteLine("Thread2: {0}", thread2.ThreadState);
        }
        #endregion
    }
}

 调试:当Thread1代码逻辑运行到判断语句时Thread2线程已经开启了,所以Thread1需要等待Thread2线程结束才能继续还行后续逻辑

添加超时

  • 调用Join的时候,可以设置一个超时,用毫秒或者TimeSpan都可以
    • 如果返回true,那就是线程结束了;如果超时了,就返回false.

Thread.Sleep()

  • Thread.Sleep() 方法会暂停当前的线程,并等待一段时间
  • 注意:
    • Thread.Sleep(0)这样调用会导致线程立刻放弃本身当前的时间片,自动将cpu移交过其他线程。
    • Thread.Yield()做同样的事情,但是它只会把执行交给同一处理器上的其他线程
    • 当等待Sleep或者Join的时候,线程处于阻塞的状态。

 

二、阻塞 Blocking

  • 如果线程的执行由于某种原因导致暂定,那么就认为该线程被阻塞了
    • 例如在Sleep或者通过Join等待其他线程结束。
  • 被阻塞的线程会立刻将其处理器的时间片生成给其他线程,从此就不再消耗处理器时间,直到满足其阻塞条件为止。
  • 可以通过ThreadState这个属性来判断线程是否处于被阻塞的状态;

ThreadState

  • ThreadState 是一个flags enum, 通过按位的形式,可以合并数据的选项。

    • 线程状态图
  • 但是它大部分的枚举值是没什么用,下面的代码将ThreadState剥离为四个最有用的值之一:Unstarted、Running、WaitSleepJoin和Stopped

 解除阻塞 unblocking

  • 当遇到下列四种情况的时候,就会解除阻塞:
    • 阻塞条件被满足
    • 操作超时(如果设置超时的话)
    • 通过Thread.Interrupt() 进行打断
    • 通过Thread.Abort() 进行中止

上下文切换

  • 当线程阻塞或解除阻塞时,操作系统将执行上下文切换。这会产生少量开销,通常为1或2微妙

I/O-bount vs Compute-bouund (CPU -Bound)

  • 一个花费大部分时间等待某事发生的操作称为I/O-bound
    • I/O绑定操作通常涉及输入或者输出,但这不是硬性要求:Thread.Sleep() 也被视为I/O-bound
  • 相反,一个花费大部分时间执行CPU密集型工作的操作称为Compute-bound

阻塞vs忙等待 (自旋) Block vs Spinnning

  • IO-bund 操作的工作方式有两种
    • 在当前线程上同步的等待
      • Consloe.ReadLine(),Thread.Sleep(),Thread.Join....
    • 异步的操作,在稍后操作完成时触发一个回调动作。
  • 同步等待的I/O-bound操作将大部分时间花在阻塞线程上。
  • 他们也可以周期性在一个循环里进行“打转(自旋)”
  • 在忙等待和阻塞方面有一些细微差别。
    • 首先,如果您希望条件很快得到满足(可能在几微妙之内),则短暂自旋可能会很有效,因为它避免了,上下文切换的开销和延迟。
      • .Net Framework 提供了特殊的方法和类来提供帮助SpinLock 和SpinWait
    • 其次,阻塞也不是零成本。这是以为每个线程在生存期间会占用1mb的内存,并会给CLR和操作系统带来持续的管理开销。
      • 因此,在需要处理成百上千个并发操作的大量I/O-bound程序的上下本中,阻塞可能会很麻烦
      • 所以,此类程序需要使用基于回调的方法,在等待完全撤销其线程。

三、什么是线程安全

本地 vs 共享的状态 Local vs Shared State

Local 本地独立

  • CLR为每个线程分配自己的内存栈(Stack),以便使本地变量保持独立。

Shaded 共享

  • 如果多个线程都引用到同一个对象的实例,那么他们就共享了数据
    •  

  • 被Lambad表达式或匿名委托所捕获的本地变量,会被编译器转化为字段(field),所以也会被共享

  • 静态字段(filed)也会在线程间共享数据。

 线程安全 Thread Safety

  • 前三个例子就引出了线程安全这个关键概念 (或者说缺乏线程安全)
  • 上述例子的输出实际上是无法确定的:
    • 有可能(理论上)“Done”会被打印两次。
    • 如果交换Go方法里面语句的顺序,那么“Done”被答应零次的几率会大大增加
    • 因为一个线程可能正在评估if,而另一个线程在执行WriteLine语句,它还没来得及把done设为true.

锁定与线程安全 简介Locking & Thread Safety

  • 在读取和写入共享数据的时候,通过使用一个互斥锁(exclusive lock),就可以修复前面的例子的问题
  • C#使用lock语句来加锁
  • 当两个线程同时竞争一个lock的时候(锁可以基于任何引用类型对象),一个线程会等待或阻塞,直到锁变为可用状态。
  • 在多线程上下文中,以这种方式避免不确定性的代码叫做线程安全
  • Lock不是线程安全的银弹,很容易忘记对字段加锁,lock也会引起一些问题(死锁)

五、向线程传递数据 & 异常处理

传递数据

  •        如果你想往线程的启动方法里传递参数,最简单的方式是使用lambda表达式,在里面使用参数调用方法。(例子lambda) 
  • 甚至可以把整个逻辑都放在lambda里面。(例子mulit-lambda)
  • 在C#3.0之前,没有lambda表示式。可以使用Thread的Start方法来传递参数。
  • Thread的重载构造函数可以接受下列两个委托之一作为参数:

Lambda表达式与被捕获的变量

  • 使用lambda表达式可以简答的给Thread传递参数。但是线程开始后,可能会不小心修改了被捕获的变量,这要多加座椅。(例子captured) 
    • 解决方案(Captured-solution) 

异常处理

  • 创建线程时在作用范围内try/catch/finally块,在线程开始执行后就与线程无关了。
  • 在WPF、WinForm里,可以订阅全局异常处理事件:
    • Application.DispatcherUnhandledException
    • Application.ThreadException
    • 在通过消息循环调用的程序的任何部分发生未处理的异常(这相当于应用程序处于活动状态时在主线程上运行的所有代码)后,将触发这些异常。(人话:主线程上有未处理的异常就会被触发,UI线程)
    • 但是非UI线程上的未处理异常,并不会触发它。
  • 而任何线程有任何未处理的异常都会触发 AppDomain.CurrentDomain.UnhandledException

六、前台线程 vs 后台线程 Foreground vs Background Threads

  • 默认情况下,你手动创建的线程都是前台线程。
  • 只要有前台线程在运行,那么应用程序就会一直处于活动状态。
    • 但是后台线程却不行。
    • 一旦所有的前台线程停止,那么应用程序就停止了
    • 任何的后台线程也会突然终止
    • 注意:线程的前台、后台状态与它的优先级无关(所分配的执行时间)
  • 可以通过IsBackground属性判断线程是否是后台线程
  • 进程以这种形式终止的时候,后台线程执行栈中的finally块就不会被执行了。
    • 如果想让它执行,可以在退出程序时使用Join来等待后台线程(如果是你自己创建的线程),或者使用signal construct,如果是线程池...
  • 应用程序无法正常退出的一个常见原因是还有活跃的前台线程。

七、线程优先级

  • 线程的优先级(Thread的Priority属性)决定了相对于操作系统中其他活跃线程所占得执行时间
  • 优先级分为:
    • enum ThreadPriority{Lowest,BelowNormal,Normal,AboveNormal,Highest}

提高线程优先级

  • 提升线程优先级的时候需要特别注意,因为它可能“饿死”其他线程
  • 如果想让某线程(Thread)的优先级比其他进程(Process)中线程(Thread)高,那就必须提升进程(Process)的优先级
    • 使用System.Diagnostics下的Process类。
  • 这可以很好地用于只做少量工作且需要较低延迟的非UI线程。
  • 对于需要大量计算的应用程序(尤其是有UI的应用程序),提高进程优先级可能会是其他线程饿死,从而降低整个计算机的速度

八、信号 Signaling

  • 有时,你需要让某线程一直处于等待的状态,直至接收到其他线程发来的通知。这就叫做signaling(发送信号)
  • 最简单的信号结构就是ManualResetEvent。
    • 调用上面的WaitOne 方法会阻塞当期的线程,直到另一个线程通过调用Set方法来开启信号。
  • 例子signaling
    • 调用玩Set之后,信号会“打开”状态。可以通过调用Reset方法将其再次关闭

九、富客户端应用的线程

  • 在WPF,UWP,WinForm等类型的程序种,如果在主线程执行耗时的操作,机会导致整个程序无响应。以为主线程同时还需要处理消息循环,而渲染和鼠标键盘事件处理等工作都是消息循环来执行的
  • 针对这种耗时的操作,一种流行的做法是启用一个worker线程
    • 执行完操作后,再更新到UI
  • 富客户端应用的线程模型通常是:
    • UI元素和空间只能从创建他们的线程来访问(通常是主UI线程)
    • 当想从worker线程更新到UI的时候,你必须把请求交给UI线程
  • 比较底层的实现是:
    • WPF,在元素的Dispatcher对象上调用BeginInvoke或Invoke。
    • WinForm,调用空间的BeginInvoke或Invoke。
    • UWP,调用Dispatcher对象上的RunASync或Invoke。
  • 所有这些方法都接收一个委托。
  • BeginInvoke或RunAsync通过将委托排队到UI线程的消息队列来执行工作。
  • Invoke执行相同的操作,但随后会进行阻塞,直到UI线程读取并处理消息。
    • 因此,Invoke允许您从方法中获取返回值。
    • 如果不需要返回值,BeginInvoke/RunAsync更可取,因为他们不会阻塞调用方,也不会引入死锁的可能性。

十、Synchronization Context  同步上下本

  • 在System.ComponentModel下有一个抽象类:SynchronizationContext,它使得Thread Marshaling得到泛化。
  • 针对移动、桌面(WPF,UWP,WinForms)等富客户端应用的API,他们都定义和实例化了SynchronizationContext的子类
    • 可以通过静态属性SynchronizationContext.Current来获取(当运行在UI线程时)
    • 捕获该属性让你可以稍后的时候从worker线程向UI线程发送数据
    • 调用Post就相当于调用Dispatch或Control上面的BeginInvoke方法
    • 还有一个Send方法,它等价于Invoke方法

十一、线程池 Thread Pool

线程池

  • 当开始一个线程的时候,将花费几百微秒来组织类似一下的内容:
    • 一个新的局部变量栈(Stack)
  • 线程池就可以节省这种开销:
    • 通过预先创建一个可循环使用的线程池来减少这一开销。
  • 线程池对于高效的并行编程和细粒度并发是必不可少的。
  • 它允许在不被线程启动的开销淹没的情况下运行短期操作

使用线程池需要注意的几点

  • 不可以设置线程池的Name
  • 池线程都是后台线程
  • 阻塞池线程可使性能降级
  • 你可以自由的更改池线程的优先级
    • 当他释放回池的时候线程池将还原为正常状态
  • 可以通过Thread.CurrentThread.IsThreadPoolThread属性来判断是否执行在池线程上

进入线程池

  • 最简单、显式的在池线程运行代码的方式就是使用Task。Run

 谁使用了线程池

  • WCF、Remoting、ASP.NET、AsMX Web Services 应用服务器
  • System.Timers.Timer、System.Threading.Timer
  • 并行编程结构
  • BackgroundWorker(现在很多余)
  • 异步委托(现在很多余)

线程池中的整洁

  • 线程池提供了另一个功能,即确保临时超出计算-Bound的工作不会导致CPU超额订阅
  • CPU超额订阅:活跃的线程超过CPU的核数,操作系统就需要对线程进行时间切片
  • 超额订阅对性能影响很大,时间切片需要昂贵的上下文切换,并且可能使CPU缓存失效,而CPU缓存对于现代处理器的性能至关重要 

CLR的策略

  • CLR通过对任务排队并对其启动进行节流限制来避免线程池中的超额订阅
  • 它首先运行尽可能多的并发任务(只要还有CPU核),然后通过爬山算法调整并发级别,并在特定方向上不断调整工作负载。
    • 如果吞吐量提高,它将继续朝同乙方向(否则将反转)
  • 这确保它始终追随最佳性能曲线,即使面对计算机上竞争的进程活动时也是如此
  • 如果下面两点能够满足,那么CLR的策略将发挥出最佳效果:
    • 工作项大多是短时间的(<250毫秒),因此CLR有很多机会进行测量和调整。
    • 大部分时间都被阻塞的工作项不会主宰线程池

十二、开始一个Task

Thread的问题

  • 线程(Thread)是用来创建并发(concurrency)的一种低级工具,它还有一些限制,尤其是:
    • 虽然开始线程的时候可以方便的传入数据,但是当Join的时候,很难从线程获得返回值。
      • 可能需要设置一些共享字段。
      • 如果操作抛出异常,捕获和传播该异常都很麻烦
    • 无法告诉线程在结束时开始做另外的工作,你必须进行Join操作(在进程中阻塞当前线程)
  • 很难使用较小的并发(concurrent)来组建大型的并发。
  • 导致了对手动同步的更大以来以及随之而来的人问题。

Taks Class

  • Task类可以很好的解决上述问题
  • Task是一个相对高级的抽象:它代表了一个并发操作(concurrent)
    • 该操作可能由Thread支持,或不由Thread支持
  • Task是可组合的(可使用Continuation把他们串成链)
    • Tasks可以使用线程池来减少启动延迟
    • 使用TaskCompletionSource,Task可以利用回调的方式,在等待I/O绑定操作时完全避免线程

开始一个Task  Task.Run

  • Task类在System.Threading.Tasks 命名空间下。
  • 开始一个Task最简单的办法就是使用Task.Run (.Net4.5, 4.0的时候是Task.Factory.StartNew这个静态方法:
    • 传入一个Action委托即可(例子task))
  • Task默认使用线程池,也就是后台线程
    • 当主线程结束的时候,你所创建的所有tasks都会结束(例子task)
  • Task.Run返回一个Task对象,可以使用它来监听其过程
    • 在Task.Run之后,我们没有调用Start,因为该方法创建的是“热”任务(hot task)
      • 可以通过Task的构造函数来创建“冷”任务(cold task),但是很少这样做
  • 可以通过Task的Status属性来跟踪task的执行状态

Wait 等待

  • 调用task的Wait方法会进行阻塞直到操作完成
    • 相当于调用thread上的Join方法

  • Wait 也可以让你制定一个超时时间和一个取消令牌来提前结束等待。

Long-running tasks 长时间运行的任务

  • 默认情况下,CLR在线程池中运行Task,这非常适合短时间运行的Compute-Bound类工作。
  • 针对长时间运行的任务或者阻塞操作(例如前面的例子),你可以不采用线程池(例子longRunning)
  • 如果同时运行多个long-running tasks(尤其是其中有处于阻塞状态的),那么将会受很大影响,这时有比TaskCreationOptions.LongRunning更好的办法:
    • 如果任务是IO-Bound,TaskCompletionSource和异步函数可以让你用回调(Coninuations)代替线程来实现并发。
    • 如果任务是Compute-Bound,生产者/消费者队列允许你对任务的并发性进行限流,避免把其他线程和进程饿死、

十二、开始一个Task

  • Task有一个泛型子类叫做Task<TResult>,它允许发出一个返回值。
  • 使用Func<TResult>委托或兼容的Lambda表达式来调用Task.Run就可以得到Task<TTResult>
  • 随后,可以通过Result属性来获得返回的结果
    • 如果这个task还没有完成操作,访问Result属性会阻塞该线程直到该task完成操作。
    • 例子(tresult
    • , prime
  • Task<TResult>可以看做是一种所谓的“未来/许诺”(future、promise),在它里面包裹了一个Result,在稍后的时候就会变得可用。
  • 在CTP版本的时候。Task<TResult>实际上叫做Future<TResult>

十三、Task 的异常

异常

  • 与Thread不一样,Task可以很方便的传播异常
    • 如果你的task里面抛出一个未处理的异常(故障),那么该异常就会重新被抛出给:
      • 调用wait()的地方
      • 访问了Task<TResult>的Result属性的地方。
      • 例子(exception)
  • CLR将异常包裹在AggregateException里,以便在并行编程场景中发挥很好的作用
  • 无需重新抛出异常,通过Task的IsFaulted和IsCanceled属性也可以检测出Task是否发生了故障:
    • 如果两个属性都返回false。那么久没有错误发生。
    • 如果IsCanceled为true,那就说明给一个OpoerationCanceledException为该Task抛出了。
    • 如果IsFaulted为true,那就说明另一个类型的异常被抛出了,而Exception属性也将指明错误

异常与“自治”的Task

  • 自治的,“设置完就不管了”的Task,就是指不通过调用Wait()方法,Result属性或者continuation进行回合的任务。
  • 针对自治的Task,需要像Thread一样,显式的处理异常,避免发生“悄无声息的故障”。
  • 自治Task上未处理的异常被称为未观察到的异常

未观察到的异常

  • 可以通过全局的TaskScheduler.UnobservedTaskException来订阅未观察到的异常。
  • 关于什么是“未观察到的异常”,有一些细微的差别:
    • 使用超时进行等待的Task,如果在超市后发生故障,那么它将产生一个“未观察到的异常”。
    • 在Task发生故障后,如果访问Task的Exception属性,那么该异常就被认为是“已观察到的”。

十五、Continuation

Continuation

  • 一个Continuation会对Task说:“当你结束的时候,继续做点其他的事”
  • Continuation通常是通过回调的方式实现的
    • 当操作一结束,就开始执行
  • 例子(prime)
    • 在task上调用GetAwaiter会返回一个awaiter对象
      • 它的OnComplete方法会告诉之前的task:“当你结束/发生故障的时候要执行的委托”
  • 可以将Continuation附加到已经结束的task上面。此时Continuation将会被安排立即执行

如果发生故障

  • 如果之前的任务发生故障,那么当Continuation代码调用awaiter.GetResult()的时候,异常会被重新抛出。
  • 无需调用GetResult,我们可以直接访问task的Result属性
  • 但调用GetResult的好处是,如果task发生故障,那么异常会被直接的抛出,而不是包裹在AggregateException里面,这样的话catch块就间接很多了。

非泛型task

  • 针对非泛型的task,GetResult()方法有一个void返回值,它就是用来重新抛出异常。

awaiter

  • 任何可以暴露下列两个方法和一个属性的对象就是awaiter:
    • OnComplete
    • GetResult
    • 一个叫做IsCompleted 的bool属性
  • 没有借口或者父类来统一这些成员。
  • 其中OnCompleted是INotifyCompletion的一部分

同步上下文

  • 如果同步上下文出现了,那么OnComplete会自动捕获它,并将Continuation提交到这个上下文中。这一点在富客户端应用中非常有用,因为它会把Continuation放回到UI线程中。
  • 如果是编写一个库,则不希望出现上述行为,因为开销较大的UI线程切换应该在程序运行离开库的时候只发生一次,而不是出现方法调用之间,所以,我们可以使用ConfigureAwait方法来避免这种行为(例子configureAwait)
  • 如果没有同步上下文出现,或者你使用的是COnfigureAwait(false),那么Continuation会运行在先前task的同一个线程上,从而避免不必要的开销

ContinueWith

  • 另外一种附加Continuation的方法就是调用task的ContinueWith方法(例子continueWith)
  • ContunueWith本身返回一个task,它可以用它来附加更多Continuation。
  • 但是,必须直接处理AggregateException:
    • 如果task发生故障,需要写额外的代码来把Continuaiton封装(marshal)到UI应用上。
    • 在非UI上下文中,若想让Continuation和task执行在同一个线程上,必须制定TaskContinuationOptions.ExecuteSynchronously,否则它将弹回线程池。
  • COntinueWith对于并发编程来说非常有用

十六、TaskCompletionSource

  • Task.Run 创建Task
  • 另一种方式就是用TaskCompletionSource来创建Task
  • TaskCompletetionSource让你在稍后开始和结束的任意操作中创建Task
    • 它会为你提供一个可手动执行的“从属”Task
      • 指示操作何时结束或发生故障
  • 它对IO-Bound类工作比较理想
    • 它可以获得所有Task的好处(传播值,异常、Comtinuation等)
    • 不需要在操作时阻塞线程

使用 TaskCompletionSource

  • 初始化一个实例即可
  • 它有一个Task属性可返回一个Task
  • 该Task完全由TaskCom对象控制
  •  调用任意一个方法都会给Task发信号:
    • 完成、故障、取消
  • 这些方法只能调用一次,如果再次调用:
    • SetXXX会抛出异常
    • TryXXX会返回false

 TaskCompletionSource的真正魔力

  • 它创建Task,但并不占用线程
  • (例子 timer)
  • (例子delay)

十七、同步和异步

同步和异步

  • 同步操作会在返回调用者之前完成工作
  • 异步操作会在返回调用者之后去做它的(大部分)工作
    • 异步的方法更为少见,会启用并发,因为它的工作会与调用者并行执行
    • 异步方法通常很快(立即)就会返回到调用者,所以叫非阻塞方法
  • 目前见到的大部分的异步方法都是通用目的:
    • Thread.Start
    • Task.Run
    • 可以经contunuation附加到Task的方法

什么是异步编程

  • 异步编程的原则是将长时间运行的函数写成异步的。
  • 传统的做法是将长时间运行的函数写成同步的,然后从新的线程或Task进行调用,从而按需引用并发
  • 上述异步方式的不同之处在于,它是从长时间运行函数的内部启动并发。这有两点好处:
    • IO-Bound 并发可不使用线程来实现。可提高可扩展性和执行效率;
    • 富客户端worker线程会使用更少的代码,简化了线程安全性。

异步编程的两种用途

  • 编写高效处理大量IO的应用程序(典型的:服务器端应用)
    • 挑战并不是线程安全(因为共享状态通常是最最小化的),而是执行效率
      • 特别的,每个网络请求并不会消耗一个线程
  • 调用图(call graph)
  • 在富客户端应用里简化线程安全。
    • 如果调用图中任何一个操作时长时间运行的,那么整个call graph必须运行在worker线程上。以保证UI响应。
      • 得到一个横跨多个方法的单一并发操作(粗粒度);
      • 需要为call graph的每个方法考虑线程安全。
    • 异步的call graph,直到需要才开启一个线程,通常较浅(IO-Bound操作完全不需要)
      • 其他的方法可以在UI线程执行,线程安全简化。
      • 并发的粒度适中;
        • 一连串小的并发操作,操作之间会弹回到UI线程。

经验之谈

  • 为了获得上述好处,下列操作建议异步编写:
    • IO-Bound和Compute-Bound操作
    • 执行操作50毫秒的操作
  • 另一方面过细的粒度会损害性能,因为异步操作也有开销

十八、异步和contunuaion以及语言的支持

  • Task非常适合异步编程,因为他们支持Continuation(它对异步非常重要)
    • 第十六讲里面TaskCompletionSource的例子
    • TaskCompletionSource实现底层IO-bound异步方法的一种标准方式
  • 对于Compute-bound方法,Task.Run会初始化绑定线程的并发。
    • 把task返回调用者,创建异步方法;
    • 异步编程的区别:目标是在调用图较低的位置来这样做。
      • 富客户端应用中,高级方法可以保留在UI线程和访问控制以及共享状态上,不会出现线程安全问题

语言对异步的支持非常重要

  • 需要对task的执行序列化
    • 例如Task B 依赖于 Task A的执行结果
    • 为此,必须在continuation 内部触发下一次循环
  • async 和 await
    • 对于不想复杂的实现异步非常重要
  • 命令式循环结构不要和continuation混合在一起,因为他们以来本地当前状态。
  • 另一种实现,函数式写法(Linq查询),它也是响应式编程(Rx)的基础.

十九、await

异步函数

  • async和await 关键字可以让你写出和同步代码一样简洁且结构相同的异步代码

awaiting

  • wait 关键字简化了附加contunuation(继续体、延续体)的过程
  • 其结构如下:
  • 它的作用相当于:
  • 例子

async修饰符

  • async修饰符会让编译器把await当做关键字而不是标识符(C#5.0以前可能会使用await作为标识符)
  • async修饰符只能应用于方法(包括lambda表达式)。
    • 该方法可以返回void,Task,Task<TResult>
  • async修饰符对方法的签名或public元数据没有影响(和unsafe一样),它只会影响方法内部。
    • 在接口内使用async是没有意义的
    • 使用async来重载非async的方法确实合法(只要方法签名一致)
  • 使用async修饰符的方法就是“异步函数"

异步方法如何执行

  • 遇到await表达式,执行(正常情况下)会返回调用者
    • 就像iterator里面的yield return。
    • 在返回前,运行时会附加一个continuation到await的task
      • 为保证task结束时,执行会跳回原方法,从停止的地方继续执行。
    • 如果发生故障,那么异常会被重新抛出
    • 如果一切正常,那么它的返回值就会赋给await表达式

可以await什么?

  • 你await的表达式通常是一个task
  • 也可以满足下列条件的任意对象:
    • 有GetAwaiter方法,它返回一个awaiter(实现了INotifyCompletion.OnCompleted借口)
    • 返回适当类型的GetResult方法
    • 一个bool类型的IsCompleted属性

捕获本地状态

  • await表达式的最牛之处就是它几乎可以出现在任何地方。
  • 特别的,在异步方法内,await表达式可以替换任何表达式。
    • 除了lock表达式和unsafe上下文

await之后在哪个线程上执行

  • 在await表达式之后,编译器依赖于continuation(通过awaiter模式)来继续执行
  • 如果在富客户端应用的UI线程上,同步上下文会保证后续是在原线程上执行;
  • 否则,就会在task结束的线程上继续执行。

二十、编写异步函数

编写异步函数

  • 对于任何异步函数,你可以用Task替代void作为返回类型,让该方法称为更有效异步(可以进行await)【例子611】
  • 并不需要在方法体重显式的返回Task。编译器会生成一个Task(当方法完成或发生异常时),这使得创建异步的调用链非常方便【例子612】
  • 编译器会对返回Task的异步函数进行扩展,使其成为当发送信号或发生故障时使用TaskCompletionSource来创建Task的代码。 
  • 因此,当返回Task的异步方法结束的时候,执行就会跳回到对它进行await的地方。(通过continuation)

富客户端场景下

  • 富客户端场景下,执行会在此刻跳回到UI线程(如果目前不在UI线程的话)
  • 否则,就在continuation返回的任意线程上继续执行。
  • 这意味着,在异步调用图中向上冒泡的时候,不会发生延迟成本,除非是UI线程启动的第一次“反弹”

返回Task<TResult>

  • 如果方法体返回TResult,那么异步方法就可以返回Task<TResult>。
  • 其原理就是给TaskCompletionSource发送的信号带有值,而不是null
  • 与同步编程很相似,是故意这样设计的。为的就是简单明了

C#中如何设计异步函数

  • 以同步的方式编写方法

  • 使用异步调用代替同步调用,并且进行await

  • 除了顶层方法外(UI控件的event handler),把你的方法的返回类型升级为Task或Task<TResult>,这样他们就可以进行await了。

编译器能对异步函数生成Task意味着什么?

  • 大多数情况下,你只需要在初始化IO-bound并发的底层方法里显式的初始化。TaskCOmpletionSource,这种情况很少见。
  • 针对初始化compute-bound的并发方法,你可以使用Task.Run来创建Task。

异步调用图执行

  • 整个执行与之前同步例子中调用图执行的顺序一样,因为我们对每个异步函数的调用都进行了await
  • 在调用图中创建了一个没有并行和重叠的连续流
  • 每个await在执行中创建了一个间隙,在间隙后,程序可以从中断处恢复执行。

并行(Parallelism)

  • 不使用await来调用异步函数会导致并行执行的发生。
  • 例如:_button.Click += (sender,args)=> Go();
    • 确实也能满足保持UI响应的并发要求。

异步Lambda表达式

  • 匿名方法(包括Lambda表达式),通过使用async也可以变成异步方法。。
  • 调用方式也一样
  • 附加event handler的时候也可以使用异步Lambda表达式

异步Lambda表达式

  • 也可以返回Task<TResult> 

 二十一、异步中的同步上下文

 发布异常

  • 富客户端应用通常以来于集中的异常处理事件来处理UI线程上未捕获的异常。
    • 例如WPF中的Application.DispatcherUnhandledException
    • ASP.NET Core 中定制ExceptionFilterAttribute也是差不多的效果
  • 其内部原理就是:通过在它们自己的try/catch块来调用UI事件(在ASP.NET Core里就是页面处理方法的管道)
  • 顶层的异步方法会使事情更加复杂
  • 当点击按钮,event handler运行时,在await后,执行会正常的返回到消息循环;1秒钟之后抛出的异常无法被消息循环中的catch块捕获
  • 为了缓解该问题,AsyncVoidMethodBuilder会捕获未处理的异常(在返回void的异步方法里),并把他们发布到同步上下文(如果出现的话),以确保全局异常处理事件能够触发
  • 编译器只会把上述逻辑应用于返回类型为void的异步方法。
  • 如果ButtonClick的返回类型是Task,那么未处理的异常将导致结果Task出错,然后Task无处可取(导致未观察到的异常)
  • 一个有趣的细微差别:无论你在await前面还是后边抛出异常,都没有区别。
  • 因此,下例中,异常会被发布到同步上下文(如果出现的话),而不会发布给调用者。
    • async void Foo(){throw null; await Task.Delay(1000);}
    • 如果同步上下文没有出现,异常将会在线程池上传播,从而终止应用程序
  • 不直接将异常抛出回调者的原因是为了确保可预测性和一致性。
  • 在下例中。不管someCondition是什么值,InvalidOperationException将始终得到和导致Task出现同样的效果
  • Iterator也一样的: 
    • 本例中,异常绝不会直接返回给调用者,直到序列被遍历后,才会抛出异常。

OperationStarted 和 OperationCompleted

  • 如果存在同步上下文,返回void的异步函数也会进入函数时调用其OperationStarted方法,在函数完成时调用其OperationCompleted方法。
  • 如果为了对返回void的异步方法进行单元测试而编写一个自定义的同步上下文,那么重写这两个方法确实很有用。

二十二、优化:同步完成

优化同步完成

  • 异步函数可以在await之前就返回
  • 如果URL在缓存中存在,那么不会有await发生,执行就会返回给调用者,方法会返回一个已经设置信号的Task,这就是同步完成。
  • 当await同步完成的Task时,还行不会返回到调用者,也不通过continuation跳回,它会立即执行到下个语句。
  • 编译器是通过检查await上的IsCompleted属性来实现这个优化的。也就是说。无论何时,当你await的时候
  • 如果是同步完成,编译器会释放可短路continuation的代码
  • 对一个同步返回的异步方法进行await,仍然会引起一个小的开销(20纳秒左右,2019的PC)
  • 反过来,跳回线程池,会引入上下文切换的开销,可能1-2毫秒
  • 而跳回到UI的消息循环,至少是10倍开销(如果UI繁忙,那时间更长)
  • 编写完全没有await的异步方法也是合理的,但是编译器会发出警告:
  • 但这类方法可以用于重载virtual/abstract方法
  • 另外一种可以达到相同结果的方式是:使用Task.FromResult,它会返回一个已经设置好信号的Task
  • 如果从UI线程上调用,那么GetWebOageAsync方法是隐式线程安全的。您可以连续多次调用它(从而启动多个并发下载),并且不需要lock来保护缓存。
  • 有一个简单的方法可以实现这一点,而不必求助于lock或信令结构。我们创建一个“futures(Task<string>)的缓存,而不是字符串的缓存。注意并没有async”:
  • lock的不是下载的过程,lock的是检查缓存的过程(很短暂)

二十三、ValueTask<T>

  • ValueTask<T>用于微优化场景,您可能永远不需要编写返回次类型的方法
  • Task<T> 和Task是引用类型,实例化他们需要基于堆的内存分配和后续的收集(垃圾收集)
  • 优化的一种极端形式是编写无需分配此类内存的代码;换句话说,这不会实例化任何引用类型,不会给垃圾收集增加负担。
  • 为了支持这种模式,C#引入了ValueTask和 ValueTask<T> 这两个struct,编译器允许使用他们替代Task和Task<T>
    • async ValueTask<int> Foo(){...}
  • 如果操作同步完成,则await ValueTask<T>是无分配的。
    • int answer = await Foo(); //(可能是)无分配的
  • 如果操作不是同步完成的,ValueTask<T>实际上就会创建一个普通的Task<T>(并将await转发给它)
  • 使用AsTask方法,可以把ValueTask<T>转化为Task<T>(也包括非泛型版本)

使用ValueTask<T>时注意事项

  • ValueTask<T>并不常见,它的出现存粹是为了性能。
  • 这意味着它被不恰当的值类型语义所困扰,这可能会导致意外。为避免错误行为,必须避免一下情况:
    • 多次await 同一个ValueTask<T>
    • 操作没结束的时候就调用.GetAwaiter().GetResult()
  • 如果你需要进行这些操作,那么先调用AsTask 方法,操作它返回的Task.
  • 避免上述陷阱最简单的办法就是直接await方法调用:
    • await Foo();
  • 将ValueTask赋给变量时,就会引发错误了:
    • ValueTas<T> valueTask = Foo();
  • 将其立即转化为普通的Task,就可以避免此类错误的发生:
    • Task<int> task = Foo().AsTask();

避免过度的弹回

  • 对于在循环中多次调用的方法,通过调用ConfigureAwait方法,就可以避免重复的弹回到UI消息循环所带来的开销
  • 这强迫Task不把continuation弹回给同步上下文。从而将开销消减到接近上下文切换的成本(如果您await的方法同步完成,则开销会小得多)

二十四、异步模式

取消

  • 使用取消标志来实现对并发进行取消,可以封装一个类:

CancellationToken 和CancellationTokenSource

  •  先不管线程安全(应该在读写IsCancellationRequested时进行lock),这个模式非常的有效,CLR也提供了一个CancellationToken类,它的功能和前面的例子类似。
  • 但是它缺少一个Cancel方法,Cancel方法在另外一个类上进行暴露:CancellationTokenSource
  • 这种分离的设计是处于安全考虑:只能对CancellatiionToken访问的方法可以检查取消,但是不能实例化取消
  • 想获得取消标志(cancellation token),先实例化CancellationTokenSource:
    • var cancelSource = new CancellationTokenSource();
  • 这会暴露一个Token属性,它会返回一个CancellationToken,所以我们可以这样调用

Delay

  • CLR里大部分的异步方法都支持CancellationToken,包括Delay方法。
  • 这时,task在遇到请求时会立刻停止(而不是1秒钟之后才停止)
  • 这里,我们无需调用ThrowCancellationRequested,因为Delay会替我们做。
    • 取消标记在调用栈中很好地乡下传播(就像是因为异常,取消请求在调用栈中向上级联一样)。

同步方法

  • 同步方法也支持取消(例如Task的Wait方法)。这种情况下,取消指令需要异步发出(例如,来自另一个Task)

其他

  • 事实上,您在构造CancellationTokenSource时指定一个时间间隔,以便在一段时间后启动取消。它对于实现超时非常有用,无论是同步还是异步:
  • CancellationToken这个struct提供了一个Register方法,它可以让你注册一个回调委托,这个委托会在取消时触发。它会返回一个对象,这对象在取消注册时可以被Dispose掉
  • 编译器的异步函数生成的Task在遇到未处理的operationCanceledException异常时会自动进入取消状态(IsCanceled返回true, IsFaulted返回false)
  • 使用Task.Run创建的Task也是如此。这里是指想构造函数传递(相同的)CancellationToken
  • 在异步场景中。故障Task和取消的Task之间的区别并不重要,因为他们在await时都会抛出一个OperationCanceledException。但这在高级并行编程场景(特别是条件continuation)中很重要

二十五、进度报告

  • 有时,你希望异步操作做运行的过程中能实时的反馈进度。一个简单的解决方法是向异步方法传入一个Action委托,当进度变化的时候触发方法调用:

IPROGRESS<T> 和PROGRESS<T>

  • CLR提供了一堆类型来解决此问题:
    • IPrigress<T>接口
    • Progress<T>类(实现了上面的接口)
  • 它们的目的就是包装一个委托,以便UI程序可以安全的通过同步上下文来报告进度。
  • 接口定义如下:
  • 使用IProgress<T> :
  • Progress<T>的一个构造函数可以接受Action<T>类型的委托
  • Progress<T>还有一个ProgressChanged事件,您可以订阅它,而不是[或附加的]将Action委托传入给构造函数。
  • 在实例化Progress<int> 时,类捕获同步上下文(如果存在)。
  • 异步方法可以通过将int替换为公开一系列属性的自定义类型来实现更精细的进度报告。

二十六、基于异步Task的模式

TAP  (Task-Based Asynchronous Pattern)

  • .NET Core 暴露了数百个返回Task且可以await的异步方法(主要I/O相关)。大多数方法都遵循一个模式,叫做基于Task的异步模式(TAP)。这是我们迄今为止所描述的合理形式化。TAP方法执行一下步骤:
    • 返回一个“热”(运行中)Task或Task<TResult>
    • 方法名以Async结尾(除了像Task组合器等情况)
    • 会被重载,以便接受CancellationToken或(和)IProgress<T>,如果支持相关操作的话,
    • 快速返回调用者(只有很小的初始化同步阶段)
    • 如果是I/O绑定,那么无需绑定线程。

二十七、Task组合器

异步函数有一个让其保持一致的协议(可以一致的返回Task),这能让其保持良好的结果:可以使用以及编写Task组合器,也就是可以组合Task,但是并不关心Task具体做什么的函数。

  • CLR提供了两个Task组合器:
    • Task.WhenAny
    • Task.WhenAll

WhenAny

  • 假设定义了一下方法:
  • 当一组Task中任何一个Task完成时,Task.WhenAny会返回完成的Task。
  • 因为Task.WhenAny本身就返回一个Task,我们对它进行await,就会返回最先完成的Task。
  • 上述完全是非阻塞的,包括最后一行(当访问Result属性是,winningTask已完成),但最好还是对winningTask进行await,因为异常无需AggregateException包装就会重新抛出:
  • 实际上,我们可以在一步中执行两个await:
  • 如果“没赢”的Task后续发生了错误,那么异常将不会被观察到,除非你后续对他们进行await(或者查询其Exce属性)
  • WhenAny很适合为不支持超时或取消的操作添加这些功能:
  • 注意:本例中返回的结果是Task类型。

 WhenAll

  • 当传给它的所有Task都完成后,Task。WhenAll会返回一个Task        
    • 本例就会在3秒后结束。
  • 通过轮流对3个Task进行await,也可以得到类似的结果
    • 不同点事(除了3个await的低效):如果task1出错,我们就无需等待task2和task3了,他们的错误也不会被观察到。
  • 与之相对,Task.WhenAll直到所有Task完成,它才回完成,即使有错误发生。如果多个错误,他们的异常会包裹在Task的AggregateException里
  • await组合的Task。只会抛出第一个异常,想要看到所有的异常。你需要这样做:
  • 对一组Task<TResult>调用WhenAll会返回Task<TResule[]>, 也就是所有Task的组合结果
  • 如果进行await,那么就会得到TResule[]

实例

自定义Task组合器

  • 可以编写自定义的Task组合器。最简单的组合器接受一个Task,看下例:
  • 这就是为等待Task添加了超时的功能。
  • 因为这很可能是一个库方法,无需与外界共享状态,所以在await时我们使用了ConfigureAwa(false)来避免弹回到UI的同步上下文。 
  • 通过Task完成时取消Task.Delay我们可以改进上述的效率(避免了计时器的小开销)

通过CancellationToken放弃Task

下面这个组合器类似WhenAll,如果一个Task出错,那么其余的Task也立即出错:

Windows 应用程序编程中常见的一个模式就是,在GUI用户界面下,将耗时的文件和网络处理放入 子线程,以避免用户界面不能响应的问题。在.NET出现以前,创建线 程并监视线程结束,还要更新 界面等工作,即复杂又要手写大量代码,并且难以调试。在.NET中,程序员可以通过异步调用,使 用简单的代码完成这项工作。 .NET Framework允许异步调用任何方法。使用异步调用之前,要定义一个委托,它的方法签名要与 调用方法签名一致。.NET会自动产生此委托的BeginInvoke和EndInvoke方法。 BeginInvoke 方法用于启动异步调用。它除了具有调用方法相同的参数外,还附加了两个额外的参 数,这两个额外参数将用于回调方法。 BeginInvoke执行后立即返回,并不等待异步调用完成。 BeginInvoke返回一个IAsyncResult接口,它可以用于监视调用的进度。EndInvoke方法被用来获取 异步调用的结果。在 BeginInvoke执行以后,任何时间都可以调用EndInvoke。如果异步调用尚未完 成,EndInvoke会被阻塞,直到异步调用完成。EndInvoke带有一个IAsyncResult接口类型的参数, 并返回一个IAsyncResult接口,用来获取调用结果。 本文中的代码演示了使用BeginInvoke和EndInvoke进行异步调用的四种常见调用方式。在调用 BeginInvoke之后,您可以: 1.完成一些其它工作,然后调用EndInvoke,等待异步调用完成。 2.使用IAsyncResult.AsyncWaitHandle获取一个WaitHandle,使用它的WaitOne方法执行阻塞调 用,完成后再调用EndInvoke。 3.轮询BeginInvoke返回的IAsyncResult接口,在异步调用完成后,再调用EndInvoke。 4.给BeginInvoke传递一个回调方法的委托,异步调用完成后,这个回调方法会在ThreadPool线程 上执行,可以在这个方法内调用EndInvoke。 警告:异步调用完成后,必须调用EndInvoke。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:游动-白 设计师:我叫白小胖 返回首页
评论

打赏作者

钟进光

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值