c#多线程相关整理03——Task篇

2024-06-25

说明

无聊整理一下线程相关操作——Task篇

FromAI:表明例子或者知识点由AI提供,这AI真是写作一大能手,有些晦涩难懂的例子或者不达预期的例子,很轻松便能找到突破口,帮助理解

对线程有兴趣可参考往期文章:

c#多线程相关整理02——ThreadPool篇 (logerlink.github.io)

c#多线程相关整理01——Thread篇 (logerlink.github.io)

本文内容参考但不局限于以下文章,谢谢分享!

C# 多线程七 任务Task的简单理解与运用一_c# task-CSDN博客

C# 多线程八 任务Task的简单理解与运用二_task asyncstate-CSDN博客

C# Task详解 - 漫思 - 博客园 (cnblogs.com)

Task相关

在了解Task之前我们先搞清楚什么是同步操作?什么是异步操作?为什么要使用异步?

同步和异步

同步和异步主要用于修饰方法。

同步操作是指线程在执行某个操作(方法)时,必须等待操作(方法)完成才能继续往下执行。在操作完成之间调用线程处于阻塞状态。

异步操作是指线程在执行某个操作(方法)时,无需等待操作(方法)完成,而是立即返回并继续往下执行代码。在异步操作中,异步和主线程是并发进行的,在操作完成之间并不会阻塞。不过要注意若调用者线程在异步操作完成之前结束,异步操作大概率也无法继续往下执行了

如下图

image-20240607151226677

更多请参考官方解释:使用 Async 和 Await 的任务异步编程 (TAP) 模型 - C# | Microsoft Learn

为什么要使用异步

异步的好处在于非阻塞(调用线程不会暂停执行去等待子线程完成)。一个字那就是:"快",异步操作可以提高程序的整体性能和相应能力,特别适用于处理I/O操作、网络请求、长时间运行的计算、数据库查询、需要并发执行的任务。因此我们可以把一些不需要立即使用结果、较耗时的任务设为异步执行

代码演示同步和异步

观察开始和结束时间,我们可以发现TestAsync方法并没有等待异步操作完成,便直接继续往下执行了

image-20240607152536451

讲完同步和异步,那怎么去创建一个异步操作任务呢?这就是我们接下来要讲的Task了

什么是Task

Task 是一种用于表示异步操作的类型,它是 .NET Framework 4.0 引入的。Task 类型允许你以异步方式执行代码,而无需显式创建和管理线程。这使得编写并发和异步代码变得更加简单和直观。

简单来说便是,我们可以使用Task创建一个异步操作任务,并对该操作进行管理

关于Task和线程

任务Task并不是线程,但是Task的执行需要线程池中的线程或者独立线程来完成。任务Task是架构在线程之上的,也就是说任务最终还是要抛给线程去执行

任务Task跟线程不是一对一的关系,比如开10个任务并不是说会开10个线程。这一点任务有点类似线程池,但是任务相比线程池有很小的开销和精确的控制。

为什么出现Task

为什么会出现Task呢?开头讲到现在,就一个核心:开启一个线程去执行某些操作,但不阻塞调用线程。那直接开线程不是也能实现异步操作吗?线程、线程池都可以完成这个操作啊,为什么还要引入一个新东西Task。我又要多学一点知识了,啊啊啊

其实不然,我们上一篇说到,ThreadPool更优于直接使用Thread,但ThreadPool仍有些不足

  • ThreadPool 不支持线程的取消、完成、失败通知等交互性操作;
  • ThreadPool 不支持线程执行的先后顺序;
  • ThreadPool 无法直接获取线程执行结果
  • Task可以将子任务的异常传播到父任务,捕获异常更简单更直观
  • Task使用Cancellation取消任务,操作更简单

Task拥有线程池的优点,同时也解决了使用线程池不易控制的弊端。所以我们才引用Task类型和异步编程模型(如 async/await)来实现异步操作,它们能够提供更好的资源管理和错误处理机制,方便对线程进程调度和获取线程的执行结果。

异步编程模型 async/await示例
  • async、await一般都是成对出现的
  • 异步方法无返回值,返回类型可返回Task类型或者void,不推荐返回void。无法等待void
  • 异步方法有返回值,返回类型要返回Task<T>,T为返回值的具体类型
  • 异步方法的方法名建议加上Async后缀,方便区分同步和异步方法,对开发和维护提供极大方便

