异步编程是现代C#开发的基石。它使应用程序更具响应性、可扩展性和效率——特别是在处理文件访问、数据库查询和Web请求等I/O密集型操作时。
无论你是构建API、桌面应用程序还是基于云的服务,理解async和await的底层工作原理都能帮助你编写更简洁、更快速的代码。
在本指南中,我们将探讨:
🧵 什么是线程? 在深入async/await之前,了解线程很有帮助。
线程是进程中最小的执行单元。每个C#应用程序都从一个主线程开始,它按顺序执行你的代码。
如果在主线程上运行长时间或耗时的操作(例如读取文件、发出Web请求),线程会被阻塞。这意味着应用程序在该任务完成之前无法响应输入或执行其他任何操作。
然而,关键洞见在于:大多数I/O绑定操作(如调用Web API或读取文件)实际上并不需要线程在整个过程中都保持活动状态。它们所做的只是发起一个请求——例如发送网络数据包或请求操作系统读取文件——然后等待响应。
⚙️ 异步I/O的内部工作原理 现代C#中的异步I/O使用事件驱动模型:
这正是异步编程所实现的:它通过在await点暂停执行来让应用程序执行非阻塞I/O,同时释放线程去做其他工作。这提高了响应性、资源效率和可扩展性,特别是在高吞吐量或基于UI的应用程序中。
🔄 C#中的阻塞I/O与异步I/O 让我们看一个简单的例子:读取文件。
❌ 阻塞I/O(同步)
public static void Main(string[] args)
{
Console.WriteLine("Reading file...");
// 这会阻塞当前线程,直到文件完全读取
string content = File.ReadAllText("sample.txt");
Console.WriteLine("File content length: " + content.Length);
Console.WriteLine("Done");
}
这里发生了什么?
✅ 非阻塞I/O(异步)
public static async Task Main(string[] args)
{
Console.WriteLine("Reading file...");
// 异步读取文件而不阻塞线程
string content = await File.ReadAllTextAsync("sample.txt");
Console.WriteLine("File content length: " + content.Length);
Console.WriteLine("Done");
}
这里发生了什么?
⏸️ await实际做了什么? 让我们通过一个例子来分析:
public static async Task Main(string[] args)
{
Console.WriteLine("Hello, World!");
var result = await DoWork();
Console.WriteLine(result);
}
public static async Task<string> DoWork()
{
await Task.Delay(1000); // 假设这里发生了一些I/O操作
return "Work completed!";
}
🧠 幕后机制:
⚠️ 如果不使用await会怎样?
public static async Task<string> DoWork()
{
Task.Delay(1000); // 移除了'await'
return "Work completed!";
}
现在,Task.Delay(1000);只是启动,但没有等待。这意味着:
这看起来好像延迟没有工作,但实际上任务正在后台运行——你的代码只是没有等待它完成。这通常被称为"发射后不管"模式。
🧵 SynchronizationContext:控制代码恢复位置 在学习异步编程时,了解SynchronizationContext也很重要。当你在C#中await某个东西时,你不仅仅是暂停执行——你还在指定一旦等待的任务完成,代码的其余部分应该在何处恢复。
这个"何处"由SynchronizationContext决定
它是什么? SynchronizationContext控制异步代码在await后如何恢复。它决定你的延续在哪个线程或环境中运行。
按回车或点击查看完整图像
.NET生态系统中各种SynchronizationContext行为 如你所见,在UI应用程序中在同一线程上恢复执行很重要。那么,我们如何确保这一点呢?这就是ConfigureAwait方法发挥作用的地方。
⚡ ConfigureAwait ConfigureAwait方法接受一个名为continueOnCapturedContext的布尔参数。当设置为true时,它尝试在await之前捕获的原始上下文(如桌面应用程序中的UI线程)上恢复执行。
在控制台应用程序或性能敏感的服务器代码中,你通常不关心在原始上下文上恢复。在这些情况下,你可以通过以下方式略微提高性能:
await Task.Delay(1000).ConfigureAwait(false);
这告诉运行时: "我不关心在哪里恢复——只需在任何可用线程上继续。"
这使你的代码更高效,特别是在:
🖥️ 示例
static async Task Main(string[] args)
{
Console.WriteLine($"Before: {Thread.CurrentThread.ManagedThreadId}");
await Task.Delay(1000).ConfigureAwait(false);
Console.WriteLine($"After: {Thread.CurrentThread.ManagedThreadId}");
}
// 输出
Before: X
After: Y
X和Y可能不同——因为ConfigureAwait(false)不会在原始上下文上恢复
❌ 使用CancellationToken优雅取消异步操作 在我们结束之前,还有一个需要理解的概念:'取消令牌'。在现实世界的应用程序中,可能需要取消长时间运行或异步的操作。例如:
这就是CancellationToken发挥作用的地方。
🛠️ 取消工作原理 CancellationToken是一个充当信号的结构体。你将它传递给异步方法,它们会定期检查是否请求了取消。
如果触发了取消,操作可以提前中止,而不是运行到完成。
public static async Task Main(string[] args)
{
using var cts = new CancellationTokenSource();
// 2秒后取消
cts.CancelAfter(2000);
try
{
await DoWorkAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation was cancelled.");
}
}
public static async Task DoWorkAsync(CancellationToken token)
{
Console.WriteLine("Starting long-running work...");
// 模拟5秒的工作
for (int i = 0; i < 5; i++)
{
token.ThrowIfCancellationRequested(); // 检查取消
await Task.Delay(1000, token); // 这里也传递token
Console.WriteLine($"Completed part {i + 1}");
}
Console.WriteLine("Work completed successfully.");
}
🔍 这里发生了什么?
🧠 最佳实践
在长时间运行的方法中定期检查token.IsCancellationRequested或调用token.ThrowIfCancellationRequested()
将CancellationToken传递给所有支持它的异步方法(如Task.Delay、HttpClient.SendAsync等)
避免忽略取消——你的应用程序在负载或关闭条件下会变得不那么响应
它提高了响应性并支持优雅关闭
异步编程在I/O操作期间释放线程,提高响应性和可扩展性
await暂停执行而不阻塞线程
当你不需要在原始上下文上恢复时,使用ConfigureAwait(false)
如果你正在使用UI或传统应用程序,请理解SynchronizationContext
通过掌握这些概念,你将在C#应用程序中解锁更好的性能和更清晰的代码。