前言:
关于本书,读过了好几遍,因为实在经典,而且不希望自己读过之后就忘记,一直想做个笔记,所以就做了个笔记。虽然是经典书籍,但是书中有地方描述得并不是很深入,因此也对感兴趣部分做了稍微深入的拓展
第一章:
Nodejs保持单线程的特点,其最大的好处是不用像多线程变成那样处处在意同步的问题,没有死锁,也没有切换上下文带来的性能上的开销。
但是,Nodejs的弱点,也可以说是单线程的弱点也就随之而来:
无法利用多核CPU (child_process模块可以解决这个问题) 错误会引起整个应用退出 大量计算占用CPU无法继续调用异步I/O
第二章:模块机制
CommonJS规范,包含3部分:
模块引用 :var math = require('math') 模块定义 : exports.add = function(){...} 模块标识 : require()的参数 模块分为:
核心模块:Node提供的内置模块,部分(未量化)核心模块在Node进程启动时直接被加载到内存,速度极快 文件模块:用户编写的,动态加载。需要完整的路径分析、文件定位、编译执行,加载速度相对于核心模块较慢 模块加载:
Node会将模块缓存起来,优先从缓存加载。核心模块缓存检查优先于文件模块的缓存检查
内置变量:__filename,exports,require,module,__dirname,在模块编译过程,Node对JS文件进行头尾包装,在头部添加了(function (__filename,exports,require,module,__dirname){ /用户代码/ })
...
第三章:异步I/O
同步、异步 区别在于是否等待返回,对客户端而言
阻塞和非阻塞 对系统而言,阻塞造成CPU空闲等待,非阻塞的麻烦是要轮询结果
3.1.1. 轮询技术
read :通过重复调用来检查I/O的状态,在获取最终数据前,CPU一只在等待 select :跟read差不多,select有一个较弱的限制,那就是采用一个1024长度的数组来存储状态,所以它最多可以同时检查1024个文件描述符 poll :采用链表方式避免数组长度限制,避免不需要的检查,但当文件描述符多的适合,性能依然十分低下 epoll :linux下效率最高的I/O事件通知机制。真实利用了事件通知、回调方式,而不是遍历查询,因此执行效率较高 kqueue :与epoll相似,但仅存在FreeBSD系统 IOCP :windows系统仅有。虽然是调用异步方法,等待I/O完成之后的通知,执行回调,用户无需考虑轮询,但它的内部其实仍然是线程池原理,不同之处是这些线程池由系统内核接手管理 (读到这里,推荐细读一篇文章:medium.com/the-node-js… )
关于nodejs使用4个线程执行异步任务的说法(其实未必正确, Whenever possible(只要有可能),libuv will use those asynchronous interfaces, avoiding usage of the thread pool. ... Here the authors of the driver will rather use the asynchronous interface than utilizing a thread pool.)
3.1.2. 理想的非阻塞异步
程序发起非阻塞的调用,无需通过遍历或者事件唤醒等方式轮询,可以直接处理下一个任务,只需在I/O完成后通过信号或者回调将数据传递给应用程序。
幸运的是,在Linux下存在这样的一种方式,它原生提供的一种异步I/O方式(AIO)就是通过信号或者回调来传递数据的;但不幸的是,只有Linux下才有,而且还有一个缺陷:无法利用系统缓存
3.1.3. 现实的异步I/O
通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递,这就轻松实现了异步I/O(尽管它是模拟的)
3.1.4. node的异步I/O:通过主循环加事件触发的方式来运行程序
事件循环:Node会在进程启动时,创建一个while循环,每次循环(tick)就是查看是否有事件待处理(通过观察者,比如:文件I/O观察者、网络I/O观察者),如果有,则取出事件以及相关的回调函数,如果存在关联的回调函数,就执行它们。然后进入下一个循环。如果不再有事件处理,就退出进程。事件循环就是一个典型的生产者/消费者模型。 从JavaScript发取调用到内核执行完成I/O操作的过渡过程中,存在一种中间产物,叫请求对象,这是异步I/O过程中的重要中间产物,所有状态都保存在这个对象中,包括送入线程池等待执行以及I/O操作完毕后的回调处理。 组装好请求对象,送入I/O线程池等待执行,实际上完成了异步I/O的第一部分,回调通知是第二部分,这里就通过事件循环的I/O观察者,取出待执行的请求,加入观察者队列,然后将其当作事件处理(通过“线程池”处理,不同之处在于,Windows的线程池由内核提供,*nix系列系统由libuv自行实现) Node本身是多线程的,只是I/O线程使用的CPU较少。除了用户的代码无法执行之外,所有的I/O(磁盘I/O、网络I/O)则是可以并行起来的
3.1.4.1. 定时器
setTimeout和setInterval,与浏览器 的API一致,实现原理与异步I/O类似,只是不需要I/O线程池参与,通过红黑树的数据结构放入并取出执行。
3.1.4.2. process.nextTick
时间复杂度为O(1),而定时器采用红黑树操作的事件复杂度为O(lg(n)),将回调函数保存在一个数组中,在每次循环中会将数组中的回调函数全部执行完。
但是process.nextTick会阻止Node进入事件循环阶段
3.1.4.3. setImmediate
将回调函数保存在一个链表中,在每次轮询中执行链表中的一个回调函数
第四章:异步编程
说到异步编程,离不开函数式编程,后续会整理一个函数式编程的笔记
高阶函数:可以把函数当参数,或者是将函数作为返回值的函数
偏函数:指创建一个调用另外一个部分--参数或变量已经预置的函数–的函数的用法
例如:
var toString = Object.prototype.toString;
var isString = function(obj){ return toString.call(obj) == '[object String]' };
var isFunction = function(obj){ return toString.call(obj) == '[object Function]' }
可以使用一个isType()函数预先制定type的值,然后返回一个新的函数:
var isType = function(type){
return function(obj) {
return toString.call(obj) == '[object ' + type + ']'
}
}
这种通过指定部分参数来产生另一个新定制函数的形式就是偏函数(其实也用到了柯里化的概念)
4.1.1. 异步编程优势和难点
背景:曾经单线程模型在同步I/O的影响下,由于I/O调用缓慢,在应用层导致CPU和I/O无法重叠执行。为了照顾编程人员的习惯,同步I/O盛行了很多年
优势:非阻塞I/O可以使CPU和I/O并不相互依赖等待
缺点:无法承担过多任务,否则会影响任务调度,导致整体效率降低
难点:
无法捕获异步异常,传统的trycatch只能捕获当次事件循环内的异常,对callback执行时抛出的异常无法处理。 因此异回调函数的第一个参数一般都约定为异常参数,比如async( function(error,result){/.../} ) (虽然es6的横空出世解决了这个问题,但本质上还是回调的语法糖) 函数嵌套太深(callback hell) 阻塞代码,如while循环( var start = new Date(); while( new Date - start < 1000 ){/code/} ),会持续占用CPU时间,破坏时间循环的调度,由于单线程的原因,CPU资源会全部用在这段代码,导致其余任何请求都无法响应 多线程编程:单个Node进程实际上无法使用多核CPU 异步转同步学习成本高
4.1.2. 异步编程解决方案
4.1.2.1. 发布订阅模式
订阅事件就是一个高阶函数的应用,如:
emitter.on("event1",function(message){}) // 事件订阅
emitter.emit("event1","I'am a msg") // 事件发射
Node对事件发布/订阅做了一些限制:
如果一个事件添加超过了10个监听器,会有一条警告。设计者认为,太多监听器可能会导致内存泄漏。当然,也可以去掉这个限制(通过设置emitter.setMaxListener(0)),但是如果监听器过多,可能存在过多占用CPU的情景 为了处理异常,EventEmitter对象对error事件进行了特殊处理。
如果触发了error事件,EventEmitter会检查是否对error事件添加过监听器,如果添加了,则该error会由这个监听器处理,否则会作为异常抛出。如果外部没有捕获这个异常,将会引起线程退出
雪崩:在高访问量,大并发量的情况下,缓存失效的情景,此时大量请求涌入数据库,数据库无法同时承受如此大的查询请求,影响网站的整体响应速度。
场景:站点刚启动的时候,由于缓存中是不存在数据的,则有可能会出现雪崩问题
这时候,可以使用event模块的EventEmitter类的once()方法处理,将所有请求的回调都压入事件队列中,利用其执行一次就会将监听器移除的特点,保证了每一个回调只会被执行一次。
思考:通过发布/订阅模式,是否解决了异步编程难点2(函数嵌套太深(callback hell))的问题?
4.1.2.2. Promise/Deferred模式
Promise/A 模式
只存在3中状态中的一种:未完成、完成、失败 只会从未完成态向完成态转化,不能逆反。完成态和失败态不能互相转化 状态一旦转化,不能被更改 一个Promise对象只要具备then()方法即可,但对于then()方法,有以下简单的要求:
接受完成态、错误态的回调方法。在操作完成或出现错误时,将会调用对应的方法
可选择地支持progress事件回调作为第三个方法 (实际上,一般的实现中,并不包含第三个参数)
只接受function对象
继续返回Promise,以实现链式调用
Promise和Deferred的区别:
Deferred主要用于内部,用于维护异步模型的状态;Promise则作用于外部,通过then()方法暴露给外部以添加自定义逻辑
接口和抽象模型都十分简洁。业务中不可变的部分封装在Deffered中,可变部分交给了Promise
更多关于Node对promise的实现的知识,建议细读:pouchdb.com/2015/05/18/…
4.1.2.3. 流程控制库(常用):
async: www.npmjs.com/package/asy…
内置非常丰富的函数,比如:
parallel()-- 异步“并行”执行 另外,还有一个parallelLimit()可提供限制并发的数量 series() -- 异步串行执行,但是当前一个结果是后一个调用的输入时,该方法并不满足。此时可以使用 walterfall()方法执行 auto() -- 自动依赖处理 等等
4.1.3. 异步并发控制
并发并不是无限制,在充分利用计算机资源的同时,也要考虑给予一定的过载保护
通过一个队列来控制并发量 当前活跃(指调用发起,但未执行回调)的异步调用量小雨限定值,从队列中取出执行 活跃调用达到限定值,调用暂时存放在队列中 每个异步调用结束时,从队列中取出新的异步调用执行 第三方库:bagpipe
npm: www.npmjs.com/package/bag…
特性包括 设置系统最大并发数 拒绝模式:当出现大量的异步调用,造成一部分调用需要等待,如果调用有实时方面需求,则需要快速返回。则拒绝模式可以让调用方尽早返回,而不用浪费不必要的等待时间 超时控制 等等
第五章 内存控制
5.1. V8内存限制
64位操作系统使用的内存约1.4GB,32位操作系统使用的内存约0.7GB(当然,该限制可以通过在启动node的时候传入 --max-old-space-size或者 --max-new-space-size来调整) ---- 为何要这么限制?
process.memoryUsage()
其中heapTotal和heapUsed是V8的堆内存使用的情况
当在代码中声明并赋值时,所使用的对象的内存就分配在堆中,如果不够,则V8会继续申请堆内存,直到超过V8的限制为止
那为啥要这么限制呢?
表层原因:V8最初为浏览器而设置,不太可能遇到大量使用内存的场景
深层原因:V8垃圾回收机制的限制
官方说法:1.5GB的垃圾回收堆内存为例,需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上,会引起js主线程暂停执行,在这样的时间花销下,应用的性能和响应能力会直线下降。
5.1.1. V8回收机制
分代垃圾回收机制:新生代、老生代。新生代对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象
V8堆的整体大小就是新生代内存加上老生代内存的空间(新生代在64位操作系统为32MB,32位操作系统为16MB)
持续更新中...