为什么要异步IO
node的事件循环
在进程启动时,node会创建一个类似于while的循环,每执行一次循环体就是一个tick
每个tick的过程就是查看是否有事件待处理,如果有,就取出事件和相关的函数执行。如果没有事件就退出进程。
-
观察者
在每个tick的过程,如何判断是否有事件需要处理?向观察者询问是否有事件要处理。
这个过程就如同饭馆的厨房,厨房一轮一轮制作菜肴,厨房每做完一轮菜肴就问一下小妹,还有没有还有没有需要做的菜。在这个过程中小妹就是观察者,她收到的客人点单就是收到的关联函数。当然可能出现多个观察者。
事件循环是典型的生产者/消费者模型。异步io和网络请求都是事件的生产者,不断为node提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。 -
请求对象
对node中的异步IO调用而言,回调函数不由开发者调用。从发起调用到内核执行完IO操作的过渡过程中,存在一种中间产物,叫做请求对象。
从js调用node核心模块,核心模块调用内建模块,内建模块通过libuv进行系统调用。
libuv作为封装层有两个平台的实现,实质上是调用uv_fs.open方法,会创建一个请求对象,从js层传入的参数和方法都被封装在这个请求对象中。请求对象随后被推入线程池中等待执行。
当线程池中有可用线程时我们会调用底层函数,js调用立即返回,js继续执行后面的任务,当前的io操作在线程池中等待执行,不管它是否阻塞io都不会影响js线程的后续执行。 -
执行回调
组装好请求对象,送入io线程池等待执行,实际上完成了异步io的第一部分,回调通知是第二部分。
线程池的io操作完成之后,会将线程归还线程池。在这个过程中,我们还动用了事件循环的IO观察者。在每次tick的执行中,会去检查线程池中是否有执行完的请求,如果有,会将请求对象加入到IO观察者的队列中,然后将其当做时间处理。
4.整个异步IO的分为三大部分,异步调用,线程池,事件循环。
js是单线程的,node执行是多线程的。除了用户代码无法并行执行,所有的io(网络io和磁盘io)都是可以并行起来的。
-
与io无关的异步API
setTimeout(), setInterval(),setImmediate, process.nextTick() -
定时器
定时器的实现原理和异步io类似,只是不需要线程池的参与。定时器会被插入到一个定时器观察者的内部,每次tick会去取出定时器对象,检查是否超过定时时间,如果超过则执行。 -
process.nextTick()
立即执行异步任务,性能优于setTImeout(() => {},0),只会将函数放入队列,在下一次tick的时候执行。 -
setimmediate
和nextTick一样,都是延迟执行回调函数,事件循环中有3个观察者,idle,io,check
事件循环检查观察者的顺序是idle > io > check ,nextTick保存在数组中,在这一次tick中会执行完,setimmediate保存在链表,每次tick只会执行一个回调
事件驱动与高性能服务器
一个线程一个请求的模式会有创建和销毁线程的开销
异步编程
node如何通过事件循环实现异步,I/O包括异步I/O和非I/O的异步(定时器,nexttick,setimmidiate)。
多线程编程
js的单线程在浏览器中指的是,js的执行线程和ui渲染共用一个线程;在node中只是没有ui渲染的这一部分,模型基本相同。如果服务器是多核cpu,node并没有充分利用。
为了提高多核cpu的利用率,浏览器端提出了web workers,它通过js执行与ui渲染分离。web worker解决了利用多核cpu和减少阻塞渲染。
异步编程解决方案
一. 事件发布/订阅模式
首先订阅一个事件,将事件回调化,一旦触发事件,则执行回调。在回调中拿到发布传递的参数。
订阅事件就是一个高阶函数的应用。事件发布订阅模式可以实现一个事件与多个回调函数的关联,这些回调函数又称为事件侦听器。通过emit发布事件后,消息会立即传递给当前事件的所有侦听器执行。
emit的调用是伴随事件循环而异步触发的,一般会将事件发布封装,程序通过监听回调只需要关注业务事件和回调即可。
-
利用事件队列解决雪崩问题
在事件订阅、发布模式中有一个once方法,通过它添加的侦听器只能执行一次,在执行之后就会将它与事件的关联解除。
在计算机中,缓存由于存放在内存之中,访问速度非常快,常常用于加速数据访问,所谓雪崩就是高访问量大并发时的缓存失效场景。在此场景下,我们利用once方法将所有请求的回调,都压入事件队列,保证每个回调只执行一次。 -
多异步之间的协作方案
侦听器作为回调函数可以随意添加和删除,一般而言事件和侦听器的关系是一对多。但也有多对一的情况,由于多个异步场景中回调函数的执行并不能保证顺序,且回调函数之间并没有任何交集,所以需要借助一个第三方函数和变量来处理结果。多个事件调用同一个回调并传入参数,回调中会搜集是哪一个事件并传入对象。等待多个事件被触发之后才会执行侦听器。
二. Promise/Deferred
promise then中的回调就是侦听器。为了完成promise的整个流程,还需要一个deferred对象,既延迟对象,称为defered。
三. 内存管理
js使用垃圾回收机制进行自动内存管理,node使用v8作为js脚本引擎。
v8的内存限制,node通过js只能使用部分内存。在这样的限制之下,将会导致node无法直接操作大内存对象,比如无法将一个2gb的文件读入内存中进行字符串的分析,在单个node进程下,计算机的内存资源无法得到充分利用。
造成这个问题的原因在于node基于v8构建,v8在浏览器绰绰有余,但是在服务器端却限制了使用大内存。
v8为何限制内存?v8限制内存的使用策略。
-
v8的对象分配
在v8中所有的js对象都是经过堆分配,执行process.memoryUsage(),会返回三个属性,heapTotal 已申请到的堆内存,heapUsed是当前的使用量。当我们在代码中声明变量并赋值时,所使用的对象的内存就在堆中。如果已申请的堆空闲内存不够分配,将继续申请堆内存。
v8为啥要限制堆的大小,本质原因是它的垃圾回收机制。v8做一次小的垃圾回收需要50ms,做一次非增量式的垃圾回收需要1s以上,在垃圾回收过程中会引起js线程暂停执行。在这样的花销下,应用的性能和相应能力都会下降。
node在启动服务时可以调整内存限制的大小 node --max-old-space-size=17000 test.js
上述参数在初始化时生效,一旦生效无法改变。 -
v8的垃圾回收机制
在实际应用中,对象的生存周期长短不一,不同的算法只能针对特定的场景才会有最好的效果。现代的垃圾回收算法按照对象的存活时间将内存的垃圾回收进行不同的分代。然后分别对不同分代的内存施以不同的算法。
-
2.1 在v8中,主要将内存分为新生代和老生代,当内存分配超过极限就会引起进程出错。
在分代的基础上,新生代的对象主要通过scavenge算法,它是采用一种复制的方式实现,它将堆内存一分为二,在这两个空间,一个是使用中,另一个是闲置状态,处于使用状态的空间称为from空间,闲置是to空间。 -
2.2 作用域
js能形成作用域的有函数调用,with,和全局作用域
函数在每次创建的时候会创建对应的作用域,函数执行结束后,该作用域会被销毁。同时作用域中声明的局部变量分配在该作用域上,随作用域的销毁而销毁。只被局部变量引用的对象存活周期较短。引用的对象会在下次垃圾回收时释放。
由于全局作用域需要进程退出时才能释放,此时将导致引用的对象常驻内存。释放全局变量可以用delete删除或者赋值为undefined,主动释放变量引用的对象。
外部作用域访问内部作用域的变量的方法。
一旦有变量引用中间函数,那么这个中间函数就不会被释放,通过os对象可以查看系统总内存和闲置内存。process.memoryUsage是查看node进程的内存占用情况。
慎将内存当做缓存,缓存的访问效率比io的效率高,一旦命中就可以节省一次io的时间。js开发者喜欢用对象的键来缓存东西,但这与严格意义上的缓存又有区别,严格意义的缓存有完善的过期策略,但是普通对象并没有。
memoize = function(func, hasher) {
var memo = {};
hasher || (hasher=.identity)
return function() {
var key = hasher.apply(this, arguments)
}
}
它的原理是以参数作为键进行缓存,以内存空间换cpu的执行时间。
- 缓存限制策略
为了解决缓存对象无法释放的问题,需要加入一种策略来限制缓存的无限增长。
缓存的解决方案
直接将内存作为缓存的方案要十分慎重,
网友评论