很多初学者有一个误解:
await Task.Delay(2000);
好像就是:
Thread.Sleep(2000);
但实际上两者完全不同。
一、同步代码的执行模型
先看同步代码:
static void Foo()
{
Console.WriteLine("A");
Thread.Sleep(2000);
Console.WriteLine("B");
}
执行流程:
线程进入 Foo
↓
执行 A
↓
Sleep 2 秒(线程被阻塞)
↓
执行 B
↓
返回
特点:
线程一直在这个方法里 即使等待期间也不能干别的事情
二、async / await 的代码执行过程
现在看异步代码:
async Task FooAsync()
{
Console.WriteLine("A");
await Task.Delay(2000);
Console.WriteLine("B");
}
表面看起来和同步几乎一样。但执行流程完全不同。
当执行到:
await Task.Delay(2000);
运行时发生三件事:
1. 创建 Task
Task t = Task.Delay(2000);
这个 Task 表示:2 秒后完成
2. 检查 Task 是否完成
运行时会检查:
if (!t.IsCompleted)
因为还没完成,所以继续下面步骤。
3. 挂起方法
编译器会把方法拆成 状态机。
此时:
当前方法暂停 记录执行位置 注册 continuation
也就是:
t.ContinueWith(_ => ResumeMethod());
当 Task 完成时,恢复方法执行。然后:当前线程立即返回。
4. 两秒后发生什么?
Task.Delay 完成时:运行时会触发 continuation。于是:
线程池线程
↓
恢复 FooAsync
↓
执行 Console.WriteLine("B")
↓
Task 完成
核心对比
这就是为什么:async / await 可以支持高并发。
四、一个非常关键的认知
很多人误以为:
await 会创建新线程
这是 错误的。await 的真实行为是:
如果 Task 未完成 挂起当前方法 注册 continuation 线程立即返回
所以:await 的核心不是线程,而是 暂停与恢复执行流。
一个简单的实验,运行下面代码:
static async Task Test()
{
Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
await Task.Delay(1000);
Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
}
在 Console 程序 中很可能看到:
1
5
说明:
方法恢复时可能是另一个线程
这再次证明:async 方法并不绑定某个线程。
我们需要记住一句话:**await 并不会等待,它只是挂起方法。**真正等待的是:Task 本身。
五、真正的场景:服务器处理请求
假设一个 Web 服务器。
每个请求都要调用数据库:
var data = await db.GetDataAsync();
数据库响应需要 1 秒。
现在同时来了 1000 个请求。
情况 A:同步代码
var data = db.GetData(); // 阻塞
执行过程:
线程1 等数据库
线程2 等数据库
线程3 等数据库
...
线程200 等数据库
假设服务器最多 200 个线程。
那么:
前 200 个请求占满线程 剩下 800 个请求只能排队
吞吐量被线程数限制。
情况 B:async/await
var data = await db.GetDataAsync();
执行过程:
线程1 发起数据库请求
线程1 释放
线程2 发起数据库请求
线程2 释放
线程3 发起数据库请求
线程3 释放
所有请求都可以:
发起数据库 I/O 然后 立即释放线程
线程可以继续处理新的请求。
数据库返回时:
线程池拿一个线程 执行 continuation
关键差别
同步模型:
线程被占住等待 I/O
异步模型:
线程只用于发起请求和处理结果
等待期间不占线程
所以:
这就是:
async/await 的可扩展性。
“线程去干别的事”到底是什么
不是同一个方法干别的事。
而是:
线程返回线程池,然后被用来执行其他 Task。
例如:
线程1 执行请求A
await DB
线程1 返回线程池
线程1 执行请求B
await HTTP
线程1 返回线程池
线程1 执行请求C
await File
一个线程在一秒内可以处理很多“发起请求”的操作。