C# async/await:异步不是开新线程

解释 async/await 的执行模型、Task、异常处理和常见误区,帮助写出更稳定的异步 C# 代码。

async/await 是现代 C# 里最常见的语法之一,但它也很容易被误解。最重要的一点是:异步不等于开一个新线程。

async 方法返回什么

常见的异步方法返回 TaskTask<T>

public async Task<string> ReadTitleAsync(string path)
{
    var text = await File.ReadAllTextAsync(path);
    return text.Split('\n')[0];
}

Task<T> 表示“将来会产生一个 T 类型结果的操作”。调用方通过 await 等它完成。

var title = await ReadTitleAsync("notes.md");

await 不会让线程傻等在那里。对于 I/O 操作,它通常会把控制权还给调用方,等操作完成后再继续执行后面的代码。

异步适合 I/O 等待

异步最擅长处理网络请求、文件读写、数据库调用、消息队列等 I/O 密集场景。

public async Task<User?> FindUserAsync(Guid id)
{
    return await db.Users.FindAsync(id);
}

这类操作的大部分时间都在等外部系统响应。异步可以让线程在等待期间去处理别的工作,提高吞吐。

如果是 CPU 密集计算,async 本身不会让计算更快。那时应该考虑算法优化、并行计算或后台任务,而不是盲目加 async

不要随手 .Result.Wait()

同步阻塞异步任务容易造成死锁或线程池耗尽。

// 不推荐
var user = FindUserAsync(id).Result;

更好的方式是让异步一路向上传递:

public async Task<IActionResult> GetUser(Guid id)
{
    var user = await FindUserAsync(id);
    return user is null ? NotFound() : Ok(user);
}

这条原则常被总结为:async all the way。

异常会被放进 Task

异步方法里的异常不会消失。await 时,异常会重新抛出。

try
{
    await SendEmailAsync(message);
}
catch (SmtpException ex)
{
    logger.LogError(ex, "Email send failed");
}

如果你创建了一个任务却从不 await,它的异常就可能变得难以追踪。

_ = SendEmailAsync(message); // 需要明确知道如何处理失败

真正需要 fire-and-forget 时,应该有日志、重试或后台队列来兜底。

CancellationToken 是异步 API 的礼貌

长时间运行或可能等待外部资源的异步方法,最好支持取消。

public async Task<string> DownloadAsync(
    HttpClient client,
    string url,
    CancellationToken cancellationToken)
{
    return await client.GetStringAsync(url, cancellationToken);
}

这样调用方在请求中断、超时、用户离开页面时,可以及时停止无意义的工作。

小结

async/await 的目标不是让每段代码都“并发”,而是让等待更便宜,让控制流更清楚。

写异步代码时可以记住:

  • I/O 等待适合异步,CPU 计算不一定适合。
  • 不要混用 .Result.Wait()await
  • 异步异常要被 await 或集中处理。
  • 公开异步 API 时考虑 CancellationToken

异步代码写得好,看起来反而应该像普通顺序代码一样平静。