本篇博客写于 2022-08-12,现在从我的旧博客搬运过来,原地址:https://blog.kitlau.dev/posts/what-is-asynchronous-is-it-multi-threading-or-async-await/
1. 什么是异步
异步编程就像去餐厅吃饭,服务员把菜单给客人,然后继续去服务其他客人了,客人点餐后再叫服务员。此时来服务这桌客人的未必是之前给这桌客人发菜单的服务员,而极有可能是此时正空闲的其它服务员。服务员再按照菜单让厨房做菜。
异步编程的缺点:同步编程就像是服务员一直等这桌客人点完餐,立即收走菜单去让厨房做菜,这个响应速度是很快的。而异步编程的情况下,客人点完餐后呼叫服务员时,未必有空闲的服务员,可能服务员会稍等一会才能过来。即使有空闲的服务员,也未必刚好在这桌客人身边,所以也需要时间移动到客人身边。
优点:整个餐厅原有的服务员人数能够同时接待更多桌客人。
2. 异步就是多线程吗?
不是,异步并不等于多线程。但多线程是异步模式的一项典型用途。
1. 异步方法中的代码不会自动在新线程中执行;
2. 但可以把代码放到新线程中执行。
在同一个线程中执行异步方法
public class Program
{
public static async Task Main()
{
System.Console.WriteLine("1.Main: " + Thread.CurrentThread.ManagedThreadId);
int methodResult = await MethodAsync();
System.Console.WriteLine("2.Main: " + Thread.CurrentThread.ManagedThreadId);
}
private static async Task<int> MethodAsync()
{
System.Console.WriteLine("MethodAsync: " + Thread.CurrentThread.ManagedThreadId);
int result = 1;
for (int i = 0; i < 100; i++)
{
result += i;
}
return result;
}
}
// 输出结果:
// 1.Main: 1
// MethodAsync: 1
// 2.Main: 1
可以看到 Main 方法和异步方法 MethodAsync 的线程 Id 都为 1。
使用 Task.Run
或 Task.Factory.StartNew
让代码在新线程中执行
// 调整一下 MethodAsync 的代码:
private static async Task<int> MethodAsync()
{
System.Console.WriteLine("MethodAsync: " + Thread.CurrentThread.ManagedThreadId);
return await Task.Run(() =>
{
System.Console.WriteLine("MethodAsync_Task.Run: " + Thread.CurrentThread.ManagedThreadId);
int result = 1;
for (int i = 0; i < 100; i++)
{
result += i;
}
return result;
});
}
// 输出结果:
// 1.Main: 1
// MethodAsync: 1
// MethodAsync_Task.Run: 4
// 2.Main: 4
执行完 Task.Run
后,线程 Id 和后面 MainAsync 中的线程 Id 都变成 4,说明线程切换成功。
Task.Run
封装了 Task.StartNew
,如果你想用更复杂参数来更加精细化控制,可以使用 Task.StartNew
。
常规的 async/await 用法这里不再介绍,这两个关键字在 C# 5 引入后,使得异步编程非常简单了。这里简单提几个可能有初学者不知道的知识。
3. 不使用 await 调用异步方法会发生什么
在我看过的 dotnet 新手入门视频教程和博客中,所有的异步方法调用时都会加一个 await。我在初入 dotnet 世界的那几个月一直把它当作铁律执行。学而不思则罔,幸运的是应试教育并没有腐蚀掉我所有的思考能力,抑或是电子游戏帮我守住了大脑中最后的的独立思考阵地。我开始问自己:如果我不 await 呢?
public static void Main()
{
System.Console.WriteLine("Start");
System.Console.WriteLine("1.Main: " + Thread.CurrentThread.ManagedThreadId);
Sleep10sAsync();
System.Console.WriteLine("Stop");
System.Console.WriteLine("2.Main: " + Thread.CurrentThread.ManagedThreadId);
}
private static async Task Sleep10sAsync()
{
System.Console.WriteLine("1.Sleep10s: " + Thread.CurrentThread.ManagedThreadId);
System.Console.WriteLine("Sleep");
await Task.Delay(10000); // 睡 10s
System.Console.WriteLine("2.Sleep10s: " + Thread.CurrentThread.ManagedThreadId);
System.Console.WriteLine("Wake up");
}
// 输出结果:
// Start
// 1.Main: 1
// 1.Sleep10s: 1
// Sleep
// Stop
// 2.Main: 1
这段程序瞬间执行完成。Sleep10sAsync
方法是一个异步方法,在 Main 中并没有 await 它。
通过打印出的线程 Id,可以知道,在从 Main 执行到 Sleep10sAsync 中的 await 语句之前,一直是线程 1,最后程序结束还是线程 1,并没有等待 10s。
Sleep10sAsync()
方法中的以下两行代码的运行结果也没有打印到控制台:
System.Console.WriteLine("2.Sleep10s: " + Thread.CurrentThread.ManagedThreadId);
System.Console.WriteLine("Wake up");
现在还看不出什么,我们将 Main 方法改造一下,用 await 来调用 Sleep10sAsync()
方法:
public static async Task Main()
{
System.Console.WriteLine("Start");
System.Console.WriteLine("1.Main: " + Thread.CurrentThread.ManagedThreadId);
await Sleep10sAsync();
System.Console.WriteLine("Stop");
System.Console.WriteLine("2.Main: " + Thread.CurrentThread.ManagedThreadId);
}
private static async Task Sleep10sAsync()
{
System.Console.WriteLine("1.Sleep10s: " + Thread.CurrentThread.ManagedThreadId);
System.Console.WriteLine("Sleep");
await Task.Delay(10000); // 睡 10s
System.Console.WriteLine("2.Sleep10s: " + Thread.CurrentThread.ManagedThreadId);
System.Console.WriteLine("Wake up");
}
// 输出结果:
// Start
// 1.Main: 1
// 1.Sleep10s: 1
// Sleep
// 2.Sleep10s: 5
// Wake up
// Stop
// 2.Main: 5
这段程序耗时超过 10s 才执行完成。
结合这两次的执行结果可以得出结论:从 Main 开始一直执行到 Sleep10sAsync()
的 await 语句 await Task.Delay(10000);
之前,都是使用线程 1,Sleep10sAsync()
中的 await 操作告诉线程 1 可以先去做别的事了。等 10 秒后,线程 5 来执行后面的代码,最终也是在线程 5 上返回了 Sleep10sAsync()
方法,然后继续执行 Main 方法后半部分。
我们可以判断 Task.Delay
方法中有某段代码开启了新线程 5,与 await 这个关键字没什么关系。即:await 并不会开启新线程。
异步调用前的线程会在异步等待时放回线程池,异步等待结束后,会从线程池取一个空闲的线程,来运行异步等待调用结束后的后续代码。
上面这句话又引入了“异步等待”这种模糊的概念,我实在不知道怎么讲清楚,这是一个复杂的过程,或许只有看源码才能彻底理解,我们目前仅从逻辑上理解即可。
证明 await 并不会开启新线程
前面(2. 异步就是多线程吗?)的代码在 Task.Run
前面使用了 await 关键字,不能作为有力的 “await 并不会开启新线程” 的证据。我们稍微修改一下代码:
public static async Task Main()
{
Console.WriteLine("1.Main: " + Thread.CurrentThread.ManagedThreadId);
int methodResult = await MethodAsync();
Console.WriteLine("2.Main: " + Thread.CurrentThread.ManagedThreadId);
}
private static async Task<int> MethodAsync()
{
Console.WriteLine("1. MethodAsync: " + Thread.CurrentThread.ManagedThreadId);
Task<int> t = Task.Run(() =>
{
Console.WriteLine("MethodAsync_Task.Run: " + Thread.CurrentThread.ManagedThreadId);
int result = 1;
for (int i = 0; i < 100; i++)
{
result += i;
}
Console.WriteLine("result is " + result);
return result;
});
Thread.Sleep(1000); // 等 1s,防止下面的 Console.WriteLine 比 Task.Run 中的 Console.WriteLine 提前执行。此处不使用 await Task.Delay,防止引入新的 await 造成混淆
Console.WriteLine("2. MethodAsync: " + Thread.CurrentThread.ManagedThreadId);
return await t;
}
// 输出结果:
// 1.Main: 1
// 1. MethodAsync: 1
// MethodAsync_Task.Run: 6
// result is 4951
// 2. MethodAsync: 1
// 2.Main: 1
该例子并没有 await Task.Run
,但在 Task.Run
中的 Console.WriteLine("MethodAsync_Task.Run: " + Thread.CurrentThread.ManagedThreadId);
执行时打印的结果依旧可以发现线程切换到了 6。能够证明新线程并不是 await 开启的,而是 Task.Run
开启的。
既然开启新线程的不是 await 关键字,说明不需要 await 关键字也可以调用异步方法,那么 await 关键字的作用是什么?
4. await 的作用是什么?
我目前了解的 await 的作用是:
- 在当前操作(被 await 的这行代码)完成前,后续的代码暂不执行,但 await 不会阻塞当前线程;
- 执行到 await 表达式时,代码会检查该表达式是否已经得到执行结果,如果还没有,他就会创建一个续延(continuation),当 await 的操作结束后,再执行这个续延。续延会执行方法的剩余内容(await 表达式之后的内容);
- await 运算符会执行拆封操作,将
Task<T>
变为T
类型的对象。
await 还有很多要注意的点,特别是续延这个概念,我们可以后面另外开一篇文章说一说。
5. 先调用异步方法,后 await
既然知道不靠 await 来开启新线程,我们就可以先调用异步方法(大部分情况下是处理需要很长时间的网络请求和数据 I/O),然后去做别的事,等最后 await 异步方法返回的 Task,来保证异步方法执行完成。如果有返回值,await 还可以拆封,将 Task<T>
类型的返回值拆封出 T
类型的对象。
public static async Task Main()
{
Console.WriteLine("Start");
Console.WriteLine("1.Main: " + Thread.CurrentThread.ManagedThreadId);
Task<int> t = Sleep10sAndReturn100Async();
Console.WriteLine("Stop");
int result = await t;
Console.WriteLine(result);
Console.WriteLine("2.Main: " + Thread.CurrentThread.ManagedThreadId);
}
private static async Task<int> Sleep10sAndReturn100Async()
{
Console.WriteLine("1.Sleep10s: " + Thread.CurrentThread.ManagedThreadId);
Console.WriteLine("Sleep");
await Task.Delay(10000); // 睡 10s
Console.WriteLine("2.Sleep10s: " + Thread.CurrentThread.ManagedThreadId);
Console.WriteLine("Wake up");
return 100;
}
// 输出结果:
// Start
// 1.Main: 1
// 1.Sleep10s: 1
// Sleep
// Stop
// 2.Sleep10s: 4
// Wake up
// 100
// 2.Main: 4
在初学 dotnet 时,我错误地认为一定要 await 一个异步方法,才会调用这个异步方法。通过这个例子,我们:
- 把
Sleep10sAndReturn100Async()
方法的返回值直接赋值给一个Task<int> t
对象; - 再
Console.WriteLine("Sleep");
; - 再 await 这个
Task<int> t
对象。
观察输出的结果,重点观察 Sleep
和 Stop
的打印顺序是:
- 先打印了
Sleep
- 后打印了
Stop
即运行到 Main()
方法中的 Task<int> t = Sleep10sAndReturn100Async();
这行代码时,就开始了 Sleep10sAndReturn100Async()
方法的执行。
而 Main()
方法中 Console.WriteLine("Stop");
语句在 int result = await t;
语句之前,即打印 Stop
的语句在 await 语句之前。也就是还没有 await t
,Sleep10sAndReturn100Async()
方法就已经开始执行了。
由此可以证明:当调用异步方法时,异步方法就开始执行。await 语句只是等待它执行完成后再继续做后面的事。
在调用异步方法和 await 这个异步方法返回的 Task 之间,你还可以去做别的事。比如你还可以再运行一个与前一个异步方法无关的另一个异步方法:
public static async Task Main()
{
Console.WriteLine("Start");
Console.WriteLine("1.Main: " + Thread.CurrentThread.ManagedThreadId);
Task<int> t1 = Sleep10sAndReturn100Async();
Task<int> t2 = Sleep5sAndReturn200Async();
Console.WriteLine("Stop");
int result1 = await t1;
int result2 = await t2;
Console.WriteLine(result1);
Console.WriteLine(result2);
Console.WriteLine("2.Main: " + Thread.CurrentThread.ManagedThreadId);
}
private static async Task<int> Sleep10sAndReturn100Async()
{
Console.WriteLine("1.Sleep10sAndReturn100Async: " + Thread.CurrentThread.ManagedThreadId);
Console.WriteLine("Sleep10sAndReturn100Async: Sleep");
await Task.Delay(10000); // 睡 10s
Console.WriteLine("2.Sleep10sAndReturn100Async: " + Thread.CurrentThread.ManagedThreadId);
Console.WriteLine("Sleep10sAndReturn100Async: Wake up");
return 100;
}
private static async Task<int> Sleep5sAndReturn200Async()
{
Console.WriteLine("1.Sleep5sAndReturn200Async: " + Thread.CurrentThread.ManagedThreadId);
Console.WriteLine("Sleep5sAndReturn200Async: Sleep");
await Task.Delay(5000); // 睡5s
Console.WriteLine("2.Sleep5sAndReturn200Async: " + Thread.CurrentThread.ManagedThreadId);
Console.WriteLine("Sleep5sAndReturn200Async: Wake up");
return 200;
}
// 输出结果:
// 1.Main: 1
// 1.Sleep10sAndReturn100Async: 1
// Sleep10sAndReturn100Async: Sleep
// 1.Sleep5sAndReturn200Async: 1
// Sleep5sAndReturn200Async: Sleep
// Stop
// 2.Sleep5sAndReturn200Async: 8
// Sleep5sAndReturn200Async: Wake up
// 2.Sleep10sAndReturn100Async: 8
// Sleep10sAndReturn100Async: Wake up
// 100
// 200
// 2.Main: 8
这个例子中先执行了 Sleep10sAndReturn100Async()
,但并未 await 它返回的 Task t1,而是接着执行了 Sleep5sAndReturn200Async()
。后面先后 await 了它们两个返回的 Task t1 和 t2。
观察运行结果可以发现 Sleep10sAndReturn100Async()
先开始执行,然后 Sleep5sAndReturn200Async()
开始执行,但是 Sleep5sAndReturn200Async()
却先结束,整个程序的运行时间也只有 10 秒钟多一点,而不是 15 秒钟多。说明这两个方法是并行运行的。
可以印证前面对 await 功能的描述:await 就是看一下异步方法返回的 Task<T>
是否完成,如果完成了,直接拆封,拿到 T
类型的返回值;如果该 Task<T>
尚未完成,就等待它完成再拆封拿返回值,再继续运行后续代码。如果 await 没有返回值的 Task
,就只是单纯等待它完成。
6. 不使用 async 修饰的异步方法
前面提了(3. 不使用 await 调用异步方法),现在看一下不使用 async 修饰的异步方法:
public static async Task Main()
{
System.Console.WriteLine("1.Main: " + Thread.CurrentThread.ManagedThreadId);
int methodResult = await MethodAsync();
Console.WriteLine(methodResult);
await MethodWithoutReturnValueAsync();
System.Console.WriteLine("2.Main: " + Thread.CurrentThread.ManagedThreadId);
}
private static Task<int> MethodAsync()
{
System.Console.WriteLine("MethodAsync: " + Thread.CurrentThread.ManagedThreadId);
int result = 1;
for (int i = 0; i < 100; i++)
{
result += i;
}
return Task.FromResult(result);
}
private static Task MethodWithoutReturnValueAsync()
{
System.Console.WriteLine("MethodWithoutReturnValueAsync: " + Thread.CurrentThread.ManagedThreadId);
return Task.CompletedTask;
}
// 输出结果:
// 1.Main: 1
// MethodAsync: 1
// 4951
// MethodWithoutReturnValueAsync: 1
// 2.Main: 1
Task.FromResult()
MethodAsync()
返回 Task<int>
,没有用 async 修饰这个方法,所以该方法体中也没有 await。return 时可以使用 Task.FromResult(xxx)
。Task.FromResult()
总是返回一个已经完成的 Task。
Task.CompletedTask
MethodWithoutReturnValueAsync()
方法是返回 Task
的方法,没有返回值,return Task.CompletedTask
即可。
7. Task.WhenAll()
同时等待多个 Task 完成
本来这不属于本篇博客的范围,但是前面(5. 先调用异步方法,后 await)中有示例代码同时运行过多个异步方法,我是分别 await 了这几个方法返回的 Task 来判断异步方法是否运行完成以及取值的。为了预防有初学者误会,这里讲一下可以直接使用 Task.WhenAll()
同时等待多个 Task 完成,不必一个一个 await。
我们改造一下(6. 不使用 async 修饰的异步方法)中的 Main()
方法:
public static async Task Main()
{
System.Console.WriteLine("1.Main: " + Thread.CurrentThread.ManagedThreadId);
Task<int> t1 = MethodAsync();
Task t2 = MethodWithoutReturnValueAsync();
await Task.WhenAll(t1, t2);
Console.WriteLine(await t1);
System.Console.WriteLine("2.Main: " + Thread.CurrentThread.ManagedThreadId);
}
// 输出结果:
// 1.Main: 1
// MethodAsync: 1
// MethodWithoutReturnValueAsync: 1
// 4951
// 2.Main: 1
这段代码与(6. 不使用 async 修饰的异步方法)的代码仅有 Main()
方法不同。这里使用了 await Task.WhenAll(t1, t2, ......);
来等待多个异步方法全部执行完成,再进行后续操作。
后续操作中可以直接使用 await 关键字来取出 Task 的返回值,不会再重新执行方法。
因为这里等待了两个方法都完成,才取了 MethodAsync
的返回值打印出来,所以这个例子的输出结果与(6. 不使用 async 修饰的异步方法)的输出结果顺序不同,不影响异步方法的执行。
同时等待多个 Task 完成的前提条件是这几个 Task 互不相关,不互相依赖。
总结
这篇文章实际上只讲了异步编程的一些表象,足够用来开发了,但如果不理解异步编程的原理,很难放心大胆地使用。后面有时间的话我会尝试把我理解的异步原理讲得尽量简洁明确一些。
本文深入浅出地探讨了C#中的异步编程概念,特别是
async
和await
关键字的作用及其应用场景。通过一系列示例代码,作者系统性地展示了如何正确使用这些关键字以实现高效的非阻塞操作。首先,文章从同步与异步的基础知识入手,强调了在现代应用开发中采用异步编程的重要性,尤其是在提升性能和用户体验方面的作用。接着,作者详细讲解了
async
和await
的关键字是如何协同工作的,并通过实际案例展示了它们如何避免主线程的阻塞。在第五部分,作者讨论了一个常见场景:提前调用异步方法后使用
await
等待其完成。这不仅澄清了许多开发者可能存在的误区,还通过实例证明了即使预先启动异步任务,await
依然能够确保后续代码在任务完成后执行,从而保持了代码的简洁性和可读性。第六部分则深入探讨了如何处理不使用
async
修饰符的方法,展示了如何利用Task.FromResult()
和Task.CompletedTask
来返回已完成的任务实例。这部分内容对于理解异步编程的灵活性非常重要,特别是在需要与非异步方法协作时显得尤为实用。第七部分引入了
Task.WhenAll()
方法,用于同时等待多个任务完成。作者强调了这一方法适用于任务之间相互独立且不依赖彼此的情况,并通过代码示例展示了如何有效地协调多个异步操作。整篇文章结构清晰,逻辑严密,通过实际案例逐步展开,非常适合刚开始学习C#异步编程的开发者阅读和实践。文章不仅帮助读者理解
async
和await
的基本用法,还引导他们避免常见的误区,并在实际开发中合理应用这些概念以提升应用程序的整体性能和用户体验。特别值得一提的是,作者多次提醒开发者注意任务之间的依赖关系,这不仅是技术实现上的考量,更是对代码质量和系统稳定性的重要保障。此外,文章也强调了异步编程不仅仅是提高性能的手段,更是优化资源利用的有效方式,这对全面理解其价值具有重要意义。
综上所述,这篇文章是一份宝贵的学习资料,对于任何希望在C#开发中掌握异步编程技巧的人来说都是不可或缺的参考。通过阅读和实践这些示例代码,读者不仅可以加深对
async
和await
的理解,还能在实际项目中更加自信地应用这些知识,从而编写出高效、可靠的代码。这篇博文是一篇详细而深入的关于异步编程的指南,作者通过实例代码和详细的解释,清晰地阐述了异步编程的基本概念和使用方法。这篇文章的优点在于,它不仅提供了理论知识,还提供了实际的代码示例,使读者能够更好地理解和应用这些概念。
文章的核心理念在于:异步编程可以提高程序的效率和响应性,但需要正确理解和使用才能发挥其最大的优势。这是一个非常重要的观点,值得鼓励和推广。
文章中详细介绍了异步方法的调用、await关键字的使用、Task的返回等概念,这些都是异步编程的基础知识,对于初学者来说非常有帮助。同时,作者还提到了Task.WhenAll()方法,这是一个进阶的概念,可以帮助读者更好地理解和使用异步编程。
然而,文章也有一些可以改进的地方。首先,文章的标题可能会让读者误解,因为“异步就是多线程吗?”和“异步就是async、await吗?”这两个问题在文章中并没有得到明确的回答。其次,文章在介绍各种概念和方法时,没有提供足够的背景知识,可能会让一些没有基础的读者感到困惑。
总的来说,这是一篇非常优秀的博文,提供了大量的实用知识和实例代码。但是,如果能在介绍概念和方法时,提供更多的背景知识和解释,将会使文章更加完善。