- Node.js:来一打 C++ 扩展
- 死月
- 4462字
- 2020-08-27 17:13:37
2.1 为什么要写C++模块
在写C++模块之前,我们有必要问自己一个问题:为什么要写C++模块?用JavaScript原生模块不行吗?
这里笔者大致从两个方面来回答这个问题。
2.1.1 C++比JavaScript解释器高效
其实笔者想了很久也没太想明白怎么给本小节取节名。因为市面上大多数JavaScript解释器就是用C++写的,所以这个标题也不是特别稳妥。
笔者对此做一个补充,就是相同意思的代码,在JavaScript解释器中执行JavaScript代码的效率通常比直接执行一个C++编译好后的二进制文件要低。
当然这不是绝对的,本节特指那些非并行、计算密集型的代码。因为模型的不同,单线程下实现C++的Web请求处理和有着异步I/O优势的Node.js下实现的Web请求处理也是不一定能相提并论的——Node.js底层偷偷用了别的线程。
1.NBody
这里从The Computer Language Benchmarks Game(简称CLBG)中摘一段Node.js和C++对于同一个问题编码执行之后的性能对比。题目就是NBody1。以下几段代码为遵循BSD协议的开源代码,在此附上开源协议链接:https://benchmarksgame.alioth.debian.org/license.html。
(1)问题背景
不同语言使用相同的简易辛积分器来模拟类木行星的轨道。
在CLBG里面对于该问题给出了Java版的样例辛积分器代码,供其他语言临摹。
该问题将测试当N为50000000情况下程序的执行时间。
(2)摘录一份C++代码
该代码在四核Ubuntu下,以G++6.3进行编译,编译命令如下:
编译完成之后使用下面的命令执行:
程序的执行时间约为9.67秒。
(3)摘录一份Node.js代码
将该Node.js代码放到同样的Ubuntu机器下,使用7.9.0版本的Node.js执行。
其执行时间约为27.76秒。
(4)对比结果
在9.67秒和27.76秒的对比下,我们很容易能发现C++在该题目的对比下完胜Node.js。实际上类似的C++完胜结果在CLBG里面有很多,读者可以自行去https://benchmarksgame.alioth.debian.org/查看。
这时可能很多读者会问:既然C++效率那么高,为什么不整个项目都用C++写呢?其实这也是不科学的。C++的效率固然高,但其所需要的维护成本和开发效率与Node.js也不在一个层次上。所以我们也需要在一定程度上权衡项目的开发效率、维护成本以及性能才行。所以在一个项目中,整体使用Node.js来完成,偶尔在里面维护一两个使用C++写的扩展,这也是一个非常奇妙的体验。
2.正则趣事
有时候C++写出来的包虽然在性能上稍稍高于Node.js用JavaScript写出来的代码,但这高出来的性能如果还抵不过Node.js打开并执行C++扩展时所消耗掉的I/O时间或者在V8数据结构与你自行设计的数据结构之间进行转换所消耗的时间(常见的一种情况就是要将一个大的JavaScript对象遍历并转换为你能用的一个数据结构),这个时候用C++反而有点得不偿失了。
在CLBG里面有一道关于正则表达式的题目,地址是https://benchmarksgame.alioth.debian.org/u64q/regexredux-description.html,该题目的大意是,在不同语言下使用一些相同的简易正则表达式去操作一个从文件读取的结果。
我们发现最快的是C语言,为1.45秒;Node.js的结果也比较靠前,是4.15秒;而C++的结果是17.14秒,让人意想不到。
不过待我们冷静下来分析一下之后,发现C语言那版作者用的是PCRE这个正则表达式解析库里面的正则表达式,Node.js用的是V8带的正则表达式解析库Irregexp,而C++版本的作者用的则是boost::regex的正则表达式解析库。
从一个较早期的性能对比报告来看,大部分情况下这3种正则表达式解析库的性能从高到低依次为PCRE、V8到boost::regex,如表2-1所示。
表2-1 3种正则表达式解析库的性能对比(秒)4
4 数据来源为Benchmark of Regex Libraries:http://lh3lh3.users.sourceforge.net/reb.shtml。
这里笔者为表2-1的几个正则表达式做一个解释。
① URI:([a-zA-Z][a-zA-Z0-9]*)://([^/]+)(/[^]*)?
② Email:([^@]+)@([^@]+)
③ Date:([0-9][0-9]?)/([0-9][0-9]?)/([0-9][0-9]([0-9][0-9])?)
④ URI and Email:([a-zA-Z][a-zA-Z0-9]*)://([^/]+)(/[^]*)?|([^@]+)@([^@]+)
所以一份代码是否使用C++更为高效只是站在一个宏观的角度进行剖析。在某些情况下或者由于实现的问题,C++的效率可能不一定比Node.js高。不过通常情况下,在一些计算密集型的代码实现上,Node.js的C++扩展的效率会比使用JavaScript来实现要高。
2.1.2 已有的C++轮子
还有一种比较常见的使用C++扩展的原因是市面上或者手头已有一套C++的轮子,且用Node.js重新实现一遍非常麻烦或者不现实,这个时候就可以基于这个轮子包一层C++的扩展了——当然前提是你的项目主体本身就是Node.js,否则就是多此一举了。
1.阿里云ONS
这里说一个笔者自身遇到过的例子,那就是阿里云ONS了。阿里巴巴内部之前就有一套从Java源码仿制过来的Node.js客户端SDK,不过久久未向外界发布。后来阿里巴巴官方于2014年白色情人节3月14日发布其ONS的第一个版本Node.js客户端SDK。不过,这不是重点。
重点是ONS本身的协议(也就是RocketMQ)并没有一个很好的文档,也没有开源的ONS客户端源码可以参考,因为纯仿制RocketMQ SDK代码的话会有小部分兼容上的问题——ONS有一些额外的内容存在。
既然其一没有协议文档,二没有开源的其他语言可仿制代码,这个时候怎么办呢?
于是笔者很机智地将目光放到了其官网上公布的C++SDK——它由一堆头文件、一份文档和两个平台的静态链接库组成。瞧,这不就可以开工了吗?
在官方的Node.js版本ONS SDK出来之前,笔者自己造了一个基于其官方C++版本的ONS SDK封装的轮子,用的当然是本书所讲的姿势——Node.js的C++扩展了。
有兴趣的读者可以到该SDK的仓库进行围观:https://github.com/xadillax/aliyun-ons。而且截至笔者编写本书之际,通过对比两个库暴露出来的API,可以看出笔者实现的ONS SDK中生产者有生产顺序消息的能力,而官方SDK在最底层虽然有预留逻辑,但是并未在使用者层面上暴露出来。这个优势得益于其C++的SDK实现了对顺序消息的支持。
2.Bling Hashes
这里还有一个例子,与上面ONS的情况略不一样。
Bling Hashes是笔者以前为了在Node.js实现一套计算字符串哈希的C++扩展。其算法是在ACM界中常见的哈希算法。网上有很多人都会收集这样的代码作为自己的备用代码库。
当时笔者实现的时候直接摘抄了ByVoid博客上收录的一些ACM常用字符串哈希函数,并对其进行C++的封装。ByVoid为这些算法进行了一次评分,其结果如表2-2所示。
表2-2 常用字符串哈希函数评分3
3 表2-2以及本节所涉及的未特殊说明的字符串哈希算法均摘自ByVoid的个人博客:https://www.byvoid.com/zhs/blog/string-hash-compare。
表2-2进行评测的原则如下:
数据1为100000个字母和数字组成的随机串哈希冲突个数。数据2为100000个有意义的英文句子哈希冲突个数。数据3为数据1的哈希值与1000003(大素数)求模后存储到线性表中冲突的个数。数据4为数据1的哈希值与10000019(更大素数)求模后存储到线性表中冲突的个数。
经过比较,得出以上平均得分。平均数为平方平均数。
为了讲解方便,书中给出其中评测分最高的BKDRHash算法的代码。
在C++中我们能比较随意地借助数据边界溢出的特性,比较方便地进行乘法操作和加法操作来截取某个边界值,并且能比较方便地使用位运算进行某位的修正。
而如果上面的代码使用JavaScript实现,那么我们就免不了要非常细致地了解ECMAScript规范以知道各符号操作后的结果,尤其是数据边界的情况(笔者相信其实很多人虽然了解ECMAScript的大致情况,但是要具体到某一条规范的时候仍需要翻阅ECMAScript规范文档,将其作为工具书来用)。而且ECMAScript中所有的数值都是浮点数,哪怕是执行parseInt之后在接下来的使用中还是会在底层变回一个浮点数。并且实际上执行parseInt返回的结果并不是无符号整型的,也就是说边界值也不一样。这给我们将上述代码转用JavaScript来实现制造了困难,哪怕不是困难,也会消耗一些脑力来考虑如何转换。
还有一个需要考虑的问题,那就是就算你转了一个函数,这里却还有许多函数等着你去转。
直接站在巨人的肩膀上使用公开的源码不好吗?一定要从头造吗?于是——使用Node.js的C++扩展来封装吧,把上面的函数原封不动地复制到你的代码中并很有良心地注明出处,然后包一层扩展来完成字符串哈希包的实现吧。
什么?这样还不能说服你?那笔者要“祭”出在Bling Hashes包里面的另一种算法了——出自谷歌之手的CityHash:
CityHash是一系列的非加密哈希函数,是为了快速计算出字符串哈希,其效率在64位CPU上得到了特殊优化。在工业级应用中可以使用该哈希算法。不过其缺点是代码比同类流行算法复杂。
在这种情况下照抄一遍CityHash那么长的代码还得看不少论文,并且在转换成JavaScript代码时在数值边界和处理上还有非常多的“坑”,这时用它原来的代码进行C++封装是再好不过了。
事实上Bling Hashes这个包里面就是这么做的。除了包含上述流行的一些字符串哈希算法之外,其还包含了CityHash32和CityHash64。别问笔者为什么这里没有CityHash128——JavaScript原生能支持到这么大的数值吗?当然,实际上我们能用包含两个Number的数组进行返回,因为在CityHash底层也是这么干的。
//CityHash128的返回值定义
typedef std::pair<uint64,uint64> uint128;
最后,这里给出大家期待已久的Bling Hashes代码仓库地址:https://github.com/XadillaX/bling_hashes。
通过阿里云ONS和Bling Hashes的例子,我们就能很容易想象到这样的场景了:一个库要么代码太复杂,使用JavaScript完全实现一遍不大现实;要么其根本就没开放源码,但是恰巧都有C++(或者C语言)实现的版本(无论是头文件加链接库的形式还是纯源码的形式)。这时为了完成任务或者节约开发成本,就需要使用Node.js的C++扩展了。
3.扩展阅读
最后这里再讲两个笔者以前写的原生扩展,造这两个轮子的理由与上面说的差不多,不过更大的原因是使用JavaScript难以实现——如系统底层的API等。
(1) Sync Runner
在早期的Node.js版本中,子进程的创建和执行是不支持同步的,这就导致了我们在执行某些子进程命令行时使代码结构复杂。
实际上Node.js异步没错,但是也要分场合。如果仅仅是为了写一个命令行的程序,在里面需要获取当前环境的Git版本,那么我们就会想要自己能写一份这样的代码。
很遗憾,在早期的Node.js版本中这样的写法是不存在的,我们必须使用Child Process的异步API启动子进程,然后在回调函数中获取其结果。
当年为了在Node.js中使上述代码成真,笔者就写了这个Sync Runner。虽然就现在来说已有childProcess.execSync等API能支持同步启动子进程了,但在之前没有该API的时候,这个原生扩展还是非常有用的。
有兴趣的读者可以访问https://github.com/xadillax/syncRunner获取更多相关信息。
(2) Real Homedir
这个包的出现源自一个环境变量出现的Bug。
事情的起因主要是一个Java开发人员通过sudo-u admin来执行基于Node.js实现的服务挂了,然后在各种调试之后,终于找到了问题的所在。
那个Node.js程序会在用户路径下记录Log文件,而这个用户路径是由process.env.HOME获取到的。很神奇的一点是,通过它获取到的用户路径依然是原用户的路径,而不是期望中的/home/admin,于是就出现了权限问题,导致写入失败。
接下来看了一下sudo-h里面对-u的解释:
那么,问题又来了,这个命令(sudo-u admin)和真正切换到该用户下(sudo su admin)去执行命令有什么区别呢?是不是因为这些区别才引起了一些奇怪的权限问题呢?
首先来试一下sudo-u xxx。由于本地计算机上没有其他的用户,因此直接用root代替了。
可以看到这里的HOME字段并不是/var/root而是/Users/当前用户,用了root的权限来执行node,但是其类似HOME字段的环境变量仍未被改变,还是在当前状态。
——摘自阿里巴巴Node.js工程师芙兰的博客
后来笔者在尝试使用os.homedir()的时候发现结果也不是预期的,原因在于os.homedir()的底层实现中用的是libuv来获取用户目录,而libuv的相关方法会先去环境变量获取用户目录,这导致了其非预期的结果。当时Node.js的版本还没有6,没有os.userInfo()这个API。实际上Node.js 6的这个API也是因为笔者当时在Node.js的Git仓库提交了相关的Issue才加上去的。图2-1就展示了在UNIX下libuv的uv_os_homedir()函数流程图。
图2-1 UNIX下libuv的uv_os_homedir()函数流程图
在Node.js添加os.userInfo()之前,笔者临时用C++写了原生扩展来规避这个问题。
实际上其原理很简单,就是复制了一遍libuv获取用户目录的方法,然后删除了该方法最开始的使用环境变量作为用户目录的一个分支条件。
有兴趣的读者可以访问https://github.com/BoogeeDoo/real-homedir获取更多相关信息。
2.1.3 小结
本节内容说明了使用C++来写Node.js原生扩展的两大理由——性能和开发成本。
对于有显著性能提升的情况,使用C++来完成这一项任务,我们何乐而不为呢?而对于已存在的C++类库,那些难以迁移或者无法迁移的项目我们何苦迁移呢?还有后一种情况的一个小分支,那就是其实并没有已存在的C++类库可以直接用,但是使用JavaScript难以实现某种功能。
这两大理由造就了一批又一批的C++原生扩展。
2.1.4 参考资料
[1]The Computer Language Benchmarks Game:http://benchmarksgame.alioth.debian.org/.
[2]Benchmark of Regex Libraries:http://lh3lh3.users.sourceforge.net/reb.shtml.
[3]Google发布CityHash系列散列算法:http://www.cnbeta.com/articles/tech/139994.htm.
[4]cityhash/src/city.h:https://github.com/google/cityhash/blob/master/src/city.h.
[5]一个由于环境变量产生的bug:http://f10.moe/2016/03/07/%E4%B8%80%E4%B8%AA%E7%94%B1%E4%BA%8E%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F%E4%BA%A7%E7%94%9F%E7%9A%84bug/.