前言
此文承接上文 什么是 nodejs 模块机制,继续讲述自己阅读了深入浅出 nodejs 的相关章节后的理解,这次是第三章 异步 I/O。
为什么要 异步 I/O
这一篇的开头,从用户体验和资源方面分析了一下当前的编程模型的优劣
| 单线程 | 多线程 | |
|---|---|---|
| 用户体验 | 任务一个一个的执行 | 任务可同步进行 |
| 资源分配 | 会阻塞 I/O 导致资源浪费 | 死锁、状态同步问题非常麻烦 |
然后提出了异步 I/O 的方案,利用单线程,远离死锁、状态同步,利用异步 I/O 远离阻塞,更好利用资源。
我们可以看个异步 I/O 的示意图
看起来确实很棒,执行到 I/O 操作就开始异步调用,然后接着执行其余操作,异步完成了就返回结果,执行回调。
异步 I/O 实现现状
首先,我们说的同步或者异步,只是编程层面上的,对于操作系统内核来说,I/O 只有两种方式,阻塞与非阻塞。
阻塞 I/O 就是调用 I/O 时,需要等到操作都做完了,得到数据,然后才开始执行接下来的程序。
非阻塞,当然就是调用 I/O 时,先不返回数据,之后通过轮询反复确认是否完成,才得到数据。
(这里有个知识点,内核在进行文件 I/O 操作时,通过文件描述符进行管理,而文件描述符类似于应用程序和系统内核之间的凭证,应用程序如果需要进行 I/O 调用,需要先打开文件描述符,然后再根据文件描述符去实现文件的数据读写。)
非阻塞 I/O 有几种方式,大致分为两种类型,一种是真的轮询去检查状态,一种是 linux 下的 I/O 事件通知机制 epoll,进入轮询的时候如果没有检查到 I/O 事件,将会休眠,直至回调事件发生将它唤醒。
看起来好像非阻塞 I/O 确实比较满足我们异步的需求,其实不然,对于应用程序来说,它还是像同步,因为应用程序需要等待数据的返回,这期间不是在轮询查看文件描述符的状态就是在休眠。
理想中的异步 I/O,应该是应用程序发起非阻塞 I/O,不需要轮询也不需要休眠,而是接着执行下一个任务,等到 I/O 处理完后通过信号或回调返回数据给应用程序即可。
那么现实中当然可以实现类似的效果,只不过就可能要改变一些前面提到的观点,前面说过使用单线程,我们可以改用多线程。
我们可以把一个主线程用于执行,当遇到 I/O 操作时,我们可以用专门处理 I/O 操作的线程来处理,得到数据后执行回调把数据返回给主线程,这些线程都统一在线程池处理。
由于 windows 平台和 *nix 平台的差异,node 提供了 libuv 作为抽象封装层,在 *nix 平台中,node 有自己定义的线程池,在 windows 里,则由平台提供的 IOCP(可以理解为是 windows 自己创建的线程池,但是有提供操作的 api) 来实现。
node 的异步 I/O
前面介绍了系统对异步 I/O 的支持,现在来说说 node 异步 I/O 模型的实现,有四个基本要素。
事件循环
我觉得前端对这个应该是比较熟悉的,相当于是个无限的循环,每执行一次循环体的过程叫 tick,每一次 tick 就是查看是否有相应的事件要处理,有就取出事件以及相关的回调函数执行,然后进入下个循环,如果都没有事件了就退出。
观察者
浏览器就有类似的机制,例如 click,你给某个元素绑定了 click 事件,当点击到这个元素时,浏览器就会调用你绑定的回调函数。
那么 node 里,事件主要来源于网络请求、文件 I/O,对应的就有网络 I/O 观察者,文件 I/O 观察者。
事件循环是一个生产者/消费者模型,文件 I/O、网络请求等则是事件的生产者,源源不断的为 node 提供事件,这些事件被传到对应的观察者那里,事件循环则从观察者那取出事件并处理。
请求对象
以一个操作文件的 api 为例 fs.open()
- js 调用 node 核心模块,核心模块调用
C/C++内建模块,内建模块通过 libuv 进行系统调用。 - 那么在这个过程中,会创建一个请求对象,里面会带有回调函数以及传入的参数等等数据。
- 然后把这个请求对象送到 I/O 线程池里,接着 js 的主线程就继续执行下面的逻辑了。
- 线程池中的 I/O 操作完成后,把结果放在请求对象里,然后把请求对象放到相应的观察者那,比如当前例子就是放到文件 I/O 观察者里,然后等到事件循环时,从观察者里取出请求对象,当成事件来处理,至此结束。
所以我们知道,请求对象里面包含了回调函数、传入的参数等等数据,同时 I/O 操作的结果也会放到请求对象里。
I/O 线程池
线程池以及相关的 I/O 操作,在 *nix 平台下时,node 是自定义的线程池加 epoll,windows 平台则都是 IOCP 提供。
非 I/O 的异步 API
定时器
原理与异步 I/O 相似,只是不需要 I/O 线程池,同样也是有对应的观察者:定时器观察者。
事件循环时会去检查观察者里面的定时器是否超过时间,超过了就会形成一个事件,按顺序执行相关回调函数。
process.nextTick
定时器有使用到红黑树,所以 process.nextTick 更高效更轻量。
setImmediate
跟 process.nextTick 类似,但是没有 process.nextTick 快。
这里在书中是这么写的
process.nextTick()属于 idle 观察者, setImmediate()属于 check 观察者。在每个轮循环检查中,idle 观察者先于 I/O 观察者,I/O 观察者先于check观察者。 所以 process.nextTick 比 setImmediate 快。
按我的理解这个应该是属于老版 node 的内容,新版本里,process.nextTick 应该是属于微任务的,而 setImmediate 应该是属于 check 观察者的宏任务的,所以 process.nextTick 比 setImmediate 快。
事件驱动与高性能服务器
事件驱动的本质就是通过主循环加事件触发来运行程序。
服务器这块了解的不多,以后有深入的认识再补充。
总结
学完这章,我们可以清楚的知道,js 是单线程的,但是 node 可不是单线程的,为了实现异步 I/O 还是需要用到 I/O 线程池的。
事件循环是异步的核心,在这一点上 node 与浏览器的执行模型基本保持一致。
好的再见~