Menu

C# Task 教程:Task.Run、WhenAll、WhenAny、取消与线程

Task 是 .NET 中异步和并行工作的基本单位。学习用 Task.Run 处理 CPU 密集型工作、读取结果、用 WhenAll 和 WhenAny 组合多个任务、延续任务、用 CancellationToken 取消任务、Task 与 Thread 的区别,以及什么时候更适合用 Parallel.For。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

Task 是一个代表“进行中的工作”的对象。它可能正在线程池的某个线程上运行,可能在等一个计时器,也可能在等网络回复;无论哪种情况,你都可以问它是否已完成、获取它的结果、等待它,或者在它之后追加更多工作。async 和 await 是建立在 Task 之上的语言语法。本页讲的是 Task API 本身,以及线程。

Task.Run:在线程池上执行工作

Task.Run 接收一个 lambda,并把它排入线程池。线程池是 .NET 预先保留的一组线程,这样就不必为每项工作都新建一个线程。它返回一个 Task,如果 lambda 有返回值,则返回 Task<T>。

输出:

Started, completed yet: False
Primes up to 200,000: 17984
Status: RanToCompletion

Result 会阻塞调用线程,直到任务完成。在控制台程序的 Main 中这样做没问题,但在异步代码里你应该写 int primes = await task;,它会等待而不阻塞线程。对于没有结果的 Task,task.Wait() 也是同样的道理。

Task.Run 适用于 CPU 密集型工作,也就是让一个核心一直忙碌的计算。对于 I/O,例如读取文件或调用 Web API,请使用库本身提供的异步方法(File.ReadAllTextAsync、HttpClient.GetStringAsync),并直接 await 它。I/O 还在进行时,被 await 的异步调用根本不占用任何线程,所以把它包进 Task.Run 毫无收益,只会多绕一次线程池。真正会占住线程池线程的情况,是用 Task.Run 包装一个同步的阻塞调用,比如 File.ReadAllText:那个线程会一直闲着,直到磁盘或网络给出响应。

同时运行多个任务:WhenAll

把相互独立的工作拆成多个任务,全部启动,再用 Task.WhenAll 合并结果。在多核机器上,这些部分会并行运行。

输出:

Parts: 14999995, 14999999, 15000003, 15000000
Total: 59999997

每个分块处理自己的范围并返回自己的和,所以这些任务之间不共享任何东西,也不需要加锁。无论各线程实际以什么顺序完成,WhenAll 都会按任务的顺序返回结果。如果任务确实需要更新共享状态,请参阅 lock。

WhenAny 与超时

Task.WhenAny 在其中第一个任务完成时就完成,并返回那个任务。让真正的工作和 Task.Delay 赛跑,就得到了超时:

输出:

fast mirror answered after 100 ms
Timed out after 300 ms

WhenAny 返回的是获胜的那个任务本身,而不是它的结果,所以你要再 await 一次才能拿到值(如果它失败了,拿到的就是异常)。注意,落败的任务还会继续运行,WhenAny 不会让它们停下。要真正停止慢的那项工作,就要像下文那样用 token 取消它。在 .NET 6 及更高版本中,await work.WaitAsync(TimeSpan.FromMilliseconds(300)) 一次调用就能表达同样的超时。

用 ContinueWith 延续任务

在有 await 之前,要在任务完成后运行代码,靠的是 ContinueWith。在较老的代码里你仍然会见到它:

输出:

Report total: 42

每个延续都会收到前一个任务,并在它完成时运行。await 做的是同一件事,但写法简洁得多,能自然地处理异常,还会回到正确的上下文,所以新代码请优先用它。即使前一个任务失败或被取消,ContinueWith 也会运行,因此老代码必须检查 t.IsFaulted,或者传入 TaskContinuationOptions,以免对失败的任务调用 .Result。

用 CancellationToken 取消任务

任务的取消是协作式的。CancellationTokenSource 发出一个 token,执行工作的代码检查这个 token 并自行停止。没有任何东西会被强行中止。

输出:

Cancelled before finishing all 10 orders
Processed 3

token 会一层层往下传递,而 Task.Delay、HttpClient 和数据库调用等异步 API 也都接受它,所以取消可以让它们在等待途中停下。请捕获 OperationCanceledException;有些 API 抛出的 TaskCanceledException 就派生自它。CancellationTokenSource 实现了 IDisposable,所以这里用了 using。

Thread 与 Task

Thread 是你自己创建的操作系统线程。它更重:每个线程都有自己的栈(在 Windows 上默认 1 MB),启动一个线程要花明显的时间,而且它不提供结果,也不帮你处理异常。

输出:

