Node.js异步I/O,事件驱动

Leo_

文章原文: https://yq.aliyun.com/article...
本文相对于原文有部分修改

前言

Node.js以高效,轻量著称,具有非阻塞I/O,事件驱动的特性.
非阻塞I/O很浅显的解释就是: 代码以单线程的方式执行,在遇到I/O操作时Node会开辟新的线程去执行I/O操作,主线程代码继续执行.
事件驱动很浅显的解释就是: 事件产生者发布一个事件,事件订阅者在收到事件后执行某段代码.
但非阻塞I/O,事件驱动到底是如何实现的呢,它们跟Node.js的单线程有什么关系呢?

Node.js结构

图片描述

  • Node standard library: 这一层是Node.js提供的标准库,里面有提供的各种API接口,是由JavaScript编写,在源码中lib目录下
  • Node bindings: 这一层为上层提供了底层C/C++的调用,数据交换等,实现在node.cc中
  • C/C++底层:

    • V8:大名鼎鼎的Google JavaScript VM,这也是Node.js使用js的原因,它为js提供了运行环境.
    • libuv:它为Node.js提供了跨平台,线程池,事件池,异步I/O等能力.
    • C-ares:提供了异步处理DNS相关的能力.
    • http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、数据压缩等其他的能力.

Libuv

Libuv(docs,GitHub)是Node.js关键的一个组成部分,它为上层js提供了统一的API调用,兼容了平台差异,隐藏了底层实现(它来源于libev,然而libev只能运行于Unix-like系统上。为了能够使Node.js运行在Windows/Unix-like系统上,libuv因此产生了)图片描述

  • Network I/O: 网络I/O

    • TCP: Transmission Control Protocol 传输控制协议
    • UDP: User Datagram Protocol 用户数据报协议
    • TTY: 大概就是控制台终端的意思
    • Pipe: 进程间通信管道
  • File I/O: 文件读写
  • DNS Ops: DNS解析
  • User code: 提供线程运行用户代码并获取循环的通知
  • uv_io_t: 没有找到相关资料
  • epoll: Linux下多路复用IO接口select/poll的增强版本
  • kqueue: UNIX上高效的IO复用技术
  • event ports: 提供事件端口
  • IOCP: 用于高效处理很多很多的客户端进行数据交换的一个模型
  • Thread Pool: 线程池
    可以看出,它是一个对开发者友好的工具集,包含定时器,非阻塞的网络 I/O,异步文件系统访问,子进程等功能。它封装了 Libev、Libeio 以及 IOCP,保证了跨平台的通用性.

一个全流程的例子

举一个文件操作的例子来阐述Node.js整个的执行流程

const fs = require("fs")
fs.open("./test.txt", "w", (err, data) => {
    // TODO
});

整个代码的调用过程大致可描述为: lib/fs.js -> src/node_file.cc -> uv_fs
图片描述

具体来说,当我们调用 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开启线程来执行这次I/O操作,执行完成后传回给JavaScript进行后续操作.
这里的I/O包括了文件I/O和网络I/O,这两者的实现并不相同,文件I/O和DNS等操作都是依托线程池(Thread Pool)来实现,而网络I/O(包括TCP,UDP,TTY等)是由epoll,IOCP,kqueue来实现的.
一个异步I/O的流程大体如下:

  • 发起I/O调用

    • 开发者通过JavaScript调用Node内置模块,将参数和回调函数传入到内置模块.
    • Node内置模块会将传入的参数和回调函数封装成一个请求对象.
    • 将这个请求对象推入到I/O线程池等待执行.
    • JavaScript发起的异步调用结束,主线程执行后续代码.
  • 执行回调

    • I/O操作完成后,会将结果存储到请求对象的result属性上,病发出操作完成的通知.
    • 每次事件循环时会检查是否有完成的I/O操作,如果有,则将请求对象加入到I/O观察者队列中,之后当做事件处理.
    • 处理 I/O 观察者事件时,会取出之前封装在请求对象中的回调函数,执行这个回调函数,并将 result 当参数,以完成 Javascript 回调的目的.
      图片描述

从这里,我们可以看到,我们其实对 Node.js 的单线程一直有个误会。事实上,它的单线程指的是自身 Javascript 运行环境的单线程,Node.js 并没有给 Javascript 执行时创建新线程的能力,最终的实际操作,还是通过 Libuv 以及它的事件循环来执行的。这也就是为什么 Javascript 一个单线程的语言,能在 Node.js 里面实现异步操作的原因,两者并不冲突。

事件驱动

当我们写了一大堆事件处理函数后,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 相关的过程如下:

  1. 更新当前循环的 time 字段,即当前循环下的“现在”;
  2. 检查循环中是否还有需要处理的任务(handlers/requests),如果没有就不必循环了,即是否 alive。
  3. 检查注册过的timer,如果某一个 timer 中指定的时间落后于当前时间了,说明该 timer 已到期,于是执行其对应的回调函数.
  4. 执行一次`I/O polling(即阻塞住线程,等待 I/O 事件发生),如果在下一个 timer 到期时还没有任何 I/O
    完成,则停止等待,执行下一个 timer 的回调。如果发生了 I/O 事件,则执行对应的回调;由于执行回调的时间里可能又有 timer
    到期了,这里要再次检查 timer 并执行回调。
阅读 3.1k

后端学习记录
后端学习笔记

learning...

651 声望
12 粉丝
0 条评论

learning...

651 声望
12 粉丝
宣传栏