这是我参与「第四届青训营 」笔记创作活动的第3天,今天给大家分享一下我观看课程《Node.JS与前端开发实战》做的笔记,主要是Node.JS的一个总体概况介绍,以及自己学习《Node.JS深入浅出》中对异步I/O原理的一个大致记录。
1. 应用场景
1.1 前端工程化
- Bundle:webpack、vite、esbuild、parcel
- Uglify:uglifyjs
- Transpile:bablejs、typescript,转移
- ...
1.2 Web 服务端应用
特点:
-
学习曲线平缓、开发效率高,也就是语法与JS基本相同,可以节省学习时间
-
运行效率接近常见的编译语言
-
社区生态丰富、工具链成熟。(例如npm和V8 inspector)
社区成熟度相比于其他语言可能会略逊一筹,但成熟的工具链可以让开发者在不同的语言环境下可以使用同一套调试方案。
-
与前端结合的场景例如SSR有优势
1.3 跨端桌面应用
工具有electron、slack等,应用场景为:
- 商业应用:vscode等
- 大公司的效率工具:相比传统桌面应用开发工具更高效
2. 运行时结构
2.1 组成
运行时就是程序在运行时需要的环境,这个运行环境包含了代码运行所需要的解释器和底层操作系统的接口等。这里指Nodejs是JavaScript的运行时。
主要包括以下几种
-
V8:JavaScript Runtime、诊断调试工具(inspector)
实现了JS解析和执行,支持自定义拓展,例如可以自己编写C++扩展模块,方便实现一些自定义需求
-
libuv:eventloop,syscall(系统调用)
封装操作系统API,提供跨平台的IO操作
-
其他库,如http解析器、dns解析库等
2.2 特点
2.2.1 异步I/O
异步I/O是指在进行I/O操作时,调用后可以不用等待整个I/O执行完,而是直接返回,让CPU可以执行其他事务,直到I/O操作执行完毕,就返回执行回调函数。相比于阻塞I/O,可以大大提高CPU运行的效率。
Node在*nix中使用线程池实现了异步IO,Windows则是IOCP(内部的原理也是线程池,只是这些线程池由系统内核管理)。
核心实现主要通过事件循环模型,下面简单说说大体的实现。
事件循环
进程启动时,Node会创建一个循环。每一次执行称为Tick,每个Tick的过程要查看是否有事件待处理,如果有则取出事件及其相关的回调函数,如果存在关联的回调函数就执行他们。接着进入下一个循环。
若不再有事件需要处理,则退出进程:
那么如何判断每个Tick是否有事件需要处理呢?通过观察者。
每个事件循环中有一个或多个观察者,判断是否有事件需要处理就是向观察者询问是否有要处理的事件。
每次Tick会调用IOCP相关的GetQueuedCompletionStatus方法检查线程池中是否有执行完的请求,如果存在则会将请求对象加入到I/O观察者的队列中,将其当作事件进行处理。(线程池在后面)
浏览器也有类似的机制,需要观察事件的产生。例如文件I/O观察者,网络I/O观察者。
在Windows中事件循环基于IOCP创建,而在*nix下基于多线程创建。
一次I/O调用的内部实现
以Windows的IOCP为例,以下是JS代码到系统内核之间发生的事情:
Node中异步I/O调用中的回调函数不由开发者调用,在发出调用后到回调函数被执行的过程中,有一个叫请求对象的中间产物。
fs.open是根据指定路径和参数去打开文件,从而得到一个文件描述符,此为后续I/O操作的初始操作。
fs.open = function(path, flags, mode, callback) {
// ...
binding.open(pathModule._makeLong(path),
stringToFlags(flags),
mode,
callback);
};
在上面的uv_fs_open调用过程中,创建了一个FSReqWrap请求对象。
其中包含
- 所有从JS层传入的参数和当前方法
- 所有的状态,包括送入线程池等待执行和IO操作完毕后的回调处理
其中最为关注的回调函数则被设置在此对象的oncomplete_sym属性上
req_wrap->object_->Set(oncomplete_sym, callback);
请求对象包装完毕,若在windows下,则调用QueueUserWorkItem方法将FSReqWrap对象推入线程池中等待执行:
QueueUserWorkItem(&uv_fs_thread_proc, req, WT_EXECUTEDEFAULT)
三个参数分别是:
- 将要执行的方法的引用,这里引用uv_fs_thread_proc
- 第一个参数传入的方法所需的参数
- 执行的标志
uv_fs_thread_proc会根据传入参数的类型调用对应的底层函数,如果是uv_fs_open则调用fs__open方法
接着JS调用立即返回,JS线程可以继续执行当前任务的后续操作,当前的IO操作在线程池中等待执行,无论是否阻塞IO都不会影响JS线程的后续执行。
以上为异步I/O的第一部分,接着第二部分是执行回调(回调通知)。
线程池的I/O操作调用完毕后,会将获取的结果存储在req->result属性上,接着调用PostQueuedCompletionStatus方法通知IOCP,当前对象操作已完成。
PostQueuedCompletionStatus((loop)->iocp, 0, 0, &((req)->overlapped))
PostQueuedCompletionStatus的作用是向IOCP提交执行状态,并把线程归还给线程池,提交的状态可以通过GetQueuedCompletionStatus提取。
通知后,只要在事件循环中的观察者调用GetQueuedCompletionStatus方法获取到正确的状态,则会将请求对象取出,将其result属性作为参数,传入其oncomplete_sym方法,调用执行,即为调用回调函数。
至此,一次异步I/O就完成了。
2.2.2 单线程
JavaScript的单线程实际上不是只有一个线程,而是包括:JS线程、uv线程池、V8任务线程池、V8Inspector线程。
单线程的优缺点:
- 优点:不用考虑多线程状态同步问题,所以不需要线程锁,能够比较高效地利用系统资源
- 缺点:阻塞可能引起更多负面影响,例如无法利用多核CPU,错误会引起整个应用退出,大量计算占用CPU导致无法继续调用异步I/O等。可以使用多进程或多线程来解决
2.2.3 跨平台
NodeJS大部分功能和api是跨平台的,且JavaScript本身无需编译环境,还有一些工具例如V8 Inspector也是跨平台的,所以NodeJS在大部分场景下无需担心跨平台的问题。