引言
现在异步编程真的是越来越普遍了,从前端的Promise到后端的Channel、Future、Task,异步编程正变得越来越流行。很多同学也玩得很溜了,满世界的异步调用,让程序的效率和用户体验都大大提升。不过,当谈到为什么要使用异步编程,以及它背后的工作原理时,大部分同学就哑火了。对于一个有追求的程序员来说,我们不仅要会用,更要理解其中的原理,所谓"知其所以然"。
而且异步编程并不是银弹,本质上它不会让程序运行的更快,使用它也伴随着复杂的错误处理和调试难题,比如著名的"回调地狱"。因此,了解它的工作原理,以及正确地使用它,对于编写高质量的代码来说特别重要。
本文,我们就来一起探讨下同步和异步调用的本质区别,深入解析异步编程的工作原理,以及介绍如何在实际开发中灵活运用这两种调用方式。
概念
要讨论问题,首先得明确概念,也就是我们到底在说什么。
同步调用,简单来说,就是执行多个任务的时候,其中一个任务必须完成后,才能开始下一个任务。在这种模式下,任务按照顺序依次执行,每个任务的执行必须等待前一个任务完成,所以大家也称之为阻塞调用。
在编程中,同步调用的一个典型应用场景是数据库事务。比如,在事务中更新一系列的记录时,系统会按照顺序执行这些操作,直到全部完成,期间不会去处理其他任务。这确保了数据的一致性和完整性,但也意味着在事务处理期间,其他依赖于这些数据的操作必须等待。
异步调用,顾名思义,是一种任务可以在后台执行,而不阻塞当前线程继续执行其他任务的调用方式,这可以使多个任务得以并行处理。
在编程中,异步调用的一个典型应用场景是网络请求。比如,前端向服务器请求数据时,我们可以不需要让整个应用停下来等待服务器的响应。通过异步调用,前端可以在等待服务器响应的同时,继续执行其他任务,比如响应用户的输入,这会提高用户体验。
简单来说,同步调用就像是在排队取餐,不能走开,而异步调用则像是扫码点餐,可以去做其他事情,等饭好了给你送过来。
异步的优势所在
更快
这里先抛出一个问题:异步会不会让程序运行的更快?
我们以经典的网络请求场景为例,当客户端使用异步的方式发起一次请求后,程序霸占的当前线程就被底层系统分配去干别的事情去了,然后请求会在网络上传递极短的一些时间,到达服务端后再进行一段时间的处理,最后再通过网络将处理结果返回给客户端底层系统,底层系统再唤起之前的任务继续处理。
在这个过程中,网络来回传输的时间、服务端处理的时间都没有受到异步调用的任何影响,反而可能会因为异步调用产生任务切换而增加网络请求的响应时间。所以单次的异步调用并没有让程序运行的更快。
但是但是,异步调用还是可能会让程序整体运行的更快。还是以网络请求场景为例,假设我们需要在页面上发起3个网络请求,每个网络请求的响应时间都是基本相同的,同步的情况下我们只能一个一个的干,总的响应时间就是单次网络请求响应时间的3倍,如果换成异步调用,理想情况下,这三个网络请求可以在服务端并行处理,而网络传输的时间是极短的,那么总的响应时间可能就是一个比单次网络请求响应时间略高一点的数字。所以异步调用相比同步调用,很有可能会让程序整体运行的更快。
谈到更快时,我们这里一直比较的就是时间,如果网络传输的时间、服务端处理的时间都很短,短到就像本地的一次函数调用,那么异步也不会让程序更快。所以根本的问题是网络传输的时间太慢、服务端处理的时间太慢,它们相比CPU的处理速度要慢上很多个数量级,所以这才让异步有了可乘之机,而异步就是在这些网络IO、磁盘IO等慢速设备的通信上发挥主要作用。
更多
我们以一个服务端网络处理程序为例,当请求到达服务端时,程序会给这个请求分配一个线程,用来运行相关的服务端处理程序,假设这个处理中还要调用别的API,同步调用和异步调用就会出现不同的行为了。
同步调用时,线程会一直等在这里,等待的时候谁也不能抢走这个线程,直到这次内部调用返回结果,然后继续处理,直到全部完成,最后返回给调用方。
异步调用时,调用发起后,线程就被底层系统分配给别的任务了,比如用来接收新的网络请求,等这次内部调用的结果返回后,底层系统再为本次任务分配线程资源,然后继续处理,直到全部完成,最后返回给调用方。
我们可以看到,在使用异步调用的情况下,线程的利用率提高了,而这会节省大量的服务器资源。比如,在Linux系统中,一个线程会占用8M的内存资源,那么同步调用时,8G的内存也就能同时接入大概1000个请求,改为异步调用后,8G的内存能同时接入多少请求呢?这里做一个不是很严谨的计算,假设1个请求的完整处理时间为100毫秒,请求接入到发起异步调用的时间为1毫秒,那么使用异步调用后,8G内存就能在这100毫秒内接收100倍的请求,也就是10万个请求。
这也是Go语言、Node.js等可以轻松驾驭高并发的核心法门。
更省
有一种说法是异步调用后,CPU就去干别的了,不用等着网络请求返回,所以节省了CPU资源。其实现代操作系统一般没有这么傻,它有一套比较科学的CPU调度算法,CPU并不会傻傻的等着网络请求返回,除非我们使用特殊的方法霸占着CPU不放。这种说法可能只在古老的操作系统或者一些特殊的嵌入式系统中存在。
异步节省内存资源是实实在在的,同样的网络请求数量下,需要的线程更少了,占用的内存也就更少了。
更好的用户体验
我们可以以一个现代Web应用的实例来说明。当用户在一个复杂的Web应用中进行操作时,比如提交一个表单,这个表单的数据需要通过网络发送到服务器。在这个过程中,我们不希望用户界面冻结或变得无响应。通过使用异步调用发送数据,用户界面可以继续响应其他用户操作,比如滚动页面、点击其他按钮等。服务器的响应会在数据处理完成后返回,这时应用会相应地更新用户界面,而用户可能都没有注意到这个后台的数据交换过程。
异步的实现原理
接下来,我们深入探讨一下异步是怎么做到上边这一切的,特别是事件循环、回调函数,以及Promises和Async/Await这些概念。以Node.js为例,可以先看看这张图,下边会有详细介绍。
事件循环
在一家餐厅里,有一个厨师(CPU)和一个服务员(事件循环)。当顾客(任务)下单(发起异步调用)后,服务员记录下订单,然后继续服务其他顾客。厨师在后厨准备好食物后,服务员再将食物递给对应的顾客。这个过程中,服务员不断的在顾客和厨师之间循环,确保每个顾客的需求都得到满足,这就是事件循环的机制。
在不同的操作系统和语言框架中,事件循环的具体实现可能有所不同,但核心思想是一致的:使得单线程环境下,可以高效地处理多个异步任务,而不会造成阻塞。
Node.js
Node.js是一个基于Chrome V8引擎的JavaScript运行环境,它使用事件驱动、非阻塞IO模型,非常适合处理大量的并发连接。Node.js的事件循环由libuv库实现,这个库专门为了提高Node.js的异步IO性能而设计。
在Node.js中,事件循环负责执行用户代码、收集和处理事件,以及执行队列中的子任务。
.NET
在.NET框架中,异步编程模型(Asynchronous Programming Model, APM)和基于任务的异步模式(Task-based Asynchronous Pattern, TAP)都是.NET中处理异步操作的方式。.NET中的事件循环不像Node.js那样明显,因为.NET应用通常运行在多线程环境下,通过线程池(Thread Pool)来处理异步任务。
在.NET中,异步操作通常通过Task来表示,搭配使用async和await关键字让异步代码的编写和阅读更加直观。.NET运行时会负责调度这些Task到线程池中的线程上执行,从而实现非阻塞的异步操作。
操作系统
语言框架的异步处理都是基于操作系统的底层支持。
在操作系统层面,Linux和Windows提供了不同的机制来实现高效的IO事件处理。
- Linux上的epoll是一种高效的IO事件通知机制,它允许应用程序监视多个文件描述符,以了解是否有IO操作可执行。epoll相比于传统的select或poll,在处理大量并发连接时可以显著减少资源消耗和提高性能。
- Windows上的IO完成端口(IOCP)是一个高效的线程池技术,用于处理大量的并发IO操作。IOCP能够将IO操作的完成通知直接与线程池结合起来,当IO操作完成时,相应的处理线程会被唤醒来处理结果。
语言框架为了实现异步操作,在不同的操作系统上会选择相应的异步IO处理方式。
回调函数
回调函数就像是你对服务员说:"当我的汉堡准备好了,请通知我。"服务员(事件循环)记下了这个请求,当厨师(CPU)做好汉堡后,服务员会回来通知你。这个过程就是回调机制。
然而,如果你的要求变得复杂,比如:"我的汉堡准备好后,请通知我,然后我会要求加薯条,薯条准备好后,请再通知我,我可能还会有其他要求......"这样的多层次回调会导致所谓的"回调地狱",使得代码难以阅读和维护。
javascript
function prepareBurger(callback) {
console.log("开始准备汉堡...");
setTimeout(() => {
console.log("汉堡准备好了!");
callback("汉堡");
}, 2000); // 假设准备汉堡需要2秒钟
}
function prepareFries(callback) {
console.log("开始准备薯条...");
setTimeout(() => {
console.log("薯条准备好了!");
callback("薯条");
}, 1500); // 假设准备薯条需要1.5秒钟
}
// 请求汉堡,然后请求薯条
prepareBurger(function(burger) {
console.log("你的" + burger + "已经准备好了。");
// 汉堡准备好后,请求薯条
prepareFries(function(fries) {
console.log("你的" + fries + "也准备好了。");
// 如果这里还有更多的异步请求,代码会继续嵌套下去...
});
});
Promises和Async/Await
为了解决"回调地狱"的问题,现代编程语言引入了Promises和Async/Await,以Javascript为例:
Promises 就像是你给服务员下了一个订单,并得到了一个"承诺"。服务员说:"我保证会告诉你何时你的汉堡准备好。"这样,你就不需要在柜台前等待,而是可以去做其他事情,服务员会在承诺的时间里来通知你。
javascript
function prepareBurger() {
// 返回一个Promise对象
return new Promise((resolve, reject) => {
console.log("开始准备汉堡...");
setTimeout(() => {
// 模拟汉堡准备过程
console.log("汉堡准备好了!");
resolve("汉堡"); // 成功完成时调用resolve
}, 2000); // 假设准备汉堡需要2秒钟
});
}
// 调用prepareBurger,并处理结果
prepareBurger().then(burger => {
console.log("你的" + burger + "已经准备好了。");
}).catch(error => {
console.log("出错了:" + error);
});
Promise的写法看起来还是有点怪异,Async/Await 则是在Promises的基础上,让异步代码看起来更像同步代码。使用async/await时,你可以用同步的方式写异步代码,这让代码更加直观易懂。比如,你对服务员说:"我会在这里等,你准备好汉堡后直接给我。"尽管实际上汉堡的准备是异步的,但对你来说,就像是同步等待结果一样。
javascript
async function getOrder() {
try {
// 等待prepareBurger完成,并获取结果
const burger = await prepareBurger();
console.log("你的" + burger + "已经准备好了。");
} catch (error) {
// 处理可能发生的错误
console.log("出错了:" + error);
}
}
// 调用getOrder
getOrder();
async/await 其实还利用了协程的一些处理方式,协程不是操作系统提供的,而是由编程语言框架在用户程序中实现的,在异步编程中,它就是用来在IO操作发起后,将线程分给其它的任务,在IO操作完成后再给任务分配线程。具体到JavaScript中,是通过Generator生成器实现的,它可以控制函数的暂停和恢复,async/await只是做了一个包装,实际执行时,运行引擎会转换处理。
在 .NET 平台中,同样支持使用 async/await 的方式编写异步代码,只不过 Promise 变成了 Task。
总结
最后,让我们总结一下同步调用和异步调用的区别,以及它们对软件开发的影响。
首先,同步调用就像是在餐厅里排队取餐,你得等服务员把饭端上来后才能干别的事情;而异步调用则像是扫码点餐,餐点制作的时候,你可以去做任何其他事情。简而言之,同步调用会阻塞当前操作直到任务完成,而异步调用不会,它允许程序在等待过程中继续执行其他任务。
对软件开发来说,这两种调用方式的本质区别影响深远。同步调用因为简单直接,适合那些必须顺序执行、步步为营的任务,特别是计算密集型的任务,异步了也没有可以节省的地方;但是,在处理IO操作等耗时任务时,同步调用可能会导致程序"卡住",既霸占大量的资源,又影响用户体验,此时选择异步调用则能更有效的利用计算资源,且显著提高程序的响应性和性能,尤其是在需要大量IO操作的场景下,比如网络服务器、大型数据库操作等。
如有问题,欢迎留言讨论!