Thread started
Result: 42
ThreadTask
运行在一个新的专用操作系统线程上线程池线程上(I/O 则不占线程)
启动开销高低
结果值没有,只能共享一个变量Task<T>.Result / await
异常未处理的异常会让进程崩溃保存在任务中,await 时重新抛出
取消手动设置标志内置 CancellationToken
等待它完成Join()await、Wait()、WhenAll

只有当你需要线程池给不了的东西时,才去用 Thread:一个永远不该借用线程池线程的长时间循环、特定的优先级,或者用于 COM 的单线程单元。对于长时间运行的任务,Task.Factory.StartNew(work, TaskCreationOptions.LongRunning) 能拿到一个专用线程,同时保留 Task 的 API。

Parallel.For 与 Parallel.ForEach

如果要“用上所有核心,对每一项都做这件事”,System.Threading.Tasks.Parallel 比手动创建任务更简单。它会把范围划分给多个线程,并在所有迭代完成后返回:

示例输出:

image 4 on thread 7
image 3 on thread 6
image 5 on thread 8
image 0 on thread 1
image 1 on thread 4
image 2 on thread 5
Total pixels: 385425

每张图片对应的那几行,会按线程实际执行的顺序输出,线程 ID 每次运行都不一样;只有总数是固定的。这就是并行循环的本质:各次迭代不能依赖彼此的顺序,任何共享状态都需要同步(这里用的是 Interlocked.Add)。Parallel.ForEach 对任意集合做同样的事,.NET 6 还新增了 Parallel.ForEachAsync,用于异步的循环体。

常见错误

  • 在异步代码中使用 .Result 或 .Wait()。 会阻塞线程,在 UI 应用中还可能死锁;请改用 await。
  • 在 .Result 周围捕获原始异常类型。 失败会被包装在 AggregateException 中送达;await 会把它拆开。
  • 启动后就不管。 Task.Run(...) 既不 await 也不保存任务,它的异常就会丢失。
  • 用 Task.Run 包装 I/O。 包装异步方法只会多绕一次线程池;包装 File.ReadAllText 这样的阻塞方法,则会在整个等待期间占住一个线程池线程。请直接 await 异步 I/O 方法。
  • 忽略 token。 只传入 CancellationToken 没有任何作用,除非工作代码会去检查它。
  • 并行代码中未同步的共享状态。 请使用 Interlocked、lock,或者让每个任务使用自己的数据。

常见问题

C# 中的 Task 是什么?

Task 表示一个可能在将来完成的操作:在线程池上运行的代码、一个计时器、一次 I/O 请求。它可以被 await,会告诉你它是已完成、失败还是被取消,而 Task<T> 还带有一个结果。Task 是 async/await 底层的基础构件。

C# 中 Task 和 Thread 有什么区别?

Thread 是你自己创建和管理的操作系统线程,拥有自己的栈;启动它开销很大,而且它不返回值。Task 描述的是一项工作,Task.Run 会把它安排到一个由可复用线程组成的共享池上执行;它可以返回结果、传递异常、被取消,也可以被 await。除非你需要一个带特殊设置、长期运行的专用线程,否则就用 Task。

什么时候应该用 Task.Run?

用于你想移出当前线程的 CPU 密集型工作:图像处理、大型计算、在内存中解析大文件,尤其是为了让 UI 保持响应。不要用 Task.Run 包装 I/O:像 ReadAllTextAsync 这样的异步 I/O 方法在等待期间本来就会释放线程。

C# 中如何取消一个 Task?

创建一个 CancellationTokenSource,把它的 Token 传给要执行的工作,然后在 source 上调用 Cancel()(或 CancelAfter(timeout))。工作本身必须配合:在循环中检查 token.IsCancellationRequested 或调用 token.ThrowIfCancellationRequested(),并把 token 继续传给 Task.Delay 这类异步 API。await 一个已取消的任务会抛出 OperationCanceledException。

Task.WhenAll 和 Task.WhenAny 有什么区别?

Task.WhenAll 在所有任务都完成时才完成,并把全部结果交给你。Task.WhenAny 在第一个任务完成时就立即完成,并返回那个任务。WhenAny 是添加超时的常用方法:让实际工作和 Task.Delay 赛跑。

为什么 Task.Result 会抛出 AggregateException?

.Result 和 .Wait() 会把任何失败包装进 AggregateException,因为一般来说一个任务可能包含多个异常。await 会拆开这层包装,直接重新抛出第一个内部异常,这也是优先使用 await 的原因之一。如果你必须阻塞等待,.GetAwaiter().GetResult() 同样会抛出原始异常。

Coddy 编程语言插图

和 Coddy 一起学编程

开始