C# · #csharp#dotnet#async#threading

C# async/await 异步编程深入解析

2017.01.18 C# 3 min 1.3k
// 目录 · contents

async/await 是 C# 5.0 引入的特性,经过两年实际使用,我发现大多数人只是把它当成”让代码不阻塞”的语法糖来用,对底层机制不甚了解,导致写出的异步代码要么有性能问题,要么在某些情况下死锁。这篇文章是我把这块知识系统梳理之后的总结。

1. async/await 的本质:状态机

编译器会把 async 方法转换成一个状态机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 你写的代码
public async Task<string> GetUserNameAsync(int userId)
{
var user = await _db.Users.FindAsync(userId);
var profile = await _api.GetProfileAsync(user.Email);
return profile.DisplayName;
}

// 编译器生成的等价代码(简化版)
public Task<string> GetUserNameAsync(int userId)
{
var stateMachine = new GetUserNameAsyncStateMachine
{
_this = this,
userId = userId,
_state = -1
};
stateMachine.MoveNext();
return stateMachine._builder.Task;
}

// 状态机:每个 await 点对应一个状态
private struct GetUserNameAsyncStateMachine : IAsyncStateMachine
{
public int _state;
public AsyncTaskMethodBuilder<string> _builder;
// ... 局部变量和参数

public void MoveNext()
{
switch (_state)
{
case -1: // 初始状态
var awaiter1 = _db.Users.FindAsync(userId).GetAwaiter();
if (!awaiter1.IsCompleted)
{
_state = 0;
awaiter1.OnCompleted(MoveNext); // 注册回调
return;
}
goto case 0;
case 0: // await FindAsync 完成后继续
user = awaiter1.GetResult();
// ... 类似处理 GetProfileAsync
}
}
}

理解状态机让你明白:await 不是创建新线程,而是注册回调后释放当前线程

2. ConfigureAwait(false)

这是 async 代码里最重要也最容易忽略的细节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 在 UI 应用(WinForms/WPF)或 ASP.NET 经典版本中:
public async Task LoadDataAsync()
{
// await 后,后续代码会回到原来的 SynchronizationContext(UI 线程)执行
var data = await _service.FetchDataAsync();
// 这里运行在 UI 线程,可以更新控件
textBox.Text = data.Name;
}

// 在库代码或不需要回 UI 线程的情况下:
public async Task<string> FetchDataAsync()
{
// ConfigureAwait(false) 告诉运行时:不必回到原 Context
var response = await httpClient.GetStringAsync(url).ConfigureAwait(false);
// 这里可能运行在线程池线程,但对于纯数据处理这没关系
return Process(response);
}

库代码应该总是用 ConfigureAwait(false),原因: 1. 提高性能(避免无谓的线程切换) 2. 避免死锁(见下面的死锁陷阱)

3. 最危险的陷阱:async 与同步代码混用导致死锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 在 ASP.NET 经典版本(有 SynchronizationContext)中,以下代码会死锁:
public ActionResult DeadlockDemo()
{
// .Result 或 .Wait() 阻塞当前线程(假设是 ASP.NET 请求线程)
var result = GetDataAsync().Result; // 死锁!
return Content(result);
}

public async Task<string> GetDataAsync()
{
// await 后需要回到 ASP.NET 的 SynchronizationContext
// 但那个线程已经被 .Result 阻塞了 → 死锁
var data = await httpClient.GetStringAsync(url);
return data;
}

// 解决方案 1:全程使用 async/await
public async Task<ActionResult> FixedDemo()
{
var result = await GetDataAsync();
return Content(result);
}

// 解决方案 2:在库方法里加 ConfigureAwait(false)
public async Task<string> GetDataAsync()
{
var data = await httpClient.GetStringAsync(url).ConfigureAwait(false);
return data;
}

黄金法则:async 具有传染性,一旦开始用就要贯穿到底,不要在异步代码中混用 .Result/.Wait()

4. 并行执行多个异步操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 串行:慢(总时间 = T1 + T2 + T3)
var user = await GetUserAsync(userId);
var orders = await GetOrdersAsync(userId);
var profile = await GetProfileAsync(userId);

// 并行:快(总时间 = max(T1, T2, T3))
var userTask = GetUserAsync(userId);
var ordersTask = GetOrdersAsync(userId);
var profileTask = GetProfileAsync(userId);

await Task.WhenAll(userTask, ordersTask, profileTask);

var user = await userTask;
var orders = await ordersTask;
var profile = await profileTask;

注意Task.WhenAll 失败时会抛出第一个异常,如果需要获取所有异常:

1
2
3
4
5
6
7
8
var tasks = new[] { Task1(), Task2(), Task3() };
var results = await Task.WhenAll(tasks);
// 如果有任务失败,需要检查每个 task 的 Exception
foreach (var task in tasks)
{
if (task.IsFaulted)
Console.WriteLine(task.Exception);
}

5. 取消异步操作:CancellationToken

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public async Task<List<User>> SearchUsersAsync(
string keyword,
CancellationToken cancellationToken = default)
{
// 检查是否已取消
cancellationToken.ThrowIfCancellationRequested();

var response = await httpClient.GetAsync(
$"/api/users?q={keyword}",
cancellationToken); // 传递 token,HTTP 请求可以被取消

var json = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<List<User>>(json);
}

// 调用方:设置超时
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
try
{
var users = await SearchUsersAsync("alice", cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("搜索超时");
}

6. 异步流(.NET Core 3.0 预告)

虽然当时还是 2017 年,但值得提前了解:IAsyncEnumerable<T> 可以实现真正的异步流式处理:

1
2
3
4
5
6
7
8
// 未来(.NET Core 3.0)可以这样写
public async IAsyncEnumerable<User> GetUsersStreamAsync()
{
await foreach (var batch in _db.Users.AsAsyncEnumerable())
{
yield return batch;
}
}

总结

async/await 核心要点:

  • 本质是状态机,不是新线程,而是释放线程等待 I/O
  • 库代码加 ConfigureAwait(false),应用代码按需决定
  • 禁止混用 .Result/.Wait(),在有 SynchronizationContext 的环境下必然死锁
  • 并行任务用 Task.WhenAll,不要串行 await 独立任务
  • CancellationToken 贯穿始终,让调用方有能力取消长时间操作

async/await 语法简单,但正确使用需要理解底层机制。这些坑我基本都亲自踩过,希望这篇文章能帮你少走弯路。

作者 · authorzt
发布 · date2017-01-18
篇幅 · length1.3k 字 · 3 min
许可 · licenseCC BY-SA 4.0
$ echo "comments" · 评论