image-20240607163020781

Task核心使用

常用,多了解

Task创建与开始

创建任务一般有两种方式new Task()Task.RunTask.Run会创建并立即开始执行任务,new Task()只会创建任务,直到调用Start()才开始执行,更推荐使用Task.Run或者Task.Factory.StartNew

当你使用Task.Run时,它内部实际上使用了Task.Factory.StartNew,并且默认地为你处理了异步执行的细节

image-20240607155954902

值得注意:如果委托是一个异步方法,如async () => { await ... },请一定要使用Task.Run(async () => { await ... }),不要使用new Task。new Task无法按预期等待内部的异步方法执行完成

具体原因可参考(FromAI):

当你尝试在new Task的构造函数中使用async lambda表达式时,你实际上是将一个异步方法包装在一个同步的Action委托中。这种情况下,await关键字的行为可能不会按预期工作,因为它依赖于上下文是否能够正确处理异步等待。如果没有适当的上下文来处理异步等待,await可能会在内部立即返回,导致任务看起来没有等待

Task.Run可以接受一个ActionFunc<Task>作为参数。当你传递一个async lambda表达式给Task.Run时,它实际上是在创建一个异步任务。Task.Run内部会处理异步方法的启动和等待,确保异步操作能够正确地执行。

Task.Run内部使用了线程池来管理线程,这意味着异步方法可以在一个线程池线程上执行,而不会阻塞调用者的线程。当异步方法中的await表达式被执行时,它会释放线程池线程,直到异步操作完成,这是异步编程的正确行为。

如图,我们分别用不同方式创建任务去执行异步方法,Task.RunTask.Factory.StartNew均按预期执行,new Task创建的任务并没有等待内部的异步方法执行成功,自己先成功了,未按预期执行

image-20240620164557584

Task返回值

异步方法签名的返回值有以下三种:

Task<T>:如果调用方法想通过调用异步方法获取一个T类型的返回值,那么签名必须为Task<T>

Task:如果调用方法不想通过异步方法获取一个值,仅仅想追踪异步方法的执行状态,那么我们可以设置异步方法签名的返回值为Task;

void:如果调用方法仅仅只是调用一下异步方法,不和异步方法做其他交互,我们可以设置异步方法签名的返回值为void,这种形式也叫做“调用并忘记”。

创建一个任务Task,返回值类型一般为TaskTask<T>

若返回值为Task<T>,我们可以使用关键词await.Result.GetAwaiter().GetResult()获取任务的执行结果(await关键词是异步执行,其他都是同步执行的)

等待任务完成

我们可以使用Wait()或者await关键词等待异步任务完成,不过这两种方式实现有些不同,更推荐使用await关键词

  • await:异步等待,通常与async关键词一起使用。await会暂停当前方法的执行,不会阻塞当前线程,直到等待任务完成。在等待期间,控制权会返回给调用者(调用方法),允许在等待任务完成时执行其他任务,有效避免了线程阻塞。这通常用于等待动画、模拟实时行为或实现超时等场景
  • Wait:同步等待,Wait()会阻塞当前线程,直到任务执行完成,可能会导致死锁

我们可以发现,awaitWait()都可以实现等待任务执行完成。但比较好奇的是,为何await关键词,执行异步前后输出的线程Id不一致?是因为等待任务完成的过程中,主程序线程(即调用线程)可能会被释放,允许其他任务执行。(查了很久都没有头绪,这个说法是有很大可能的)

image-20240612100903259

await与Wait

await相比与Wait()的优势

  • 更自然、更直观
  • 异常处理:await允许使用标准的 try/catch 语句处理异步操作中可能发生的异常.
  • 结果处理:await可以直接获取异步操作的结果,而无需调用 Result 属性。避免死锁
  • 非阻塞性:await 关键字等待异步操作时,调用线程不会被阻塞,调用线程可以继续执行其他任务。适用于UI动画
  • 更好的资源利用:await 关键字允许开发者在等待异步操作完成时释放系统资源,如线程和内存。适用于处理大量API请求
  • 更好的兼容性
await 异常处理演示

我们可以发现,await和Wait都可以通过try-catch成功捕获异常,但是await捕获的异常会更直观一点,不会像Wait在原有异常上再包上一层异常

image-20240612105616678

await 结果处理演示

