Nodejs高性能原理上-异步非阻塞事件驱动模型

6次阅读

共计 4337 个字符,预计需要花费 11 分钟才能阅读完成。

前言

终于开始我 nodejs 的博客生涯了, 先从基本的原理讲起. 以前写过一篇浏览器执行机制的文章, 和 nodejs 的相似之处还是挺多的, 不熟悉可以去看看先.
Javascript 执行机制 – 单线程,同异步任务,事件循环

写下来之后可能还是有点懞, 以后慢慢补充, 也欢迎指正, 特别是那篇翻译文章后面已经看不懂了. 有人出手科普一下就好了. 因为懒得动手做, 整篇文章的图片要么来源官网, 要么来源百度图片.
补充: 当前 Nodejs 版本 10.3.0

什么是 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 系统被激活调用返回, 线程发起 read 操作读取数据再执行其他操作

优点 : 同个线程能执行多个 I /O, 跨平台支持
缺点: 原理上还是属于阻塞, 单个 I / O 的处理时间甚至高过阻塞 I /O, 需要轮询并发量有限(1024);

poll

同 select 机制类似, 但并发量没有限制
优点 : 同个线程能执行多个 I /O, 并发量没有限制
缺点: 原理上还是属于阻塞, 单个 I / O 的处理时间甚至高过阻塞 I /O;

epoll

针对前两者的缺点进行改进, 降低内存开销
优点 : 同个线程能执行多个 I /O, 并发量没有限制
缺点: 并发量少的情况下效率可能不如前两者;

信号驱动 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 利用率;

事件驱动

事件是一种通过监听事件或状态的变化而执行回调函数的流程控制方法, 一般步骤

  1. 确定响应事件的元素;
  2. 为指定元素确定需要响应的事件类型;
  3. 为指定元素的指定事件编写相应的事件处理程序;
  4. 将事件处理程序绑定到指定元素的指定事件;

我们就以每个入门必学的创建服务器为例子

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 – 同步,异步,阻塞,非阻塞(亡羊补牢篇)

正文完
 0