Node.js 异步原理

写文章写得实在生无可恋,依旧看起了书——《深入浅出 Node.js》,觉得自己确实有错,之后如果有人问我 Node.js 入门怎么入门我可能还是会推荐他网上的教程,但是说到推荐书,这确实是一本很棒的书。

——安利完毕,之后进入正题。

在面试时可能真的会遇到这样的情况,面试官问:请问一下异步是怎么实现的。

之前在听小伙伴说到这个问题的时候一脸黑人问号,毕竟仿佛是一知半解,不得其意,今天总算可以好好聊一聊异步了,也算是一个章节的笔记。不过掌握的还不是很透彻,如果有错误请指出。

为什么会有异步

  1. 单线程同步模型系统资源利用率低下
  2. 多线程模型线程切换开销大,多线程编程中的同步问题让人头大

异步让单线程远离阻塞,同时规避了线程切换(恢复现场)的开销,让单一线程在执行 I/O 操作后立即进行其他操作。

异步 I/O 与非阻塞 I/O

尽管异步与非阻塞我们常常一起提及,异步也确实解决了非阻塞的问题,但是在计算机内核的角度,这并不是同一回事。

我们知道,阻塞 I/O 造成了 CPU 等待,浪费了系统资源,而非阻塞 I/O 会在执行完毕后立即返回,如果需要获得数据,需要再次读取。系统通过轮询来获取非阻塞 I/O 的结果,对 CPU 资源又存在浪费。

现在主要的轮询技术有:read / select / poll / epoll

其中 epoll 在进入轮询后没有检测到 I/O 事件将会休眠,直到事件发生,利用率较高,但仍然需要花费资源去等待与确认。

我们理想中的异步应该是可以在进行一次 I/O 之后不闲置 CPU,不去关心,直到完成之后执行回调。

现实中也确实有好几种异步方案,在 Node.js 和 Windows 下的 IOCP 中都使用线程池完成异步 I/O。Node.js 中利用 libuv 封装,来判断平台进行兼容。

based-libuv-1.png

事件循环

Node.js 中的异步 I/O 当然得提到大名鼎鼎的事件循环了,如果面试只面试到这里,相信每个人都能说出来:

事件循环就是执行一次循环(一个 Tick),就检测是否有事件未处理,如果有,就取出事件及相关回调函数,如果存在关联的回调函数,就执行它们,然后进入下一循环,如果没有,就退出进程。

事件循环就是一个典型的生产者消费者模型,由请求生产,事件循环消费,这个循环由 IOCP / 多线程创建。

首先我们引入请求对象的概念,JavaScript 层传入的参数和方法都被封装在请求对象中,当有可用线程时,我们就会调用对象底层对应的方法。

组装好请求对象,送入线程池等待执行,这就完成了我们的第一步。

执行结束后,将会将结果存储,并且调用方法通知 IOCP 将线程交还给线程池。

之后事件循环观察到执行完的请求,进行处理即可。

整个流程如图(摘自深入浅出 Node.js):

14964161584392.jpg

非 I/O 的异步 API

这部分介绍了一下 setTimeout, process.nextTick, setImmediate 的异同,可以从这里理解为什么 setTimeout 或者 setInterval 实现计划任务是个不靠谱的行为。

setTimeout 时创建的定时器会被插入到定时器观察者内部的一个红黑树中。每次 Tick 执行时,就会从该红黑树中迭代取出定时器对象,检测是否超时,如果超时就立即执行。

由此我们知道,如果两个定时 1ms 的任务,但是一个任务占用了 4 ms 的时间片,那么下一个任务就定然是不精准的。

行为图(摘自深入浅出 Node.js):

14964164304499.jpg

如果你需要立即执行一个任务,就应该使用 process.nextTick() 他可以规定和保证在下一个循环中执行。

此外,Node.js 还有一个 setImmediate() 方法,与 process.nextTick() 的区别是,process.nextTick 的回调函数保存在一个数组中,而 setImmediate 保存在链表中,在同一个 Tick 中会清空数组,但是只执行链表中的一项。此外,process.nextTick 在事件循环的检查中(idle 观察者)高于 setImmediate(check 观察者)。

植入部分

如果您觉得文章不错,可以通过赞助支持我。

如果您不希望打赏,也可以通过关闭广告屏蔽插件的形式帮助网站运作。

标签: 知识, node.js

已有 3 条评论

  1. Carter

    epoll 只试用于套接字(网络),然而文件读写也是IO操作。nodejs(libuv)在*nix上使用的是多线程同步读写的方式(https://github.com/libuv/libuv/blob/v1.x/src/unix/fs.c#L313),在macOS上还加了全局锁(https://github.com/libuv/libuv/blob/v1.x/src/unix/fs.c#L697),并不是特别高效。
    linux内核支持原生文件异步IO,叫做aio(http://lse.sourceforge.net/io/aio.html);BSD系(包括macOS)的kqueue支持文件IO;libuv都没有使用

    1. sf

      回carter:因为linux下的磁盘io和epoll没办法很好地配合呀,给他设置非阻塞是无效的,所以只能用同步加上线程池的方式。
      不仅是磁盘io,还有很多linux系统调用是阻塞的,如果不hook的话,多线程大概是最好的办法了。

  2. sf

    写得好详细,给你个♡︎。
    现在轮询技术好像没见过read(๑❛ᴗ❛๑)

  3. Carter

    闲来无事自己也写代码做了一些测试(mac):
    1、macOS上aio无法直接通知kqueue,sigev_notify_kqueue和SIGEV_KEVENT本来不存在。所以虽然macOS里有EVFILT_AIO的定义,并无卵用。
    2、macOS上虽然有sigev_notify_function和SIGEV_THREAD,但是会导致aio_read失败,提示Resource temporarily unavailable,开线程回调也不能用。
    3、所以唯一的选项是抛信号。但捕获到的信号回调函数中,siginfo->si_value.si_val始终为NULL。google后发现macOS不支持:https://stackoverflow.com/questions/5116151/how-to-get-user-data-for-aio-signal-handler-in-mac-os-x?rq=1。所以用户无法直接知道到底是哪个异步IO操作完成了。

    结论:至少在macOS里原生aio残废了90%,基本没法用。也无怪libuv自己开线程池模拟异步IO了
    google到了一些权威博客,基本都是在喷 *nix 的 aio:http://blog.libtorrent.org/2012/10/asynchronous-disk-io/

    参考资料:
    1、https://www.freebsd.org/cgi/man.cgi?query=kqueue
    2、https://www.freebsd.org/cgi/man.cgi?query=aio_read
    3、http://www.manpages.info/macosx/kqueue.2.html
    4、http://www.cnblogs.com/luminocean/p/5631336.html
    5、https://www.freebsdchina.org/forum/viewtopic.php?t=24193&sid=136d80acfd18bf42fd42fd21dbc53626

添加新评论