同步获取异步结果时,使用.Result即可,也可以使用.GetAwaiter().GetResult(),这两种都是同步的,会阻塞调用线程

image-20240612110718913

await 非阻塞性演示

MainWindow.xaml

MainWindow.xaml.cs

非阻塞性用WPF来演示效果会明显一点。如下图,当我们点击同步执行按钮时,页面不会输出"Wait,运行中...",此时UI是卡住的。当点击异步执行按钮时,页面按预期输出"Await,运行中...",并且UI不会卡住(此时我们可以去做一下Loading提示动画)

同步

await API接口演示

await能更好的利用资源,适用于处理大量API请求。

如下图,在某段时间大量请求某个接口,观察接口响应情况。我们可以发现,使用异步要不使用同步总耗时短,效率更高。所以在接口中调用异步方法时,更推荐使用异步等待(await)

image-20240612145510452

 

等待任务完成2

除了使用await关键词,我们还可以使用这些方法等待任务完成

  • task.Wait(time) 等待任务完成,若超过等待时间(ms)则不再继续等待
  • task.Result 同步获取异步方法(void返回值类型的方法不能使用.Result)
  • task.GetAwaiter().GetResult() 同步获取异步方法(若是void返回值类型的方法,不可赋值)
  • Task.WaitAll() 等待所有任务执行完成
  • Task.WaitAny() 等待其中一个任务执行完成
  • Task.WhenAll() 当所有任务执行完成时,也是等待的一种
  • Task.WhenAny() 当其中一个任务执行完成,也是等待的一种
  • task1.ContinueWith(Action) Action等待task1执行完成后才开始执行Action。注意这里建议传入委托,而不是一个Task任务
Wait(time)

演示一下:Wait(time)、.Result、.GetAwaiter().GetResult()

image-20240612153419930

WaitAll和WaitAny

WaitAny会返回一个下标,表明是第几个任务完成了。WaitAll无返回值(void)

image-20240612155244914

WhenAll和WhenAny

WhenAll和WhenAny都是返回一个新的任务,相当于这两个方法属于异步方法,我们需要手动等待,不然不会等待指定任务完成的

WhenAny、WhenAll指定泛型或者task1、task2的异步方法的返回值类型是相同的,才可以通过.Result获取任务结果,为了按预期执行,建议手动指定泛型

image-20240624173242529

ContinueWith

ContinueWith常用来等待某个任务执行完成再执行下一个任务,但由于不常用,所以写法上千奇百怪。以下列举了一些错误写法,请大家规避,不然没按预期执行,找bug的时候真的心力交瘁

执行结果如下图,所有程序都没有按 ContinueWith 预期执行——任务执行顺序错乱,甚至有些任务没有执行完成

image-20240614113132563

对此整理了一下使用ContinueWith要了解这几点:

  • 多个任务会根据 ContinueWith 顺序执行,并最终返回最后一个ContinueWith 的任务
  • 第一个任务(task1)一定要记得开启任务(task1.Start() 或者 使用Task.Run),不然程序会一直等待的
  • ContinueWith 的入参是一个委托,直接传入方法或者匿名委托即可,不需要用Task包一层
  • 如果一定非得要把Task当成委托传入 ContinueWith 中(其实没这个必要),一定要在 ContinueWith 中同步等待任务完成(使用Wait(),不要使用await关键词),否则可能会出现任务执行顺序错乱,甚至有些任务没有执行完成。
  • 与其强行把Task当委托传入 ContinueWith中,不如直接使用await关键词一个一个等待Task任务,更简洁明了
  • ContinueWith 可以使用task.Result获取上一个任务的返回结果,若上一个任务无返回值(void),则不允许获取结果。无法直接跨任务获取任务结果(如获取上上一个任务的结果),更不能获取下一个任务的结果...

如下图,这四种方式都可以实现等待任务一完成,再执行任务二的效果

image-20240614175643362

任务等待

在Task中,可以使用Task.Delay暂停一个Task的执行,程序暂停执行x毫秒,然后继续执行下一行代码。Task.DelayThread.Sleep虽然都有暂停执行的意思,但这两种不同,Thread.Sleep会阻塞当前线程,而Task.Delay则不会。在异步编程中,推荐使用Task.Delay暂停等待,可以避免阻塞线程。

image-20240617110508281

任务暂停、继续、取消

