Denonode.js之父RyanDahl一个月前启动了一个名为deno的项目。该项目的初衷是创建一个基于v8引擎的安全TypeScript运行时,同时实现HTML5的基本API。所谓安全运行时,就是将TS代码运行在一个沙箱中,访问受限的文件系统和网络功能,类似于web中的iframe沙箱。现阶段,deno的变化可谓翻天覆地。Ryan的项目一个月前提供了golang版deno的简单源码,现在不仅项目重构了,底层语言切换为c++,界面也有了很大的更新,源于大家的热烈讨论在社区中。太多的开发者和合作者提出了太多的优化和改进建议,这将导致未来几个月deno发生重大变化,这将在后面提到。现在,我将带领大家进入最初的deno微观世界,探索deno的原始设计。架构q这篇文章解释了deno的golang版本。最新的deno由于性能问题已经放弃了golang的实现,但这并不影响我们分析deno的原理。未来,deno有望在7月份发布基于Rust的低级特权实现,性能会更好。q由于deno直接参与了TS的运行,所以后面会用TS来指代JS(现阶段TS还没有自己的runtime,还是基于编译成JS运行在v8上)。deno的初始设计比较简单,从宏观上看,它包括三个部分:deno的goruntime,v8引擎,以及连接goruntime和v8的v8worker2库。go运行时是deno的特权级,负责deno对系统资源的申请、使用、释放;这里的v8引擎不仅执行JS代码,还负责编译TypeScript;而v8worker2负责go与v8的全双工通信,通过ArrayBuffer传输数据,传输的协议规范为protobuf。深入goruntime,目前deno为TS层提供了几个能力:Console、fetch、fs、module、timer、stacktrace,虽然有些功能没有提供用户端API,但是golang的接口已经完成,而且扩展很容易。go运行时,deno在特权代码中执行3端逻辑:初始化go运行环境初始化TS运行环境在go端启动事件循环(这个事件循环不同于node基于libuv的事件循环,后者会下文提到)初始化go运行环境//在HOME目录下创建cache和src目录createDirs()//使用afero库创建一个虚拟fs对象;同时在v8端订阅os事件,在go端O实现文件抓取、缓存获取、磁盘I/,并将proto序列化后的数据返回给v8InitOS()//HeartbeatInitEcho()//接受v8消息,执行超时、间隔和清除InitTimers()//订阅获取事件,代理服务器。当代理请求结束时,返回两条消息:第一条是状态码;第二个是bodyInitFetch()//recv是v8->go的回调函数,处理v8消息worker=v8worker2.New(recv)//初始化ts的相关环境,对应go端main_js=stringAsset("main.js")err:=worker.Load("/main.js",main_js)exitOnError(err)依次执行以下任务:-创建Cache目录,用于存放从TS文件编译后的JS文件-订阅os事件处理来自v8层的操作,如fs等-订阅timer事件处理来自v8的定时器操作-订阅fetch事件处理来自v8的http请求-初始化v8worker2实例,实现go与v8的绑定-加载js入口文件main.js,其中定义了js的全局接口、初始化逻辑以及与go运行时通信的方法,等待下一阶段的执行。初始化js运行环境//v8端执行denoMain函数,在main.ts中定义deno.Eval("deno_main.js","denoMain()")。上一步v8已经加载并执行了main.js文件,现在执行denoMain方法。denoMain是main.js中定义的初始化方法。它在js层和v8worker实例中定义了deno的API,也是与开发者密切相关的一层。关于ts层的逻辑留在下面。启动事件循环varresChan=make(chan*BaseMsg,10)vardoneChan=make(chanbool)varwgsync.WaitGroupwg.Add(1)first:=true//在一个goroutine中,我们等待所有的goroutines完成(例如//计时器)。我们用它来通知主线程退出。//wg.Add(1)基本上转换为uv_ref,如果这是Node。//wg.Done()基本上转换为uv_unrefgofunc(){wg.Wait()doneChan<-true}()for{select{casemsg:=<-resChan:out,err:=proto.Marshal(msg)check(err)err=worker.SendBytes(out)stats.v8workerSend++stats.v8workerBytesSent+=len(out)exitOnError(err)wg.Done()//对应Pub()中的wg.Add(1)。case<-doneChan://所有goroutine都已完成。现在我们可以退出main()。checkChanEmpty()return}//我们没有想要退出,直到我们收到至少一条消息。//这样程序在发送“开始”//消息后不会退出。iffirst{wg.Done()}first=false}熟悉go语言的人会发现,这是coroutinegoroutine的一个典型用法:主协程开始循环,监听resChan通道传来的消息。当收到resChan的消息时,说明goruntime此时需要返回相关数据给v8,比如timer执行结果,网络请求结果,执行相应的selectcase,将protobuf处理后的数据通过v8worker2写入,并进入下一个循环;直到go运行的这一刻所有ts请求都被处理完,才会执行协程doneChan中的逻辑<-true,最终触发主协程case<-doneChan的case,结束事件循环,退出程序。因此,deno的golang版本的事件循环与节点基于libuv的事件循环是不一样的,所以它们不能一概而论。TSruntime和v8worker2TSruntime对应v8instanceisolate,定义了handscope、context和handscope范围内的一系列handle对象。TS运行时的初始化配置在v8worker2中定义。在v8worker2中,go与c的通信是通过cgo模块实现的:go可以调用c库,同时go函数可以被c程序处处使用。在本文中,这不是故事的重点。感兴趣的同学可以等待下一篇文章的介绍。简而言之,TS运行时的初始化是由go的v8worker2模块完成的,它将global全局变量暴露给v8,并为v8与golang的通信提供全局变量下的V8Worker2对象。TSruntime初始化完成后,需要在TS层准备Deno的执行环境,包括:初始化timer事件,监听goruntime返回的timer事件,用return初始化fetch事件事件对象中TS调用计时器的结果。在event对象中,有ts请求net和fs返回值,订阅start事件,等待deno程序执行。在启动事件处理函数中,deno做了两件事:编译ts源文件和执行js文件deno使用了typescript模块提供的LanguageServiceHost函数,使用硬编码的编译规则ts.ModuleKind.AMD,outDir:"$deno$",inlineSourceMap:true,lib:["es2017"],inlineSources:true,目标:ts.ScriptTarget.ES2017};默认使用es2017规范,模块规范使用AMD规范。ts模块加载目前ts模块加载支持fs和nfs,即“相对路径加载和网络加载”,如import{printHello}from"./subdir/print_hello.ts";import{printHelloNfs}from"http://localhost:4545/testdata/subdir/print_hello.ts";printHello();printHelloNfs();如何将TS模块转换为AMD规范以及如何确定加载顺序,以下示例说明:有两个ts文件:a.ts和say。tsa.ts:importsayfrom'./say';说('你好世界');------------------say.ts:exportfunctionsay(msg){console.log(msg)}执行命令denoa.ts并返回“helloworld”。在ts运行时编译后,a.ts的编译代码为:define(["require","exports","./say.ts"],function(require,exports,say){"usestrict";Object.defineProperty(exports,"__esModule",{value:true});say(msg);});其中回调函数的require参数是一个简单的require实现,exports是a.ts模块的导出对象,say模块是say.ts的导出对象。对于“./say.ts”文件,ts运行时通过v8worker2传递消息,go运行时获取对应的源文件(这里通过fs或net),通过ArrayBuffer传递给ts运行时,编译和运行它。传递给引用的模块a.ts。最后,当所有依赖的模块加载完成后,执行a.ts的回调函数,实现模块间的定时调度。q关于模块加载的问题,社区有异议,即增加绝对路径的引用方式:import"/abc/test.ts"。不过Ryan认为,这种绝对路径方式会与系统根目录发生冲突,不符合deno提出的“安全TS运行时”,会暴露系统的路径或文件信息。不过社区也提出了一个解决方案,就是在deno运行时提供命令行参数--baseDir来标识当前deno进程的根目录,防止访问系统的文件系统。有争议的v8worker2和protobuf其实deno的golang实现最受诟病的还是v8worker2和protobuf。这两个模块非常有名,但是并不适合deno场景。首先说说protobuf,它是Google提出的一种跨平台、跨语言的结构化数据存储格式。它是按类型声明的。protobuf的命令行工具可以生成不同语言的代码,操作相应的数据结构。但是protobuf的性能瓶颈在于序列化和反序列化,这也是为什么deno项目下protobuf的作者之一Ryan推荐使用Cap'nProto进行数据传输的原因。Cap'nProto更有趣。它使用ArrayBuffer进行传输,不需要序列化成对应语言的相关变量。它直接提供了一组读取二进制数据的方法(类似于用于访问数组的偏移量),速度更快。对于v8worker2模块,作者已经通读了这个绑定实现。其实Ryan已经尽可能的优化了v8worker2,但是v8的snapshot特性并没有开启,重复引入的模块会有一定的性能损失。但是最主要的瓶颈其实是v8worker2依赖的cgo模块。cgo对c库和编译器的支持很好,但是数据类型的转换非常消耗性能。下图是社区对deno的golang版本做的goruntime的性能分析:可以看到v8worker2的SendBytes和Load的执行率都超过了70%。这两个函数的主要逻辑是使用cgo完成数据传输和TS执行。社区也介绍了相关的cgo性能瓶颈,即go中的协程goroutien不同于OS线程,具体实现取决于GOMAXPROCS设置和调度策略。一旦通过cgo进行c语言的系统调用,就会导致当前goroutine所在的线程休眠,直到调用返回。那么在当前线程上运行的其他goroutines就会被阻塞,导致性能下降。因此,Ryan的下一个版本也将放弃使用go的v8worker2模块。golang版deno的生命终结,终于到了这个话题。golang实现的deno现在已经废弃了。这是性能问题造成的:与c/c++的绑定性能差,是cgo模块造成的,直接导致deno的golang实现tps小,rt比较大,导致golang的GC导致性能不确定机制。目前v8采用了标记排序清晰的GC算法,golang运行时也运行了类似的GC算法,这样在多线程中有两个并行的GC线程,这会给运行Rust的程序带来很大的不确定性社区随着力量的增长,Rust的服务器性能越来越强,而且没有GC机制,与c的通信性能比golang高,所以也是一个驱动因素。不过虽然golang版本的deno已经告一段落,但是通过Ryan的实现我们还是可以很容易的掌握deno的脉络,所以对相关开发者还是有参考和参考意义的。Deno的未来与感慨从目前社区内部的讨论和Ryan的决定来看,Deno在7月份还有较大的变化:底层代码将切换到Rust,使用libdeno作为Rust和C的绑定。还是很活跃的,各种想法和想法相互碰撞,模块管理和加载,API设计,v8编译TS优化等等。这个时代,我们要跟上潮流,学习这些潮人的想法和设计理念。之前作者非常专注于node的摄入挖掘和应用,但是自从deno发布之后,给作者的震撼是无法用语言来形容的。所以,学习golang,看v8文档通读deno,努力走出自己的舒适区,感受墙外的先进思想,在碰撞中学习,求同存异,收获颇丰。最后,不知道是不是国内相对封闭的互联网环境导致了国内的前端或者全栈思维有点死板,无法产生和领导这么非常有趣的想法和项目。当然,也有可能是我们每天忙于业务需求,不能自拔。希望国内的开发者能够做到并珍惜,不要被国外同行甩在后面太多。
