守望者--AIR技术交流

 找回密码
 立即注册

QQ登录

只需一步,快速开始

扫一扫,访问微社区

搜索
热搜: ANE FlasCC 炼金术
查看: 1236|回复: 0

[Node.JS] Node.js 探秘:初识单线程的 Node.js

[复制链接]
  • TA的每日心情
    擦汗
    2018-4-10 15:18
  • 签到天数: 447 天

    [LV.9]以坛为家II

    1742

    主题

    2094

    帖子

    13万

    积分

    超级版主

    Rank: 18Rank: 18Rank: 18Rank: 18Rank: 18

    威望
    562
    贡献
    29
    金币
    52620
    钢镚
    1422

    开源英雄守望者

    发表于 2016-8-17 17:13:54 | 显示全部楼层 |阅读模式

    来源:http://bbs.html5cn.org/thread-149793-1-5.html

    前言
    从Node.js进入人们的视野时,我们所知道的它就由这些关键字组成 事件驱动、非阻塞I/O、高效、轻量,它在官网中也是这么描述自己的。
    Node.js® is a JavaScript runtime built on Chrome’s V8 JavaScript engine. Node.js uses an event-driven, non-blocking I/O model that makes itlightweight and efficient.
    于是,会有下面的场景出现:
    当我们刚开始接触它时,可能会好奇:
    • 为什么在浏览器中运行的 Javascript 能与操作系统进行如此底层的交互?
    当我们在用它进行文件 I/O 和网络 I/O 的时候,发现方法都需要传入回调,是异步的:
    • 那么这种异步,非阻塞的 I/O 是如何实现的?
    当我们习惯了用回调来处理 I/O,发现当需要顺序处理时,Callback Hell 出现了,于是有想到了同步的方法:
    • 那么在异步为主的 Node.js,有同步的方法嘛?
    身为一个前端,你在使用时,发现它的异步处理是基于事件的,跟前端很相似:
    • 那么它如何实现的这种事件驱动的处理方式呢?
    当我们慢慢写的多了,处理了大量 I/O 请求的时候,你会想:
    • Node.js 异步非阻塞的 I/O 就不会有瓶颈出现吗?
    之后你还会想:
    • Node.js 这么厉害,难道没有它不适合的事情吗?
    等等。。。
    看到这些问题,是否有点头大,别急,带着这些问题我们来慢慢看这篇文章。
    Node.js 结构
    上面的问题,都挺底层的,所以我们从 Node.js 本身入手,先来看看 Node.js 的结构。
    我们可以看到,Node.js 的结构大致分为三个层次:
    • Node.js 标准库,这部分是由 Javascript 编写的,即我们使用过程中直接能调用的 API。在源码中的 lib 目录下可以看到。
    • Node bindings,这一层是 Javascript 与底层 C/C++ 能够沟通的关键,前者通过 bindings 调用后者,相互交换数据。实现在 node.cc
    • 这一层是支撑 Node.js 运行的关键,由 C/C++ 实现。
      • V8:Google 推出的 Javascript VM,也是 Node.js 为什么使用的是 Javascript 的关键,它为 Javascript 提供了在非浏览器端运行的环境,它的高效是 Node.js 之所以高效的原因之一。
      • Libuv:它为 Node.js 提供了跨平台,线程池,事件池,异步 I/O 等能力,是 Node.js 如此强大的关键。
      • C-ares:提供了异步处理 DNS 相关的能力。
      • http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、数据压缩等其他的能力。

    Libuv
    Libuv 是 Node.js 关键的一个组成部分,它为上层的 Node.js 提供了统一的 API 调用,使其不用考虑平台差距,隐藏了底层实现。
    具体它能做什么,官网的这张图体现的很好:
    可以看出,它是一个对开发者友好的工具集,包含定时器,非阻塞的网络 I/O,异步文件系统访问,子进程等功能。它封装了 Libev、Libeio 以及 IOCP,保证了跨平台的通用性。
    我们只要先知道它本身是异步和事件驱动的,记住这点,下面的问题就有了答案,我们一一来看。
    与操作系统交互
    举个简单的例子,我们想要打开一个文件,并进行一些操作,可以写下面这样一段代码:
    var fs = require('fs');
    fs.open('./test.txt', "w", function(err, fd) {
            //..do something
    });
    这段代码的调用过程大致可描述为:lib/fs.js → src/node_file.cc → uv_fs
    Node.js 深入浅出上的一幅图:
    具体来说,当我们调用 fs.open 时,Node.js 通过 process.binding 调用 C/C++ 层面的Open 函数,然后通过它调用 Libuv 中的具体方法 uv_fs_open,最后执行的结果通过回调的方式传回,完成流程。在图中,可以看到平台判断的流程,需要说明的是,这一步是在编译的时候已经决定好的,并不是在运行时中。
    总体来说,我们在 Javascript 中调用的方法,最终都会通过 process.binding 传递到 C/C++ 层面,最终由他们来执行真正的操作。Node.js 即这样与操作系统进行互动。
    通过这个过程,我们可以发现,实际上,Node.js 虽然说是用的 Javascript,但只是在开发时使用 Javascript 的语法来编写程序。真正的执行过程还是由 V8 将 Javascript 解释,然后由 C/C++ 来执行真正的系统调用,所以并不需要过分担心 Javascript 执行效率的问题。可以看出,Node.js 并不是一门语言,而是一个 平台,这点一定要分清楚。
    异步、非阻塞 I/O
    通过上文,我们了解到,真正执行系统调用的其实是 Libuv。之前我们提到,Libuv 本身就是异步和事件驱动的,所以,当我们将 I/O 操作的请求传达给 Libuv 之后,Libuv 开启线程来执行这次 I/O 调用,并在执行完成后,传回给 Javascript 进行后续处理。
    这里面的 I/O 包括文件 I/O 和 网络 I/O。两者的底层执行略有不同。从上面的 Libuv 官网的图中,我们可以看到,文件 I/O,DNS 等操作,都是依托线程池(Thread Pool)来实现的。而网络 I/O 这一大类,包括:TCP、UDP、TTY 等,是由 epoll、IOCP、kqueue 来具体实现的。
    总结来说,一个异步 I/O 的大致流程如下:
    • 发起 I/O 调用
      • 用户通过 Javascript 代码调用 Node 核心模块,将参数和回调函数传入到核心模块;
      • Node 核心模块会将传入的参数和回调函数封装成一个请求对象;
      • 将这个请求对象推入到 I/O 线程池等待执行;
      • Javascript 发起的异步调用结束,Javascript 线程继续执行后续操作。
    • 执行回调
      • I/O 操作完成后,会将结果储存到请求对象的 result 属性上,并发出操作完成的通知;
      • 每次事件循环时会检查是否有完成的 I/O 操作,如果有就将请求对象加入到 I/O 观察者队列中,之后当做事件处理;
      • 处理 I/O 观察者事件时,会取出之前封装在请求对象中的回调函数,执行这个回调函数,并将 result 当参数,以完成 Javascript 回调的目的。

    这里面涉及到了 Libuv 本身的一个设计理念,事件循环(Event Loop),它是一个类似于while true 的无限循环,其核心函数是 uv_run,下文会用到。
    从这里,我们可以看到,我们其实对 Node.js 的单线程一直有个误会。事实上,它的单线程指的是自身 Javascript 运行环境的单线程,Node.js 并没有给 Javascript 执行时创建新线程的能力,最终的实际操作,还是通过 Libuv 以及它的事件循环来执行的。这也就是为什么 Javascript 一个单线程的语言,能在 Node.js 里面实现异步操作的原因,两者并不冲突。
    事件驱动
    说到,事件驱动,对于前端来说,并不陌生。事件,是一个在 GUI 开发时很常用的一个概念,常见的有鼠标事件,键盘事件等等。在异步的多种实现中,事件是一种比较容易理解和实现的方式。
    说到事件,一定会想到回调,当我们写了一大堆事件处理函数后,Libuv 如何来执行这些回调呢?这就提到了我们之前说到的 uv_run,先看一张它的执行流程图:
    在 uv_run 函数中,会维护一系列的监视器:
    typedef struct uv_loop_s uv_loop_t;
    typedef struct uv_err_s uv_err_t;
    typedef struct uv_handle_s uv_handle_t;
    typedef struct uv_stream_s uv_stream_t;
    typedef struct uv_tcp_s uv_tcp_t;
    typedef struct uv_udp_s uv_udp_t;
    typedef struct uv_pipe_s uv_pipe_t;
    typedef struct uv_tty_s uv_tty_t;
    typedef struct uv_poll_s uv_poll_t;
    typedef struct uv_timer_s uv_timer_t;
    typedef struct uv_prepare_s uv_prepare_t;
    typedef struct uv_check_s uv_check_t;
    typedef struct uv_idle_s uv_idle_t;
    typedef struct uv_async_s uv_async_t;
    typedef struct uv_process_s uv_process_t;
    typedef struct uv_fs_event_s uv_fs_event_t;
    typedef struct uv_fs_poll_s uv_fs_poll_t;
    typedef struct uv_signal_s uv_signal_t;
    这些监视器都有对应着一种异步操作,它们通过 uv_TYPE_start,来注册事件监听以及相应的回调。
    在 uv_run 执行过程中,它会不断的检查这些队列中是或有 pending 状态的事件,有则触发,而且它在这里只会执行一个回调,避免在多个回调调用时发生竞争关系,因为 Javascript 是单线程的,无法处理这种情况。
    上面的图中,对 I/O 操作的事件驱动,表达的比较清楚。除了我们常提到的 I/O 操作,图中还表述了一种情况,timer(定时器)。它与其他两者不同之处在于,它没有单独开立新的线程,而是在事件循环中直接完成的。
    事件循环除了维护那些观察者队列,还维护了一个 time 字段,在初始化时会被赋值为0,每次循环都会更新这个值。所有与时间相关的操作,都会和这个值进行比较,来决定是否执行。
    在图中,与 timer 相关的过程如下:
    • 更新当前循环的 time 字段,即当前循环下的“现在”;
    • 检查循环中是否还有需要处理的任务(handlers/requests),如果没有就不必循环了,即是否 alive。
    • 检查注册过的 timer,如果某一个 timer 中指定的时间落后于当前时间了,说明该 timer 已到期,于是执行其对应的回调函数;
    • 执行一次 I/O polling(即阻塞住线程,等待 I/O 事件发生),如果在下一个 timer 到期时还没有任何 I/O 完成,则停止等待,执行下一个 timer 的回调。如果发生了 I/O 事件,则执行对应的回调;由于执行回调的时间里可能又有 timer 到期了,这里要再次检查 timer 并执行回调。
    Node.js 会一直调用 uv_run 直到到循环不在 alive。
    同步方法
    虽然 Node.js 是以异步为主要模式的,但我们在实际开发中,难免会有一些情况是有时序性的,如果由异步来写,就会写出很丑的 Callback Hell,如下:
    db.query('select nickname from users where id="12"', function() {
            db.query('select * from xxx where id="12"', function() {
                    db.query('select * from xxx where id="12"', function() {
                            db.query('select * from xxx where id="12"', function() {
                                    //...        
                            });
                    });
            });
    });
    这个时候如果有同步方法,就会方便很多。这一点,Node.js 的开发者也想到了,目前大部分的异步操作函数,都存在其对应的同步版本,只需要在其名称后面加上 Sync 即可,不用传入回调。
    var file = fs.readFileSync('/test.txt', {"encoding": "utf-8});
    这写方法还是比较好用的,执行 shell 命令,读取文件等都比较方便。不过,体验不太好的一点就是这种调用的错误收集,它不会像回调函数那样,在第一参数中传入错误信息,它会将错误直接抛出,你需要使用 try...catch 来获取,如下:
    var data;
    try {
      data = fs.readFileSync('/test.txt');
    } catch (e) {
            if (e.code == 'ENOENT') {
                    //...
            }
            //...
    }
    至于这些方法如何实现的,我们下回再论。
    一些可能的瓶颈
    这里只见到讨论下自己的理解,欢迎指正。
    首先,文件的 I/O 方面,用户代码的运行,事件循环的通知等,是通过 Libuv 维护的线程池来进行操作的,它会运行全部的文件系统操作。既然这样,我们抛开硬盘的影响,对于严谨的 C/C++ 来说,这个线程池一定是有大小限制的。官方默认给出的大小是 4。当然是可以改变的。在启动时,通过设置 UV_THREADPOOL_SIZE 来改变这个值即可。不过,最大也只能是 128,因为这个是涉及到内存占用的。
    这个线程池对于所有的事件循环是共享的。当一个函数要使用线程池的时候(比如调用uv_queue_work),Libuv 会预先分配并初始化 UV_THREADPOOL_SIZE 所允许的线程出来。而128 占用的内存大约是 1MB,如果设置的太高,当使用线程池频繁时,会因为内存占用过多而降低线程的性能。具体说明;
    对于网络 I/O 方面,以 Linux 系统下来说,网络 I/O 采用的是 epoll 这个异步模型。它的优点是采用了事件回调的方式,大大降低了文件描述符的创建(Linux下什么都是文件)。
    在每次调用 epoll_wait 时,实际返回的是就绪描述符的数量,根据这个值,去 epoll 指定的数组里面取对应数量的描述符,是一种 内存映射 的方式,减少了文件描述符的复制开销。
    上面提到的 epoll 指定的数组,它的大小即可监听的数量大小,它在不同的系统下,有不同的默认值,可见这里 epoll create
    有了大小的限制,还远不够,为了保证运行的稳定,防止你在调用 epoll 函数时,指针越界,导致内存泄漏。还会用到另外一个值 maxevents,它是 epoll_wait 所能处理的最大数量,在调用 epoll_wait 时可以指定。一般情况下小于创建时(epoll_create)的数组大小,当然,也可以设置的比 size 大,不过应该没什么用。可以想到如果就绪的事件很多,超过了 maxevents,那么超出的事件就要等待前面的事件处理完成,才可以继续,可能会导致效率的下降。
    在这种情况下,你可能会担心事件会丢失。其实,是不会丢失的,它会通过ep_collect_ready_items 将这些事件保存在一个队列中,在下一个 epoll_wait 再进行通知。
    Node.js 不适合做什么
    虽然看起来,Node.js 可以做很多事情,并且拥有很高的性能。比如做聊天室,搭建 Blog 等等,这些 I/O 密集型的应用,是比较适合 Node.js 的。
    但是,有一种类型的应用,可能 Node.js 处理起来会比较吃力,那就是 CPU 密集型的应用。前文提到,Libuv 通过事件循环来处理异步的事件,这是存在于 Node.js 主线程的机制。通过这个机制,所有的 I/O 操作,底层API的调用都变成了异步的。但用户的 Javascript 代码是运行在主线程中的,如果这部分代码运行耗时很长,就会导致事件循环被阻塞。因为,它对于事件的处理,都是按照队列顺序的,所以如果其中的任何一个事务/事件本身没有完成,那么其他的回调、**、超时、nextTick() 都得不到运行的机会,被阻塞的事件循环没有机会去处理它们。这样下去,轻则效率降低,重则运行停滞。
    比如我们常见的模板渲染,压缩,解压缩,加/解密等操作,都是 Node.js 的软肋,所以使用的时候要考虑到这方面。
    总结
    • Node.js 通过 libuv 来处理与操作系统的交互,并且因此具备了异步、非阻塞、事件驱动的能力。
    • Node.js 实际上是 Javascript 执行线程的单线程,真正的的 I/O 操作,底层 API 调用都是通过多线程执行的。
    • CPU 密集型的任务是 Node.js 的软肋。
    守望者AIR技术交流社区(www.airmyth.com)
    回复

    使用道具 举报

    您需要登录后才可以回帖 登录 | 立即注册

    本版积分规则

    
    关闭

    站长推荐上一条 /4 下一条

    QQ|手机版|Archiver|网站地图|小黑屋|守望者 ( 京ICP备14061876号

    GMT+8, 2024-3-29 06:58 , Processed in 0.085765 second(s), 31 queries .

    守望者AIR

    守望者AIR技术交流社区

    本站成立于 2014年12月31日

    快速回复 返回顶部 返回列表