C#并没有直接的方法来暂停一个正在运行的任务,我们需要借助ManualResetEventCancellationToken来完成任务的暂停、继续与取消,使用CancellationToken取消任务要记得捕获异常

CancellationTokenSource用于取消异步操作或长时间运行的任务,它提供了一个取消令牌(CancellationToken)可以将该令牌传递给需要取消的操作,当调用CancellationTokenSource的Cancel方法时,与该源关联的所有取消令牌将被取消,从而通知相关的操作停止执行

image-20240617155653167

处理任务中的异常

Task的异常处理很简单,只需要使用await等待即可,使用Wait()同步等待也可以捕获异常,但会在异常包一层父异常。需要注意的是,await、Wait()一定要在try-catch范围等待执行任务完成,否则可能会无法捕获异常

任务状态TaskStatus

TaskStatus表示Task对象的状态:

  1. Created:任务已创建但尚未开始执行。
  2. WaitingForActivation:任务正在等待被激活。通常在任务由TaskCompletionSource创建时出现此状态,Task.Delay也会进入该状态。
  3. WaitingToRun:任务已调度但尚未开始运行。这通常发生在任务被添加到任务队列中,但尚未分配给线程执行。
  4. Running:任务正在执行。
  5. WaitingForChildrenToComplete:父任务正在等待其子任务完成。当父任务使用TaskCreationOptions.AttachedToParent选项创建子任务时,父任务会进入此状态。
  6. RanToCompletion:任务已成功完成。这意味着任务的所有工作都已完成,没有错误发生。(IsCompleted:True,IsCompletedSuccessfully:True)
  7. Canceled:任务被取消。这通常是因为任务的CancellationToken被触发。(IsCanceled:True,IsCompleted:True)
  8. Faulted:任务出错。这表示在执行过程中发生了未捕获的异常。(IsCompleted:True,IsFaulted:True)

我们可以使用IsCanceledIsCompletedIsCompletedSuccessfullyIsFaulted这几个属性快速获取当前任务的执行状况,分别代表是否取消、是否完成(可能会失败)、是否成功完成、是否失败

分别执行以上方法,观察任务状态

image-20240620195502074

Task边角料

不常用,可了解

父任务和子任务

线程之间可能发生的另一种类型的关系是父子关系,子任务被创建为父任务(Parent Task)主体内的嵌套任务。

子任务的类型:附加(Attached)、分离(Detached)。两种类型的任务都在父任务内部创建,并且在默认情况下,创建的子任务是分离类型

要将子任务指定为附加任务,可以将任务的 AttachedToParent 属性设置为 true。考虑创建附加类型任务的场景:

  • 子任务中引发的所有异常都必须传播到父任务
  • 父任务的状态取决于子任务
  • 父任务需要等待子任务完成。

使用子任务要注意这两点:

父任务内部要等待子任务完成,不然可能会出现父任务已完成,但子任务只执行一半的情况

创建附加类型子任务,父任务、子任务只能用Task.Factory.StartNew(ChildAction, TaskCreationOptions.AttachedToParent),不要使用new Task,也不建议使用Task.Run

// TODO:说实话子任务没太理解,卡了两天了,待定,就这样,去死吧

Task.Yield 让出执行权

yield,翻译为让出,让出什么呢?让出执行权。如有多个任务都需要执行很长时间,由于资源不足,部分任务(前10个)先执行,任务1执行到一半满足某个特定条件调用Yield,让出执行权,给其他Task执行的机会。相当于把任务1搁置(让它重新去排队),让其他排队中的任务有机会执行。Task.Yeild()Thread.sleep(0)有点相同

参考:C#中关于Task.Yeild()的探究 - 白烟染黑墨 - 博客园 (cnblogs.com)(思路还行,能看懂,不过Yield写错了,设置线程池最大数量也错了)

如下图,第一批任务执行第2次时,让出执行权(此时任务并没有完全执行完成),重新排队。给其他任务有机会执行(可以看到有些线程ID是重复的)

image-20240621150541226

Task.FromResult 返回结果

Task.FromResult核心是支持以同步的方式实现一个异步接口方法。

Task.FromResult是一个同步方法,它会创建并返回一个已完成的Task<T>实例。但是它并不会阻塞调用线程。需要注意:只有Task.FromResult不会阻塞而已,不是说整个方法(ActionAsync)不阻塞,如果需要在异步方法中执行耗时操作,还是得使用Task.Run

