2017-05~06 温故而知新--NodeJs书摘(一)
前言:
毕业到入职腾讯已经差不多一年的时光了,接触了很多项目,也积累了很多实践经验,在处理问题的方式方法上有很大的提升。随着时间的增加,愈加发现基础知识的重要性,很多开发过程中遇到的问题都是由最基础的知识点遗忘造成,基础不牢,地动山摇。所以,就再次回归基础知识,重新学习NodeJs相关内容,加深对NodeJs本质的理解。日知其所亡,身为有追求的程序员,理应不断学习,不断拓展自己的知识边界。本系列文章是在此阶段产生的积累,以记录下以往没有关注的核心知识点,供后续查阅之用。
2017
05/27
Node保持了JavaScript在浏览器中单线程的特点。而且在Node中,JavaScript与其余线程是无法共享任何状态的。单线程的最大好处是不用像多线程编程那样处处在意状态的同步问题,这里没有死锁的存在,也没有线程上下文交换所带来的性能上的开销。
同样,单线程也有它自身的弱点,这些弱点是学习Node的过程中必须要面对的。积极面对这些弱点,可以享受到Node带来的好处,也能避免潜在的问题,使其得以高效利用。单线程的弱点具体有以下3方面。
- 无法利用多核CPU。
- 错误会引起整个应用退出,应用的健壮性值得考验。
- 大量计算占用CPU导致无法继续调用异步I/O。
Node采用了与Web Workers相同的思路来解决单线程中大计算量的问题:child_process 。 子进程的出现,意味着Node可以从容地应对单线程在健壮性和无法利用多核CPU方面的问题。通过将计算分发到各个子进程,可以将大量计算分解掉,然后再通过进程之间的事件消息来传递结果,这可以很好地保持应用模型的简单和低依赖。通过Master-Worker的管理方式,也可以很好地管理各个工作进程,以达到更高的健壮性。
05/30
应用场景:
I/O密集型:I/O密集的优势主要在于Node利用事件循环的处理能力,而不是启动每一个线程为每一个请求服务,资源占用极少。
CPU密集型:关于CPU密集型应用,Node的异步I/O已经解决了在单线程上CPU与I/O之间阻塞无法重叠利用的问题,I/O阻塞造成的性能浪费远比CPU的影响小。对于长时间运行的计算,如果它的耗时超过普通阻塞I/O的耗时,那么应用场景就需要重新评估,因为这类计算比阻塞I/O还影响效率,甚至说就是一个纯计算的场景,根本没有I/O。此类应用场景或许应当采用多线程的方式进行计算。
与遗留系统和平共处 :在Web端,过去大多都是同步的方式编写的程序,这种串行调用下层应用数据的过程中充斥着串行的等待时间。 采用Node来完成Web端的开发,使得前端工程师在HTTP协议栈的两端能够高效灵活地开发。并行I/O,有效利用稳定接口提升Web渲染能力。
分布式应用 :Node高效利用并行I/O。
06/02
在Node中引入模块,需要经历如下3个步骤。
- 路径分析
- 文件定位
- 编译执行
在Node中,模块分为两类:一类是Node提供的模块,称为核心模块;另一类是用户编写的模
块,称为文件模块。
核心模块部分在Node源代码的编译过程中,编译进了二进制执行文件。在Node进程启动时,部分核心模块就被直接加载进内存中,所以这部分核心模块引入时,文件定位和编译执行这两个步骤可以省略掉,并且在路径分析中优先判断,所以它的加载速度是最 快的。
文件模块则是在运行时动态加载,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。
06/04
JavaScript模块的编译 :在编译的过程中,Node对获取的JavaScript文件内容进行了头尾包装。在头部添加了(function (exports, require, module, __filename, __dirname) {\n,在尾部添加了\n});。 在执行之后,模块的exports属性被返回给了调用方。exports属性上的任何方法和属性都可以被外部调用到,但是模块中的其余变量或属性则不可直接被调用。
JavaScript的一个典型弱点就是位运算。JavaScript的位运算参照Java的位运算实现,但是Java位运算是在int型数字的基础上进行的,而JavaScript中只有double型的数据类型,在进行位运算的过程中,需要将double 型转换为 int型,然后再进行。所以,在JavaScript层面上做位运算的效率不高。
06/05
PHP对调用层不仅屏蔽了异步,甚至连多线程都不提供。PHP语言从头到脚都是以同步阻塞的方式来执行的。它的优点十分明显,利于程序员顺序编写业务逻辑;它的缺点在小规模站点中基本不存在,但是在复杂的网络应用中,阻塞导致它无法更好地并发。
伴随着异步I/O的还有事件驱动和单线程,它们构成Node的基调,Ryan Dahl正是基于这几个因素设计了Node。
与Node的事件驱动、异步I/O设计理念比较相近的一个知名产品为Nginx。Nginx采用纯C编写,性能表现非常优异。它们的区别在于,Nginx具备面向客户端管理连接的强大能力,但是它的背后依然受限于各种同步方式的编程语言。但Node却是全方位的,既可以作为服务器端去处理客户端带来的大量并发请求,也能作为客户端向网络中的各个应用进行并发请求。
I/O是昂贵的,分布式I/O是更昂贵的 。
Node在两者之间给出了它的方案:利用单线程,远离多线程死锁、状态同步等问题;利用异步I/O,让单线程远离阻塞,以更好地使用CPU。
操作系统对计算机进行了抽象,将所有输入输出设备抽象为文件。内核在进行文件I/O操作时,通过文件描述符进行管理,而文件描述符类似于应用程序与系统内核之间的凭证。应用程序如果需要进行I/O调用,需要先打开文件描述符,然后再根据文件描述符去实现文件的数据读写。此处非阻塞I/O与阻塞I/O的区别在于阻塞I/O完成整个获取数据的过程,而非阻塞I/O则不带数据直接返回,要获取数据,还需要通过文件描述符再次读取。 由于完整的I/O并没有完成,立即返回的并不是业务层期望的数据,而仅仅是当前调用的状态。为了获取完整的数据,应用程序需要重复调用I/O操作来确认是否完成。
epoll。该方案是Linux下效率最高的I/O事件通知机制,在进入轮询的时候如果没有检查到I/O事件,将会进行休眠,直到事件发生将它唤醒。它是真实利用了事件通知、执行回调的方式,而不是遍历查询,所以不会浪费CPU,执行效率较高。
另一个需要强调的地方在于我们时常提到Node是单线程的,这里的单线程仅仅只是JavaScript执行在单线程中罢了。在Node中,无论是*nix还是Windows平台,内部完成I/O任务的另有线程池。
06/06
请求对象是异步I/O过程中的重要中间产物,所有的状态都保存在这个对象中,包括送入线程等待执行以及I/O操作完毕后的回调处理。
事件循环、观察者、请求对象、I/O线程池这四者共同构成了Node异步I/O模型的基本要素。 Windows下主要通过IOCP来向系统内核发送I/O调用和从内核获取已完成的I/O操作,配以事件循环,以此完成异步I/O的过程。在Linux下通过epoll实现这个过程,FreeBSD下通过kqueue实现,Solaris下通过Event ports实现。不同的是线程池在Windows下由内核(IOCP)直接提供,*nix系列下由libuv自行实现。
每次调用 process.nextTick()方法,只会将回调函数放入队列中,在下一轮Tick时取出执行。定时器中采用红黑树的操作时间复杂度为O(lg(n)) , nextTick()的时间复杂度为O(1)。相较之下,process.nextTick() 更高效。
process.nextTick()中的回调函数执行的优先级要高于setImmediate()。这里的原因在于事件循环对观察者的检查是有先后顺序的,process.nextTick()属于idle观察者,setImmediate()属于check观察者。在每一个轮循环检查中,idle观察者先于I/O观察者,I/O观察者先于check观察者。
06/09
Node带来的最大特性莫过于基于事件驱动的非阻塞I/O模型,这是它的灵魂所在。非阻塞I/O可以使CPU与I/O并不相互依赖等待,让资源得到更好的利用。对于网络应用而言,并行带来的想象空间更大,延展而开的是分布式和云。并行使得各个单点之间能够更有效地组织起来,这也是Node在云计算厂商中广受青睐的原因。
流程控制:
事件发布/订阅模式相对算是一种较为原始的方式,Promise/Deferred模式贡献了一个非常不错的异步任务模型的抽象。而上述的这些异步流程控制方案与Promise/Deferred模式的思路不同,Promise/Deferred的重头在于封装异步的调用部分,流程控制库则显得没有模式,将处理重点放置在回调函数的注入上。从自由度上来讲,async、Step这类流控库要相对灵活得多。EventProxy库则主要借鉴事件发布/订阅模式和流程控制库通过高阶函数生成回调函数的方式实现。
06/10
在一般的后端开发语言中,在基本的内存使用上没有什么限制,然而在Node中通过JavaScript使用内存时就会发现只能使用部分内存(64位系统下约为1.4 GB,32位系统下约为0.7 GB)。至于V8为何要限制堆的大小,表层原因为V8最初为浏览器而设计,不太可能遇到用大量内存的场景。对于网页来说,V8的限制值已经绰绰有余。深层原因是V8的垃圾回收机制的限制。按官方的说法,以1.5 GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。这是垃圾回收中引起JavaScript线程暂停执行的时间,在这样的时间花销下,应用的性能和响应能力都会直线下降。这样的情况不仅仅后端服务无法接受,前端浏览器也无法接受。因此,在当时的考虑下直接限制堆内存是一个好的选择。
V8内存管理:
在V8中,主要将内存分为新生代和老生代两代。新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。
在分代的基础上,新生代中的对象主要通过Scavenge算法进行垃圾回收。在Scavenge的具体实现中,主要采用了Cheney算法。 Cheney算法是一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间称为semispace。在这两个semispace空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。当我们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收时,会检查From空间中的存活对象,这些存活对象将被复制到To空间中,而非存活对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。简而言之,在垃圾回收的过程中,就是通过将存活对象在两个semispace空间之间进行复制。
对象晋升的条件主要有两个,一个是对象是否经历过Scavenge回收,一个是To空间的内存占用比超过限制。
对于老生代中的对象,由于存活对象占较大比重,再采用Scavenge的方式会有两个问题:一个是存活对象较多,复制存活对象的效率将会很低;另一个问题依然是浪费一半空间的问题。这两个问题导致应对生命周期较长的对象时Scavenge会显得捉襟见肘。为此,V8在老生代中主要采用了Mark-Sweep和Mark-Compact相结合的方式进行垃圾回收。
Mark-Sweep是标记清除的意思,它分为标记和清除两个阶段。Mark-Sweep在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。活对象在新生代中只占较小部分,死对象在老生代中只占较小部分,这是两种回收方式能高效处理的原因。
Mark-Compact是标记整理的意思,是在Mark-Sweep的基础上演变而来的。它们的差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。
为了避免出现JavaScript应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的3种基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为“全停顿”(stop-the-world)。在V8的分代式垃圾回收中,一次小垃圾回收只收集新生代,由于新生代默认配置得较小,且其中存活对象通常较少,所以即便它是全停顿的影响也不大。但V8的老生代通常配置得较大,且存活对象较多,需要较多的时间。为了降低全堆垃圾回收带来的停顿时间,V8先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记(incremental marking),也就是拆分为许多小“步进”,每做完一“步进”就让JavaScript应用逻辑执行一小会儿,垃圾回收与应用逻辑交替执行直到标记阶段完成。 V8后续还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction),让清理与整理动作也变成增量式的。同时还计划引入并行标记与并行清理,进一步利用多核性能降低每次停顿的时间。
如果变量是全局变量(不通过var声明或定义在global变量上),由于全局作用域需要直到进程退出才能释放,此时将导致引用的对象常驻内存(常驻在老生代中)。如果需要释放常驻内存的对象,可以通过delete操作来删除引用关系。或者将变量重新赋值,让旧的对象脱离引用关系。在接下来的老生代内存清除和整理的过程中,会被回收释放。
通常,造成内存泄漏的原因有如下几个。 缓存。 队列消费不及时。 作用域未释放。
直接将内存作为缓存的方案要十分慎重。外部的缓存软件有着良好的缓存过期淘汰策略以及自有的内存管理,不影响Node进程的性能。 将缓存转移到外部,减少常驻内存的对象的数量,让垃圾回收更高效。 进程之间可以共享缓存。
06/11
upgrade事件:当客户端要求升级连接的协议时,需要和服务器端协商,客户端会在请求头中带上Upgrade字段,服务器端会在接收到这样的请求时触发该事件。这在后文的WebSocket部分有详细流程的介绍。如果不监听该事件,发起该请求的连接将会关闭。
除此之外,WebSocket与传统HTTP有如下好处。 客户端与服务器端只建立一个TCP连接,可以使用更少的连接。 WebSocket服务器端可以推送数据到客户端,这远比HTTP请求响应模式更灵活、更高效。 有更轻量级的协议头,减少数据传送量。
06/13
SSL作为一种安全协议,它在传输层提供对网络连接加密的功能。
Node在网络安全上提供了3个模块,分别为crypto 、 tls 、 https 。
Node基于事件驱动和非阻塞设计,在分布式环境中尤其能发挥出它的特长,基于事件驱动可以实现与大量的客户端进行连接,非阻塞设计则让它可以更好地提升网络的响应吞吐。Node提供了相对底层的网络调用,以及基于事件的编程接口,使得开发者在这些模块上十分轻松地构建网络应用。
06/14
采用第三方缓存来存储Session引起的一个问题是会引起网络访问。理论上来说访问网络中的数据要比访问本地磁盘中的数据速度要慢,因为涉及到握手、传输以及网络终端自身的磁盘I/O等,尽管如此但依然会采用这些高速缓存的理由有以下几条: Node与缓存服务保持长连接,而非频繁的短连接,握手导致的延迟只影响初始化。 高速缓存直接在内存中进行数据存储和访问。 缓存服务通常与Node进程运行在相同的机器上或者相同的机房里,网络速度受到的影响较小。
06/15
模板引擎 :
语法分解。 处理表达式。将标签表达式转换成普通的语言表达式。 生成待执行的语句。 与数据一起执行,生成最终字符串。
模板编译:
为了能够最终与数据一起执行生成字符串,我们需要将原始的模板字符串转换成一个函数对象。生成的中间函数只与模板字符串相关,与具体的数据无关。
一些模板引擎的优化步骤,主要有如下几种。 缓存模板文件。 缓存模板文件编译后的函数。 优化模板中的执行表达式 。
06/18
为了解决高并发问题,基于事件驱动的服务模型出现了,像Node与Nginx均是基于事件驱动的方式实现的,采用单线程避免了不必要的内存开销和上下文切换开销。 在PHP中没有线程的支持。它的健壮性是由它给每个请求都建立独立的上下文来实现的。
由于所有处理都在单线程上进行,影响事件驱动服务模型性能的点在于CPU的计算能力,它的上限决定这类服务模型的性能上限,但它不受多进程或多线程模式中资源上限的影响,可伸缩性远比前两者高。如果解决掉多核CPU的利用问题,带来的性能上提升是可观的。
06/20
IPC的全称是Inter-Process Communication,即进程间通信。进程间通信的目的是为了让不同的进程能够互相访问资源并进行协调工作。实现进程间通信的技术有很多,如命名管道、匿名管道、socket、信号量、共享内存、消息队列、Domain Socket等。
Node中实现IPC通道的是管道(pipe)技术。但此管道非彼管道,在Node中管道是个抽象层面的称呼,具体细节实现由libuv提供,在Windows下由命名管道(named pipe)实现,*nix系统则采用Unix Domain Socket实现。
子进程根据message.type创建对应TCP服务器对象,然后监听到文件描述符上。由于底层细节不被应用层感知,所以在子进程中,开发者会有一种服务器就是从父进程中直接传递过来的错觉。值得注意的是,Node进程之间只有消息传递,不会真正地传递对象,这种错觉是抽象封装的结果。
独立启动的进程中,TCP服务器端socket套接字的文件描述符并不相同,导致监听到相同的端口时会抛出异常。 但对于send()发送的句柄还原出来的服务而言,它们的文件描述符是相同的,所以监听相同端口不会引起异常。 多个应用监听相同端口时,文件描述符同一时间只能被某个进程所用。
06/23
Node默认提供的机制是采用操作系统的抢占式策略。所谓的抢占式就是在一堆工作进程中,闲着的进程对到来的请求进行争抢,谁抢到谁服务。
06/27
Node产品的性能与许多因素相关,这里我们将范畴缩减到Web应用中来,只评估一些常见的提升性能的方法。对于Web应用而言,最直接有效的莫过于动静分离、多进程架构、分布式,其中涉及的几个拆分原则如下所示。 做专一的事。 让擅长的工具做擅长的事情。 将模型简化。 将风险分离。 除此之外,缓存也能带来很大的性能提升。
如果进程中存在内存泄漏,又一时没有排查解决,有一种方案可以解决这种状况。这种方案应用于多进程架构的服务集群,让每个工作进程指定服务多少次请求,达到请求数之后进程就不再服务新的连接,主进程启动新的工作进程来服务客户,旧的进程等所有连接断开后就退出。这样即使存在内存泄漏的风险,也能有效地规避内存泄漏带来的影响。但这属于规避问题,只解决了问题的表象,不推荐使用。
数据冰冷的,但我们要让数据温暖起来,改变我们的生活!