在前端的javascript编程中,恐怕听到的最多的就是异步回调了。 由于浏览器中的javascript脚本是单线程的,使用传统的同步I/O会导致网页“卡死”, 又无法开多线程,因此只能选择异步的I/O接口了。 又由于通过异步I/O操作网络I/O的性能好于同步I/O,因此在互联网领域,异步编程就火了起来。
在这篇博文中,整理了一下个人对异步和同步的理解,并且结合底层的一些原理来分析它们的优缺点。
1. 什么是异步回调
在javascript中,异步回调是听到的最多的名词了。所谓的异步回调,就是提供类似以下使用方式的函数:
|
|
传统的I/O方式是同步阻塞的。 也即是,当调用I/O函数,进行类似文件读写或网络数据传输,从开始到I/O操作完成,这段时间内线程一直在等待I/O操作完成。 完成后,I/O函数才会返回。在调用I/O函数期间,线程就在这个调用处“卡”住了。
而异步回调则采用了另外一种方式。调用 fs.readFile
I/O函数进行文件读取,该函数在执行后仅仅是发出I/O请求后就会立即返回,不会等待I/O操作。 之后执行后面的代码,与此同时,操作系统正在进行刚才发出的I/O操作呢。当I/O操作完成后,则会调用之前设置的 callback
函数用以处理读取的数据。
在单线程的情况下,比如浏览器端运行的javascript,采用同步的I/O方式,无法同时处理多个I/O操作,只能等第一个操作完了再操作。 这时一种思路是开多线程来同时进行多个I/O操作。由于在I/O过程中线程会阻塞,浏览器页面无法处理其它的用户操作,给用户的感觉就是网页”卡死“了。 而异步回调就完全不会有这些问题。异步函数会立即返回,从而在单线程下,能够执行其它的代码逻辑。
2. 异步回调的缺点
异步回调的优势之一是能够做到在单线程下做到同时进行多个I/O操作,同步调用的方式想要做到同样的事情就只能通过多线程的方式去实现。
那么为什么传统的I/O方式都是同步调用? 这是因为同步调用的函数的优点是非常自然且符合直觉的,而异步回调写出的代码十分反人类。。。 比如说,以下程序:
|
|
逻辑非常显然,每间隔1s,输出数组中的一个元素。
如果使用异步回调的函数,只能写成这样:
|
|
javascript写出来的还算比较好看的。可以看到,为了使用异步回调的接口,不得不把循环的代码改成递归的代码。 把循环用递归表示虽然在函数式语言中算是比较常见的写法,然而并不是所有的语言都较好的支持函数式语言的特性。
为了使用异步回调的函数,需要把逻辑上连续的代码进行切割,用lambda函数包装起来传递到异步回调的函数中。 一旦代码逻辑变得复杂起来,这种风格会大大降低代码的可读性。
3. 异步的优点与底层实现
前面提到,异步回调能够做到在单线程中同时进行多次I/O 操作。实际上,使用异步的方式处理大量的I/O请求的性能是要好于同步I/O的。
为什么?为了解释这个问题。是需要了解下底层的工作原理。
3.1. 底层的工作方式
在底层,网络I/O操作,数据发送和数据接收等操作,是由网卡负责的。CPU和网卡是两个不同的硬件,它们完全可以同时工作。 以发送数据为例,当程序通过网络发送数据时,实际上的步骤是这样的:
- 程序调用相关的I/O函数去发送数据
- 相关的I/O函数最终会调用操作系统的系统调用,由操作系统完成网络I/O
- CPU给网卡发送请求,控制网卡发送数据
- CPU转而去执行其它的代码,与此同时网卡在发送数据
- 网卡发送数据完成后,给CPU发送 中断
3.2. 同步IO的原理
同步的I/O函数会在I/O请求发出后阻塞当前线程,当网卡发出中断后,再唤醒当前线程进行I/O处理。 假设一个使用同步I/O函数的网络服务器,每当有一个tcp请求到来时,就得要创建一个线程去处理它。 当请求非常多时,则需要创建 大量的线程 ,消耗大量的系统资源。
3.3. 异步IO的原理
仔细观察可以发现,在硬件层面,CPU和网卡之间的本身就是 异步 。 那么上层对这些操作进行封装,理论上,完全可以使得,调用异步I/O函数最终会让CPU给网卡发送请求。 而当网卡发出中断时,执行中断处理程序时,有两种思路:
- 在中断处理程序调用在异步I/O函数中传入的回调函数
- 中断处理程序设置一个标志标记完成。之后由程序主线程的事件循环轮询这些标志,发现完成就调用对应的回调函数
其实第一种思路根本不可行,中断处理程序是在内核态执行,而回调函数是需要程序主线程在用户态执行的。
实际上linux系统下,是可以通过epoll系统调用,来同时对多个I/O操作进行监听。 之后调用 epoll_wait
函数,如果没有I/O操作完成,当前线程会被阻塞。当有I/O操作完成后,该函数会返回,从而程序可以处理完成的I/O。 通过循环调用 epoll_wait
函数,只要有I/O操作完成,程序就能处理它。
epoll的好处比上面轮询的好处在于,如果目前没有任何I/O操作完成,轮询仍然会占用CPU资源。 而epoll在没有I/O操作完成时,会阻塞线程,从而不会占用CPU资源。
这样一来,使用异步I/O函数的网络服务器,则完全可以在主线程内监听端口,当新的tcp请求到来时,通过异步I/O函数发送接收数据的请求, 然后转而继续去监听。使用单线程即可完成任务。
总的来说,由于异步的I/O函数充分利用了底层的硬件机制也是异步的这一特点,避免了软件层次需要创建大量线程消耗操作系统资源,从而大大提高了性能。
4. 最后
可以看到,相比同步IO,异步IO方式拥有很多的优势,但是不足之处在于异步回调方式大大降低代码可读性和提高编程难度。
值得高兴的是,已经有一种叫做 协程 的东西,即能够保留异步IO的方式,又能使用同步IO的思路编写代码。
异步回调和协程实际上都是异步的方式,它们的不同只是代码层次的。之后我准备写一篇关于协程和异步回调博文。