参考:C# Task.FromResult的用法 - 还可入梦 - 博客园 (cnblogs.com)

image-20240624175420082

为什么要用Task.FromResult呢?如下解答(FromAI)

image-20240624105453418

Task.FromException 返回异常任务

Task.FromException可以返回一个特定的异常任务,跟throw抛出异常不一样的是,我们无需使用try-catch捕获异常,只需要在等待任务执行前判断是否异常即可

如下图,我们可以明显看出Task.FromException和throw的区别

使用throw抛出异常,直接中断整个for循环。若不想整个循环失效,则需要在循环内部进行try-catch捕获异常

使用Task.FromException返回带异常的任务,要注意在await执行前,要先判断是否存在异常,不然可能会抛出异常。.Exception是同步执行的

image-20240621161745094

参考:c# - How to throw an exception in an async method (Task.FromException) - Stack Overflow

Task.FromCanceled 返回已取消的任务

Task.FromCanceled 接受一个 CancellationToken 作为参数,并返回一个表示已取消操作的 Task,支持泛型。

当你需要表示一个异步操作已经被取消,而不是正常完成或出现异常时,可以使用 Task.FromCanceled 方法。这在异步编程中很有用,因为它允许你区分不同类型的任务结束状态。

以上代码没有任何意义,只是演示Task.FromCanceled而已

image-20240621175019605

没想到一个好的例子,正常应该像Task.Delay源码这样使用的

image-20240621173248296

Task.CurrentId 查看任务ID

查看任务的Id。需要注意:

  • 任务Id不等于线程Id;
  • 线程Id会重复,任务Id一般不会重复;
  • Task.CurrentId等价于task.Id;
  • Task.CurrentId常用于任务内部(Task.Run、new Task内部)

image-20240624175900592

Task.CompletedTask

Task.CompletedTask 返回一个已经完成的 Task,无需执行任何异步操作.

image-20240624180046077

For循环的临时变量

for循环中使用Task和闭包时,可能会遇到变量作用域的问题。这通常是因为循环变量的捕获导致所有任务共享同一个变量实例。为了解决这个问题,需要在每次循环迭代中创建一个新的变量实例作为临时变量。(FromAI)

image-20240624151308728

究竟是在for循环内开启Task还是在Task内开启for循环呢?

不知道大家有没有这个疑惑?如果有一批大数据需要使用多线程循环,那么我应该是在for循环内部开启Task任务还是直接开启Task任务并在其内部执行for循环?或者大家是否见过类似代码(TestTaskForAsync)?

如下图,差异是很明显的。TestForTaskAsync——在for循环内部开启Task执行任务的效率远远要高于TestTaskForAsync

TestTaskForAsync 相当于只开启了一个任务,只使用了一个线程,并没有达到多线程的效果

image-20240624152730228

控制线程数量

无法直接控制线程数量,不过我们可以通过限制线程池的最大最小数量,达到控制线程数量的效果。但有个弊端,线程池是公共的,相当于某个任务限制了线程池,其他任务也会受限制。我们也可以通过SemaphoreSlim或ParallelOptions来实现,这里先不展开了

image-20240624161256210

任务调度器

任务调度器(Task Scheduler)负责管理和调度这些任务的执行。任务调度器允许你将任务排队以在将来的某个时间点执行,或者在特定的线程上执行。默认使用TaskScheduler.Default任务调度器,它使用线程池来管理和调度任务

TaskScheduler.FromCurrentSynchronizationContext():返回一个任务调度器,该调度器将任务调度在与当前同步上下文关联的线程上。这在UI应用程序中很有用,因为它允许你在UI线程上执行任务,以避免跨线程操作的问题。

常见使用

  1. task1.Start(TaskScheduler):这个方法允许你在指定的任务调度器上启动任务。通过传递一个自定义的任务调度器,你可以控制任务的执行方式和位置。
  2. task1.ContinueWith(Action, TaskScheduler):这个方法允许你在前一个任务完成后,使用指定的任务调度器继续执行另一个任务。
  3. Task.Factory.StartNew(Action, CancellationToken.None, TaskCreationOptions.None, TaskScheduler):创建任务,并指定任务调度器

演示一下调度器的使用,时间不多,没有细致了解,其实也没什么。以下代码演示(FromAI)

自定义任务调度器

使用任务调度器

image-20240624164152259