Node 与底层之间如何执行异步 I/O 调用

koala 2020-1-9 Node.js异步I/O

本文你能学到:

  • Node.js 与底层之间是如何执行异步 I/O 调用的?和事件循环怎么联系上的呢?
  • 为什么说 Node 高性能,Node 的异步 I/O 对高性能助力了什么?
  • Node 的事件循环,你对事件怎么理解?

看完本文后,你应该能更好的去理解事件循环,知道事件是怎么来的,Node 究竟执行异步 I/O 调用。如果面试官再问事件循环还有 Node 与底层之间如何执行异步 I/O,我觉得你把本文的流程说清楚,应该能加分!本文对事件循环中的具体步骤没有详细讲解,每个步骤看官方文档更佳。

# 理解本文先要学习的几个概念

# Node.js 模块分类

nodejs 模块可以分为下面三类:

  • 核心模块(native 模块):包含在 Node.js 源码中,被编译进 Node.js 可执行二进制文件 JavaScript 模块,其实也就是 lib 和 deps 目录下的 js 文件,比如常用的 http,fs 等等。
  • 内建模块(built-in 模块):一般我们不直接调用,而是在 native 模块中调用,然后我们再 require。
  • 第三方模块:非 Node.js 源码自带的模块都可以统称第三方模块,比如 express,webpack 等等。
    • JavaScript 模块,这是最常见的,我们开发的时候一般都写的是 JavaScript 模块
    • JSON 模块,这个很简单,就是一个 JSON 文件
    • C/C++ 扩展模块,使用 C/C++ 编写,编译之后后缀名为 .node

比如 Node 源码 lib 目录下的 fs.js 就是 native 模块,而 fs.js 调用的 src 目录下的 node_fs.cc 就是内建模块。

# libuv

Libuv 是一个高性能的,事件驱动的异步 I/O 库,它本身是由 C 语言编写的,具有很高的可移植性。libuv 封装了不同平台底层对于异步 IO 模型的实现,libuv 的 API 包含有时间,非阻塞的网络,异步文件操作,子进程等等,所以它还本身具备着 Windows, Linux 都可使用的跨平台能力。

经典 libuv 图(来源网上)

# IOCP

概念:输入输出完成端口(Input/Output Completion Port,IOCP), 是支持多个同时发生的异步 I/O 操作的应用程序编程接口,在 Windows NT 的 3.5 版本以后,或 AIX5 版以后或 Solaris 第十版以后,开始支持。

我直接这么说概念你可能也不太懂。可以暂时知道 Windows 下注意通过 IOCP 来向系统内核发送 I/O 调用和从内核获取已完成的 I/O 操作,配以事件循环,完成异步 I/O 的过程。在 linux 下通过 epoll 实现这个过程,也就是由 libuv 自行实现。

IOCP 的另一个应用场景在之前 Node.js 进程与线程那篇文章也有写过。Mater 和 app worker 进程通信使用到。

# 线程池

线程池,是一种线程的使用模式,它为了降低线程使用中频繁的创建和销毁所带来的资源消耗与代价。 通过创建一定数量的线程,让他们时刻准备就绪等待新任务的到达,而任务执行结束之后再重新回来继续待命。

这就是线程池最核心的设计思路,「复用线程,平摊线程的创建与销毁的开销代价」。

本文使用到线程池的地方:在 Node 中,无论是 *nix 还是 Window 平台。内部完成 I/O 任务的都有用到线程池。

libuv 目前使用了一个全局的线程池,所有的循环都可以往其中加入任务。目前有三种操作会在这个线程池中执行:

  • 文件系统操作

  • DNS 函数(getaddrinfo 和 getnameinfo)

  • 通过 uv_queue_work() 添加的用户代码

# Node 与底层之间的异步 I/O调用流程

对比图中两段经典 api 代码(server.listenfs.open,选择两种 api 的原因:网络 I/O 代表和文件 I/O 代表)和之前 libuv 图片,我们来一起理解异步 I/O 调用流程

上图展示了 libuv 细节的流程,图中代码很简单,包括 2 个部分:

  1. server.listen() 是用来创建 TCP server 时,通常放在最后一步执行的代码。主要指定服务器工作的端口以及回调函数。

  2. fs.open() 是用异步的方式打开一个文件。

选择两个示例很简单,因为 libuv 架构图可视:libuv 对 Network I/O 和 File I/O 采用不同的机制。

上图右半部分,主要分成两个部分:

  1. 主线程:主线程也是 node 启动时执行的现成。node 启动时,会完成一系列的初始化动作,启动 V8 engine,进入下一个循环。

  2. 线程池:线程池的数量可以通过环境变量 UV_THREADPOOL_SIZE 配置,最大不超过 128 个,默认为 4 个。

