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
Thread | Task | |
|---|---|---|
| 运行在 | 一个新的专用操作系统线程上 | 线程池线程上(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() 同样会抛出原始异常。