本章节,我们来借助一张图(版权归图片作者),来研究和说明一些nodejs系统的架构。
本来笔者在学习和研究这方面的内容的时候,本来主要专注在右半边的内容,但看到这张图后,觉得这个架构更加完整和清晰,就改用新图来进行说明。
按照图上的内容,一个完整的nodejs系统,由以下几个部分构成:
V8(JavaScript Engine)
V8,本来是Google为其浏览器产品Chrome开发的高性能JavaScript代码执行引擎。V8的原意是V型排列的8缸发动机,它通常排量巨大,马力强劲。Google用其作为JavaScript执行引擎的产品名称,表达了对此产品的期望和信心。就如V8引擎驱动高性能跑车一样,能够给浏览器提供强大的前端程序执行能力,从而将浏览器从简单的HTML页面呈现工具,进化为真正的Web应用程序平台。
V8的主要功能是解释并执行JS代码。这个功能当然在浏览器中很早就有,但了解浏览器技术发展的人都知道,由于微软的垄断和保守,在相当长的一段时间内,前端编程技术发展都非常缓慢。JS虽然在浏览器中也勉强算是一个编程语言,但无论是其功能、性能和应用方面,都十分薄弱,根本无法满足Web应用发展的需求。很多开发者也了解这个情况,也没有敢去想用JS去做很复杂的应用。那时候在前端,主流的复杂应用开发技术,就只有微软的ActiveX、Adobe的Flash,Sun的Javalet等(不提也罢),但它们都有各种问题,不支持多种操作系统和浏览器,兼容性差,性能低下,体验很差等等,都很不理想。
Google作为一个典型的互联网技术公司,其商业模式、核心竞争力、提供的产品和服务都是基于互联网的,它没有传统在操作系统和桌面应用软件的包袱,也有很大的理由和动力努力推动互联网技术的发展。可能是这个企业基因的问题,也可能是不满足于现状,或者是在这里看到了新的机会,Google决定开发一个新的浏览器软件,让浏览器技术发展成为可以承载Web应用的平台和基础。
这实际上是对微软的又一次挑战! 要达到这个目的,google的浏览器产品就需要在性能、安全和用户体验上大幅度的超过微软。Google的策略非常巧妙,它深知这个体系的庞大和复杂,并没有选择完全另起炉灶,而是把主要切入点放在了JavaScript代码的执行效率之上,这里的核心产品就是V8。
借助其在软件架构、编译器、性能优化、工程、人才和经验方面的优势,google在V8产品上投入了当时几乎所有相关的最新软件开发理念和多种优化技术,包括JIT实时编译,高效垃圾回收,编译优化,内联缓存,隐藏类,CPU特性优化,常驻后台线程等。借助这些优化技术和方案,V8在代码解析和编译速度,内存占用,启动时间,垃圾回收停顿,JSON解析速度,JS特性支持,程序稳定性和健壮性等方面,明显超越同一时期的竞争对手。和那个时间点的主流产品如IE相比,相关的浏览器性能的基准测试指标,Chrome一般都能够领先数倍。这些特点都让Chrome可以很好的支撑像Gmail这种复杂的Web应用,并获得良好的应用体验。从2008年正式发布开始,到现在从此Chrome浏览器的使用率就一路走高,到现在差不多有65%左右的份额,已经成为最为主流的浏览器产品,作为开源产品,其相关技术也成为最为主流的前端Web应用开发和支撑技术,包括V8在内。
看到V8在浏览器世界中的表现过于优异,就激励有人脑洞大开,天才的将JS和V8移植到后端环境的应用场景之中,就诞生了nodejs这个应用开发平台。
Node.js Bindings(Node API)
作为一个现代化的应用开发和支撑平台,仅有V8对于JS的执行支撑,是远远不够的。需要在此基础上实现和配套丰富的功能,才能作为一个完善的开发支撑系统。
Nodejs主要是为服务端Web应用的开发而设计的,所以相关的配套大多数都是围绕着这个目标设计和发展的。比如功能性的net、http、udp、fs、crypto、cluster、 child-process、streaming、timers等模块,还有更好的开发支持模块如debug、REPL、inspector、cli-options等等,它们统称为Nodejs Bindings(套件)或者Node API,可以看成是nodejs的核心生态系统。
这些模块的实现,有些是基于第三方库实现的,如crypto就是基于OpenSSL这个算法库实现的,有些是自己实现和封装的,但它们都可以统一提高JS的API,可以使用js代码来进行直接执行和调用。
当然,在Nodejs API之外,还有一个更大的生态系统-npm,又可以对Nodejs进行扩展和外延,真正形成一个开放、极为丰富的(现在是可以这么说的)、强大的Web应用开发平台和社区。
Libuv (Aysnchronous I/O)
Libuv,是一个异步程序执行和调度框架。这个框架,主要由事件队列、事件循环和工作线程三个部分构成。它们结合起来,执行某一个程序调用的过程,并称为事件驱动的(Event Drive),非阻塞(None Blocking)的异步输入输出(Asynchronous I/O)。
- Event Queue (事件队列)
在Libuv执行框架当中,所有来自V8的方法和程序调用,都会被转化为调用事件,推入事件队列当中。这个队列中的任务事件,会在事件循环中,依次得到检查和处理。
- Event Loop (事件循环)
事件循环维护一个持续循环的检查机制,不停的检查事件队列中的内容,如果发现有新的调用事件,就会将它取出,然后根据此事件的相关信息,发送给对应的工作线程进行执行,此为事件驱动;然后循环程序不会等待这个执行完毕,会立刻进入下一个循环进行检查和处理,此为非阻塞,因为它不会影响到其他调用和程序的执行。等到工作线程执行任务完毕之后,它会使用类似的方式,将执行结果作为事件推入事件队列,直到事件循环可以将这个结果取出,并且返回给调用方,整个过程就是一个异步的输入和输出过程,也被称为回调(Call Back)机制,笔者理解都是类似的概念。
libuv的事件循环通过计时器(timer)来实现,它可以执行立即执行或者定时执行的事件。这里这个"循环"其实是为了方便理解。在真实的执行环境中,现代高性能CPU的事件循环速度非常之快,在没有阻塞的情况下,这个循环都可以达到"并发执行"的错觉。
- Worker Threads (工作线程)
工作线程就是具体负责执行指令的线程。它从事件循环接收到任务和指令并开始执行。工作线程由libuv负责管理和调度。通过工作线程,livuv可以执行不同的具体的任务和类型,通常都是比较耗时或者不能确定执行时间的任务,比如文件操作、网络数据传输、数据库操作、长时间的密集计算、外部程序调用等等。
其实这种回调的机制,并不高深和复杂。现实生活中就有很多常见的情景可以说明这个机制,比如餐厅点餐和就餐就是一个典型的例子,我们大多数都有类似的经验。我们到一个餐厅,找到位子坐下后,招呼服务员点餐;如果只有一个服务员(其实就是Event Loop),他会依次记录点餐内容后(Event Queue),将点餐内容发送给厨房(Worker Thread)进行备餐;每份餐点的内容不同,所需要的准备工作和流程也不一样,厨房也可能将其分配给不同的厨师进行处理,最终的菜品产生的过程和时间也会有差异;当厨房准备好菜品之后,会再次通知服务员,就可以为客户上餐了(Call Back);这样,虽然服务员只有一位(单线程),他也能够高效的处理好很多客户的用餐需求,因为他只需要记录客户的需求和状态,而且不需要等待厨房备餐,主要是记录、组织和分配的工作,可以快速的完成。等待和无效的工作很少,整个系统的资源都得到了很好的利用,就达到了效率的最大化。
libuv这个名字有点让人费解。
查看相关的文档后,笔者发现,其实它有点像V8,也是一个相对独立的功能模块。其作者解释这个名字并没有太明确的含义,但一般认为其来自Unicorn Velociraptor,意为独角兽迅猛龙(logo),取其"快速","锋利"的含义。另外,笔者认为,libuv存在的意义是,虽然各个操作系统平台上都有相关的异步执行程序库(epoll, kqueue, IOCP, event ports等),但libuv可以将它们很好的封装起来,提供给上层程序使用,从而实现了nodejs应用程序的良好移植性和在不同操作系统平台上都能够有很好的性能表现。
Crypto和Zlib
除了V8和自己开发的相关Nodejs API之外,nodejs还内置集成了一些成熟的第三方程序库。其中最重要的包括密码学算法库crypto(基于OpenSSL)和信息压缩算法库zlib。这不是本文要讨论的内容,作为读者只需要了解这个信息就可以了。
组织结构
其实,文中开头的架构图,主要是从程序执行的角度和流程来阐述nodejs系统的设计的。下面补充一个从模块和代码角度来表示的nodejs系统的组成:
可以看到,从实现和组织的结构来看,nodejs基本上是一个"混合"的结构。基本上由底层和应用层两个部分构成。底层的各个模块由C/C++语言编写,结合时间驱动的异步I/O模式,并借助V8执行引擎,可以提供很高的执行性能和灵活性;并通过Node Bindings扩展库,提供了底层的网络、文件系统和操作系统调用之间的互操作性;上层是JavaScript程序接口和部分功能的实现代码,提供了和前端开发一致的开发体验。这样,前后端可以使用相同的开发语言,遵循一致的程序执行和设计逻辑,秉承一致的代码规范和结构,来开展应用系统的开发工作,可以大大的提高程序设计、开发、部署、应用和运维的整体效率。
那么,为什么要这样设计?
笔者认为,和一般的传统应用,在操作系统内部,这个比较稳定的执行环境相比,Web应用或者说网络应用,所面临的环境更为复杂,它们兼容性和不可预测性的问题更大。就需要一个模块化的,可高度解耦,可故障隔离的系统架构,同时要使整个系统的效能最大化,就是充分利用资源和时间。
此外,和浏览器的执行环境相比,nodejs针对Web应用服务和后端程序的应用场景,也会导致其在设计和实现上有很大差异。它会更多的考虑和操作系统、网络、文件、第三方应用SDK和API等方面相互操作的问题。作为需要同时为大量客户端提供服务的一方,它还会更多的考虑执行任务的多样性、并发性、资源利用的有效性、功能的丰富和可扩展性等等。
另外,随着Web应用和发展的逐渐成熟,相关Web应用开发的底层技术架构和模型相对已经固化下来。比如经典的前后端分离、MVC模式、微服务、网络协议、密码学体系、数据库支持、程序包管理等等,现代化的成熟的开发体系都会慢慢形成相对稳定的模式和组合,无需开发者自行开发或者进行外部的集成。比如Web服务和客户端,nodejs系统其实就已经进行了基本的内置,密码学库更是引入了强大而完善的OpenSSL,原生的json支持等等。这应该也是现代化开发技术架构发展的一个方向。
所以,总而言之,从设计之初,nodejs就是为了更好的支持现代化的Web应用和服务的开发而设计的,其功能模块的组成、技术选型、发展方向、执行方式、流程架构、包括附加的支持功能,都是为了这一目的而存在的,而且它做的很不错。
关于单线程
在多CPU/超线程技术和多线程编程普及的时代,nodejs却设计和实现了单线程的工作模式,其实是有其独特的见解和考量的。
第一,从计算机系统底层的工作原理上来看,对于一般的执行程序,即便使用了多线程,在最终的执行过程上,还是顺序执行的,我们感觉到的并发操作,只不过是由于现代计算机性能强大,能够快速的在多个任务之间进行切换操作,某种意义上造成了一种并发执行的"错觉";其次,同时进行"并发"处理,是有代价的,包括线程竞争、锁、中断、上下文切换、现场保护等等,都会占用计算和内存资源;再者,从常识和基础方法论上,"专注",才是提高效率最简单直接的方式,就是让程序能够不受干扰的运行完成,这时的速度和效率是最高的;而且,在这种模式下,提高性能也是比较简单的,只需要提高处理器频率,就可以达到几乎线性提升的效果;最后,从应用和需求的角度而言,用户其实并不关心其底层实现原理和机制(可能在商业宣传上需要,:)),最终体现在用户体验上的才是有效的,这一点上,nodejs系统在各个方面的表现都比传统的开发和应用模式要好。这并不是孤例,其实在现在很多高性能硬件系统的设计,也体现了这个思路。比如USB和SAS系统等。
根据以上的思路,nodejs的执行架构被设计成为了"单线程"的模式。在启动后,每个nodejs应用程序的进程,都只有一个主线程在运行。在主线程中执行 JavaScript 代码,此线程负责事件循环,非阻塞地处理请求和事件回调。主线程通过轮询的方式检查是否有回调待执行,如果有则执行。
看到这里,肯定有人有疑问,那么现在的计算机系,都是多CPU的,只用一个线程,那不会造成很大的浪费吗。这就可以说到nodejs提供的另一个线程模式-cluster群集了。简单而言,就是nodejs可以让程序在进程模式下,使用同一份代码执行程序,它们虽然在逻辑上相互隔离,但可以使用主进程来进行协调和相互通信,这一就可以充分利用到多个CPU计算资源了。关于这部分的技术细节,笔者会在相关的主题文章详细讨论。
缺陷和隐忧
前面已经提到,由于异步I/O的设计模式,nodejs是比较适合于Web应用这种I/O密集型的应用场景的,体现在其启动速度快,日常工作状态下资源占用少,和并发性能高等等。但从另一个方面而言,这种模式,加之其解释性语言的模式,可能就不太适合于原生就为多线程工作而设计的计算密集类的应用了。
另外,随着nodejs开发社区的膨胀式发展,其程序包管理也越来越复杂。原有的npm包管理的机制也面临一些挑战。这一点nodejs开发者多少也会有所体验,经常遇到node modules文件损坏,包版本依赖冲突,模块版本升级不兼容等问题,网络上有一个有趣的图片对此进行了吐槽:
关于这一点,笔者倒觉得随着对nodejs开发的熟悉和开发流程的成熟,问题不是特别大。还有可以提供的一些经验是:
1 尽量减少第三方库的集成和引用,非常简单功能(如某个转换函数)干脆就自己实现
2 尽量选择使用广泛,声誉卓著的外部库,避免偏门冷门的代码实现
作为一个相对比较全面的Web应用开发体系,在类似的技术模式下,nodejs还没有直接的竞争对手。当然Java和DotNet也在向异步执行模式发展,但那毕竟是完全使用另外一种编程语言。这样可能会影响nodejs的进取心和发展(看看那个万年Java8吧,虽然笔者没有看到相关的迹象)。
可喜的是,市场上已经出现了一些苗头,比如两个类似的产品: Deno和Bun。虽然它们出现的时间太短,没有经过时间和广泛应用的历练,社区也比较薄弱,但不妨碍给大家带来一些惊喜。Deno主打简便的包管理、代码安全、TS支持;Bun更是主打全面和性能。它们都是值得关注的技术发展方向。
小结和要点
这里总结一下相关的要点,来帮助读者能够更好的理解nodejs的技术架构:
- nodejs是为web应用开发而设计的
- nodejs程序的三个要点是:事件驱动、单线程事件循环、非阻塞异步IO
- V8虽然是nodejs的技术基础,但在逻辑上是相互分离的,这两个产品也是由不同的实体负责
- V8负责js代码的解析,执行和优化
- 异步执行架构并不是V8提供的,而是由libuv提供的