在 Node.js 中经典的代码调用方式:都是从 JavaScript 调用 Node 核心模块,核心模块调用 C++ 内建模块,内建模块通过 libuv 进行系统调用。请记住这段话

# 事件循环

不管是server.listen还是fs.open,他们在开启一个 node 服务(进程)的时候,Node 会创建一个 while(true)的循环,这个循环就是事件循环。每执行一次循环体的过程,我们称之为 Tick。每个 Tick 的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。如果存在关联的回调函数,就执行。然后进入下一个循环,如果不再有事件处理,退出进程。

这里我们知道事件循环已经创建了,上面加粗字体查看是否有事件待处理,去哪里查看?事件怎么进入事件循环的?什么情况会产生事件继续往下看

# 底层调用与事件产生

继续看这张图,讲解一下事件产生基本流程,(注意网络 I/O 和文件 I/O 会有一些不同)这里对 c++代码调用简单提一下,有兴趣的小伙伴可以继续深入研究。

# File I/O

(这里就用到了文初提到的模块分类知识)先是 javascript 代码,然后调用 lib/fs.js 核心模块代码 fs.open ,核心模块调用 C++ 内建模块 src/node_file.cc,内建模块 c++代码会有一个平台判断,然后通过 libuv 进行系统调用。

从前面到达 libuv ,会有一个参数,请求对象,也就是 open 函数前面整个流程传递进来的请求对象,它保存了所有状态,包括送入线程池等待执行以及 I/O 操作完毕后的回调处理。

请求对象组装完成后,送入 libuv 中创建的 I/O 线程池,线程池中的 I/O 操作完毕后,会将获取的结果存储到 req->result 属性上,然后通知某函数通知 IOCP ,告知当前对象操作已经完成。

在这整个过程中,进程初期创建的事件循环中有一个 I/O 观察者,每次 Tick 的执行中,它会调用 IOCP 相关的方法检查线程池中是否有执行完成的请求,如果存在,会讲请求对象和之前绑定的 result 属性,加入到 I/O 观察者的队列中,然后将其当作事件处理。

看到这里,前面提到的**是否有事件待处理,去哪里查看?事件怎么进入事件循环的?**这两个问题是不是搞懂了。

文字配上图。更清晰!

# Network I/O

V8 engine 执行从 server.listen() 开始,调用 builtin module Tcp_wrap 的过程。

在创建 TCP 链接的过程中,libuv 直接参与Tcp_wrap.cc函数中的 TCPWrap::listen() 调用 uv_listen()开始到执行uv_io_start()结束。看起来很短暂的过程,其实是类似 linux kernel 的中断处理机制。

uv_io_start()负载将 handle 插入到处理的water queue中。这样的好处是请求能够立即得到处理。中断处理机制里面的下半部分与数据处理操作相似,交由主线程去完成处理。

重要:虽然 libuv 的异步文件 I/O 操作是通过线程池实现的,但是网络 I/O 总是在单线程中执行的,注意最后还是会把完成的内容作为事件加入事件循环,事件循环就和文件 I/O 相同了。

# 异步 I/O 助力 Node.js 高性能

传统的服务器模型

  • 同步式: 同步的服务,一次只能处理一个请求,并且其余请求都处于等待状态。
  • 每进程/每请求: 为每个请求启动一个进程,这样可以处理多个请求,但是不具有扩展性,系统资源有限,开启太多进程不太合适
  • 每线程/每请求: 为每个请求启动一个线程来处理。尽管线程比进程轻量,但是每个线程也都会占用一定内存,当大并发请求的时候,也会占用很大内存,导致服务器缓慢。

Node 就不一样了!

看了文章前面的内容,Node 通过事件驱动的方式处理请求,无需为每个请求创建额外的对应线程,可以省掉创建线程和销毁线程的开销,同时操作系统在调度任务时因为线程较少,上下文切换的代价很低。这也是 Node.js 高性能之一

Nginx 目前也采用了和 Node 相同的事件驱动方式,有兴趣的也去了解下,不过 Nginx 采用 c 语言编写。

# 关注我

作者简介:koala,专注完整的 Node.js 技术栈分享,从 JavaScript 到 Node.js,再到后端数据库,祝您成为优秀的高级 Node.js 工程师。【程序员成长指北】作者,Github 博客开源项目 https://github.com/koala-coding/goodBlog

  • 欢迎加我微信【 ikoala520 】,拉你 进 Node.js 高级进阶群,长期交流学习...
  • 欢迎关注「程序员成长指北」,一个用心帮助你成长的公众号...

# 参考

本文很多内容来自朴灵老师的 《深入浅出 Node.js》,这本书虽然出版很久了,给我的感觉还是越看越香,自己可以边看边扩展,推荐。

Libuv 学习——文件处理

高性能异步 I/O 模型库 libuv 设计思路概述

# 给我留言

关注作者公众

和万千小伙伴一起学习

加入技术交流群

扫描二维码 备注 加群