系列文章
Nodejs高性能原理(上) --- 异步非阻塞事件驱动模型
Nodejs高性能原理(下) --- 事件循环详解
前言
终于开始我nodejs的博客生涯了,先从基本的原理讲起.以前写过一篇浏览器执行机制的文章,和nodejs的相似之处还是挺多的,不熟悉可以去看看先.
Javascript执行机制--单线程,同异步任务,事件循环
写下来之后可能还是有点懞,以后慢慢补充,也欢迎指正,特别是那篇翻译文章后面已经看不懂了.有人出手科普一下就好了.因为懒得动手做,整篇文章的图片要么来源官网,要么来源百度图片.
补充: 当前Nodejs版本10.3.0
PS:
2019/8/13 修改部分描述内容
什么是nodejs?
用官网的说法就是:
Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。
Node.js 使用了一个事件驱动、非阻塞式 I/O 的模型,使其轻量又高效。
Node.js 的包管理器 npm,是全球最大的开源库生态系统。
一三我就跳过不讲了,那是外部条件因素,我们集中精力了解第二条.
什么是非阻塞式 I/O?
摘抄自<<深入浅出nodejs>>
操作系统对计算机进行了抽象,将所有输入输出设备抽象为文件.内核在进行文件I/O操作时,通过文件描述符
进行管理,而文件描述符类似于应用程序与系统内核之间的凭证.应用程序如果需要进行I/O调用,需要先打开文件描述符,然后再根据文件描述符去实现文件的数据读写.此处非阻塞I/O与阻塞I/O的区别在于阻塞I/O完成整个获取数据的过程,而非阻塞I/O则不带数据直接返回,要获取数据,还需要通过文件描述符再次读取.
I/O是指磁盘文件系统或者数据库的写入和读出,其中听到一些名词像异步,非阻塞,同步,阻塞
之间好像是同一回事,实际效果而言又好像真的就是同一回事,但是从计算机内核I/O来说真不是同一回事,为了更加全面讲解这个点,我们可以把它们都列出来,分别是:
阻塞I/O(Blocking I/O)
在发起I/O操作之后会一直阻塞著进程不执行其他操作,直到得到响应或者超时为止;
例子: 调用一个进行I/O操作的API请求时(如读写操作),一定要等待系统内核层面完成所有操作如磁盘寻道,读取数据,复制数据到内存等等;
优点: 基本不占用 CPU 资源, 能保证操作结束或者数据返回;
缺点: 单进程单请求,阻塞造成CPU无谓的等待没法充分应用;
非阻塞I/O(Non-blocking I/O):
发起I/O操作不等得到响应或者超时就立即返回让进程继续执行其他操作;
例子: 调用一个进行I/O操作的API请求时(如读写操作),不等待系统内核层面完成所有操作如磁盘寻道,读取数据,复制数据到内存等等就返回;
优点: 提高性能减少等待时间;
缺点: 返回的仅仅是当前调用状态,想要获取完整数据需要重复去请求判断操作是否完成造成CPU损耗,基本方法就是轮询;
I/O多路复用(I/O Multiplexing)
在并发量大的时候上面两种肯定都不适用,于是应运而生出多路复用,里面又分几种模式
select
将需要进行I/O操作的socket添加到select中进行监听,然后阻塞线程,等待操作完成或超时之后select系统被激活调用返回,线程再发起I/O操作
具体方式还是通过轮询检查所有的socket,因为单个进程支持的最大文件描述符是1024,所以实际并发量低于这个数
优点: 同个线程能执行多个I/O,跨平台支持
缺点: 原理上还是属于阻塞,单个I/O的处理时间甚至高过阻塞I/O,需要轮询并发量有限(1024);
poll
同select机制类似,但是poll基于链表实现,并发量没有限制
优点: 同个线程能执行多个I/O,并发量没有限制
缺点: 依然是遍历链表检查,效率低下;
epoll
针对前两者的缺点进行改进,通过callback回调通知机制.减少内存开销,不因并发量大而降低效率,linux下最高效率的I/O事件机制
优点: 同个线程能执行多个I/O,并发量远远超过1024且不影响性能
缺点: 并发量少的情况下效率可能不如前两者;
信号驱动I/O(Signal-driven I/O)
应用程序使用socket进行信号驱动 I/O,并安装一个信号处理函数,进程继续运行并不阻塞。
当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 I/O 操作函数处理数据。
优点: 执行之后不需要阻塞进程,当收到信号再执行操作提高资源利用
缺点: 并发量大的时候可能会因为信号队列溢出导致没法通知;
同步I/O(Synchronous I/O):
发起I/O操作之后会阻塞进程直到得到响应或者超时。按照这个定义,之前所述的阻塞I/O,非阻塞I/O,I/O多路复用, 信号驱动I/O
都属于同步I/O。
上面讲的不管是等待完成所有操作还是通过轮询等方式获取操作结果,其实都是会阻塞著进程,区别无非是中间等待时间怎么分配;
优点: 编写执行顺序一目了然;
缺点: 阻塞造成CPU无谓的等待或多余的查询,没法充分应用;
异步I/O(Asynchronous I/O):
直接返回继续执行下一条语句,当I/O操作完成或数据返回时,以事件的形式通知执行IO操作的进程.
注意: 异步I/O跟信号驱动I/O除了同异步阻塞非阻塞的区别外,前者是通知进程I/O操作什么时候完成,后者是通知进程什么时候可以发起I/O操作;
优点: 提高性能无需等待或查询,会有通知信息;
缺点: 代码阅读和流程控制较为复杂;
(这里原本想直接过,但是相似性太高容易模糊就打算画图,因为太多又懒得话想去百度找张图,然后找不齐,最终在一个文章找到一个更加清晰明了的示意图,很无耻又不失礼貌的借用了)
流程图来自于IO - 同步,异步,阻塞,非阻塞 (亡羊补牢篇)
简单总结:
阻塞I/O和非阻塞I/O区别在于:在I/O操作的完成或数据的返回前是等待还是返回!(可以理解成一直等还是分时间段等)
同步I/O和异步I/O区别在于 :在I/O操作的完成或数据的返回前会不会将进程阻塞(或者说是主动查询还是被动等待通知)!
用个生活化的例子就是等外卖吧
阻塞I/O: 白领A下完单就守着前台服务员直到收到外卖才离开,后面其他人在排队等他走开;
非阻塞I/O: 白领B下完单每隔一段时间就去询问前台服务员外卖好了没,需要来回走多次并且也要排队但是妨碍其他人的时间较少;
I/O多路复用: 白领A和B分别在两个前台服务员下单,厨房大叔先做好哪份外卖就交给对应的服务员;
信号驱动I/O: 白领C想要下单,前台服务员先问问厨房还有没有材料,得到回复之后再帮白领C下单;
异步I/O: 白领E下完单拿了号就去干其他事,直到前台服务员叫号告诉他外卖好了;
为什么Nodejs这么推崇非阻塞异步I/O?
用户体验
我们都知道Javascript在浏览器中是单线程执行,JS引擎线程和GUI渲染线程是互斥的,如果你用同步方式加载资源的时候UI停止渲染,也不能进行交互,你猜用户会干嘛?
而使用异步加载的话就没这问题了,这不仅仅是阻塞期间的体验问题,还是加载时间的问题.
例如有两段I/O代码执行分别需时a和b,一般:
同步执行需时: a+b
;
异步执行需时: Math.max(a,b)
;
这就是为什么异步非阻塞I/O是nodejs的主要理念,因为I/O代价非常昂贵.
资源分配
主流方法有两种:
单线程串行依次执行
优点: 编写执行顺序一目了然;
缺点: 无法充分利用多核CPU;
多线程并行处理
优点: 有效利用多核CPU;
缺点: 创建/切换线程开销大,还有锁,状态同步等繁杂问题;
Nodejs方案:单线程事件驱动、非阻塞式 I/O
优点: 免去锁,状态同步等繁杂问题,又能提高CPU利用率;
事件驱动
事件是一种通过监听事件或状态的变化而执行回调函数的流程控制方法,一般步骤
- 确定响应事件的元素;
- 为指定元素确定需要响应的事件类型;
- 为指定元素的指定事件编写相应的事件处理程序;
- 将事件处理程序绑定到指定元素的指定事件;
我们就以每个入门必学的创建服务器为例子
http
.createServer((req, res) => {
let data = '';
req.on('data', chunk => (data += chunk));
req.on('end', () => {
res.end(data);
});
})
.listen(8080);
所谓的事件驱动就是nodejs里有个事件队列,每个进来的请求处理完就被关闭然后继续服务下一个请求,当这个请求完成会被推进处理队列,然后通过一种循环方式检测队列事件有没变化,有就执行相对应的回调函数,没有就跳过到下一步,如此往复.
(看看我在runoob看到的图,一不小心又借用了.)
事件驱动非常高效可扩展性非常强,因为一直接受请求而不等待任何读写操作,更加详细内容下面会讲到.
nodejs的异步I/O实现
这块知识点是从<<深入浅出nodejs>>看到的.
四个共同构成Node异步I/O模型的基本要素:事件循环, 观察者, 请求对象, 执行回调
.
(因为涉及到底层语言和系统实现不同,我衹能根据内容简单说说过程,再多无能为力了)
事件循环
进程启动之后node就会创建一个循环,每执行一次循环体的过程称为Tick.每个Tick的过程就是看是否有事件待处理,有就取出事件及其相关回调执行,然后再重复Tick,否则退出进程.
(百度找到<<深入浅出nodejs>>书本里的示意图)
观察者
Node.js 基本上所有的事件机制都是用设计模式中观察者模式实现,每个事件循环中有一个或多个的观察者,通过询问这些观察者就能得知是否有事件需要进行处理.
浏览器中的事件可能来源于界面的交互或者文件加载而产生,而Node主要来源于网络请求,文件I/O等,这些产生的事件都有对应的观察者.
(window下基于IOCP创建,*nix基于多线程创建)
请求对象
对于Node中异步I/O调用,从发起调用到内核执行完I/O操作的过渡过程中存在一种中间产物请求对象.
在Javascript层面代码会调用C++核心模块,核心模块会调用内建模块通过libuv进行系统调用.创建一个请求对象并将入参和当前方法等所有状态都封装在请求对象,包括送入线程池等待执行以及I/O操作完毕之后的回调处理.然后被推入线程池等待执行,Javascript调用至此返回继续执行当前任务的后续操作,第一阶段完成.
(官方介绍: libuv is a multi-platform support library with a focus on asynchronous I/O. It was primarily developed for use by Node.js,相当关键的东西)
(百度找到<<深入浅出nodejs>>书本里的示意图)
执行回调
线程池中的I/O操作调用完成之后会保存结果然后向IOCP(还记得上面说window下基于IOCP创建么)提交执行状态告知当前对象操作完成并将线程归还线程池.中间还动用到事件循环的观察者,每次Tick都会调用IOCP相关的方法检查线程池是否有执行完的请求,有就将请求对象加入到I/O观察者的队列中当作事件处理.至此整个异步I/O流程结束.
完整流程如下
(百度找到<<深入浅出nodejs>>书本里的示意图)
参考资源
<<深入浅出nodejs>>
runoob
IO - 同步,异步,阻塞,非阻塞 (亡羊补牢篇)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。