1. 介绍
在异步编程的世界里,我们遵循“带回调的委托”原则。想象一下:你需要下载一个超大文件、分析几GB 日志或向远端服务器发请求。与其像雕像一样僵在那里等,不如对系统说:“你去处理这件事,我先去做别的。做完了记得告诉我!”
这时候 Task 就登场了——它是这种做法的优雅体现。它不仅是个技术抽象,还是一种“聪明的中间人”,承担执行工作并保证结果不会丢失。
Task 像个私人助理,你把重要的事交给它。它点头,把任务记在本子上,说:“你放心去做别的吧,我会在完成后带着结果来找你。” 它真的会带来结果,或者会诚实地说明哪里出了问题。
如果找更生活化的比喻,Task 更像现代的网上预约系统:你在线挂号,拿到确认,就不用在候诊室里耗时间。系统会在就诊时间临近时提醒你,而你 meanwhile 可以正常生活。
类 Task
Task 是 .NET 异步编程的基本构建块。它表示已启动或将来会完成的操作,其结果将在未来可用。如果方法无需返回值,就用简单的 Task。
public async Task BackupToCloudAsync()
{
// 做备份的魔法,不返回任何东西
}
类 Task<TResult>
如果需要返回结果(比如字符串、数字、对象…),就用 Task<TResult>:
public async Task<string> DownloadHtmlAsync(string url)
{
// 下载页面并返回 HTML 代码
return "<html>...</html>";
}
为什么用 Task 而不是 Thread?
Thread 管理的是线程本身(这复杂且容易出错),而 Task 是更高层的抽象:它可以在线程池中执行,或在不分配新线程的情况下异步工作(比如 I/O 操作),不需要你操心底层细节。
用 Task 你只需要描述:“我想运行这个操作”,至于如何执行,让 .NET 去决定吧!
2. Task 对象的结构
需要知道的 Task 属性和方法
| 属性 / 方法 | 说明 |
|---|---|
|
任务的当前状态 |
|
Task<TResult> 的结果(会阻塞 线程) |
|
任务是否完成 |
|
任务是否抛出异常 |
|
任务是否被取消 |
|
阻塞当前线程直到完成(危险) |
|
在完成后再启动另一个任务 |
|
如果 Task 以错误结束,访问异常信息 |
|
任务的唯一标识符 |
带 Task 的异步方法如何工作
sequenceDiagram
participant Main as Main 线程
participant Task as Task (后台任务)
Main->>Task: 启动 Task.Run(() => ...)
Note right of Task: 后台执行
(CPU 或 I/O)
alt 任务完成
Task->>Main: await 完成,继续执行
else 错误
Task->>Main: await 抛出异常
end
3. 创建和启动任务:Task 是怎么工作的
带 async 的异步方法
最常见的情况是把方法声明为 async,并返回 Task 或 Task<TResult>(就像上面看到的)。
Task.Run:在线程池中执行
如果需要在后台执行耗时工作(比如计算大数或转码视频),可以用 Task.Run:
Task work = Task.Run(() =>
{
// 复杂计算 — 不要阻塞主线程!
Console.WriteLine("后台计算开始...");
Thread.Sleep(2000); // 模拟耗时工作
Console.WriteLine("后台计算完成!");
});
如果需要得到结果:
Task<int> calculateTask = Task.Run(() =>
{
// 比如计算前 100 个数的和
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
});
Task.Factory.StartNew
这是更低层更灵活的方式,允许精细配置任务启动(比如指定调度器、传递参数等)。在现代代码中通常推荐用 Task.Run,因为它更简单并能避免错误。
4. 今天的应用示例:我们的图书目录
假设我们有个图书目录应用,需要从“云”源加载书籍——这会是 I/O-bound 操作(慢的 HTTP 请求或文件读取)。
添加一个异步“加载”书籍的方法(模拟延迟):
public class Book
{
public string Title { get; set; }
public string Author { get; set; }
}
public class BookCatalog
{
public List<Book> Books { get; set; } = new();
public async Task LoadBooksAsync()
{
Console.WriteLine("正在加载书籍...");
await Task.Delay(2000); // 模拟耗时加载(例如 HTTP 或 文件)
Books = new List<Book>
{
new Book { Title = "CLR via C#", Author = "Jeffrey Richter" },
new Book { Title = "C# in Depth", Author = "Jon Skeet" }
};
Console.WriteLine("书籍已成功加载。");
}
}
在 Main 中调用异步加载(使用 await):
var catalog = new BookCatalog();
await catalog.LoadBooksAsync();
Console.WriteLine($"目录中有 {catalog.Books.Count} 本书。");
表格:创建和启动 Task 的主要方式
| 创建方法 | 如何使用 | 结果 | 用途 |
|---|---|---|---|
| async 方法 | |
异步操作 | 通常用于 I/O,方便 |
|
|
后台任务 | CPU-bound(计算密集型) |
|
手动创建并完成 Task | 完全由程序员控制 | 少用,适合底层场景 |
5. Task 的生命周期
Task 可以处于不同状态:
- Created — 任务已创建,但未启动(适用于需要显式启动的 Task)。
- WaitingToRun — 在线程池队列中等待。
- Running — 正在执行。
- WaitingForActivation — 等待启动或外部激活。
- RanToCompletion — 已成功完成。
- Faulted — 以错误(异常)结束。
- Canceled — 被取消(如果支持取消)。
图示
flowchart LR
Start -->|启动任务| Running
Running -->|成功| Completed
Running -->|错误| Faulted
Running -->|取消| Canceled
实践验证
Task task = Task.Run(() =>
{
Thread.Sleep(1000);
});
Console.WriteLine(task.Status); // 通常: Running 或 WaitingToRun
await task;
Console.WriteLine(task.Status); // 完成后是 RanToCompletion
6. 如何从 Task<TResult> 获取结果?
Task<TResult> 是对未来会出现结果的包装。当需要等待结果时,用 await:
Task<int> sumTask = Task.Run(() =>
{
int sum = 0;
for (int i = 1; i <= 5; i++) sum += i;
return sum;
});
int result = await sumTask;
Console.WriteLine(result); // 15
如果忘了写 await,你会得到一个 Task(一个承诺),而不是结果。这是常见的“异步坑”。
替代:同步获取结果(不要在 UI 中这样做!)
有时(比如在测试中)需要不使用 await 就取结果。可以用属性 .Result:
int result = sumTask.Result;
但如果 Task 还没完成,这行代码会 阻塞 线程,如果是在 UI 线程,应用会卡死!所以:尽量总是用 await。
关于 Task 和 Task<TResult> 的常见错误
忘记返回 Task,方法变成 void。如果方法没有返回值——请返回 Task,不要用 void,否则无法捕获错误。
忽略 await。只是调用方法不等待,任务就会“自顾自”地运行(“fire and forget”)。你无法知道它什么时候完成或是否报错。
通过 .Result 或 .Wait() 进行阻塞等待。特别容易在 UI 和 ASP.NET 中造成 deadlock。尽量只用 await。
7. Task 的高级功能
任务链:ContinueWith
可以在任务完成后“挂”上要执行的动作,使用 ContinueWith:
Task.Run(() => 10)
.ContinueWith(t =>
{
Console.WriteLine($"完成了!结果: {t.Result}");
});
但在现代 C# 中通常用 async/await 来实现,阅读起来更清晰。
示例:并行与顺序加载数据
假设你要从不同来源加载两本书。可以并行启动两个 Task 并等待它们都完成:
public async Task LoadBooksFromMultipleSourcesAsync()
{
Task<List<Book>> t1 = LoadFromCloudAsync();
Task<List<Book>> t2 = LoadFromLocalAsync();
// 并行等待两者完成
await Task.WhenAll(t1, t2);
// 合并结果
Books = t1.Result.Concat(t2.Result).ToList();
}
private async Task<List<Book>> LoadFromCloudAsync()
{
await Task.Delay(2000); // "云"
return new List<Book> { new Book { Title = "Cloud Book", Author = "Cloud Author" } };
}
private async Task<List<Book>> LoadFromLocalAsync()
{
await Task.Delay(1000); // "本地磁盘"
return new List<Book> { new Book { Title = "Local Book", Author = "Local Author" } };
}
注意:用 await Task.WhenAll(...) 时,两个请求会同时启动并并行执行(如果可能),应用会等到两者都完成。
8. 有用的细节
Task 和 Fire-and-forget
有时候你想启动一个任务但不等它完成(比如发送日志到云或“烤个吐司”,用户继续操作):
async void LogToCloudAsync(string message)
{
await Task.Run(() =>
{
// 发送日志的耗时操作
Thread.Sleep(1000);
Console.WriteLine($"日志已发送: {message}");
});
}
但记住:这种任务如果发生错误,很难发现。所以如果可能,返回 Task 并至少在内部记录异常!
Task 和 Task<TResult> 在真实场景中的用法
- 在客户端 UWP/WPF/WinForms 应用中不要阻塞 UI——对长时间操作(文件、网络)使用 Task。
- 在 WebAPI/ASP.NET 中 Task 有助于不浪费线程等待网络/数据库,从而提升性能。
- 组织“并行”执行:同时下载、处理和保存。
- 几乎所有耗时的方法都有 Async 版本:File.ReadAllTextAsync、HttpClient.GetStringAsync 等。
常见问答和意外情况
问题:为什么 Task 有时会同步执行?
回答:如果操作已经完成(比如结果被缓存),编译器或调度器可能会在同一线程上同步完成方法。这是正常的,可以加速重复调用。
问题:为什么不能使用 async void?
回答:这样的函数无法被等待,无法捕获其异常,也无法跟踪完成状态。除非是 EventHandler(例如 Button_Click),否则请使用 Task。
问题:能否启动多个任务然后只等待其中一个?
回答:可以——使用 Task.WhenAny。
GO TO FULL VERSION