背景:
目前nodejs调用c++主流的有两种方法,分别是addons和ffi
addons是nodejs官方的c++扩展实现方案,但是由于需要使用模版,并且要对v8引擎有一定的了解,入门门槛较高。
ffi是nodejs直接调用so库的一种实现,可以调用纯c的接口。
以上两种方法有一个共同的缺陷,就是当nodejs调用的时候,如果不能立即返回的话,c++代码会卡住单线程的js代码。
在这种情况下,如果c++代码没有io操作而只是纯计算的话是没有问题的,但是如果有同步io操作,势必会降低nodejs的执行效率。这时的解决方案有两个思路
1.在js代码中使用child_process模块创建一个子进程,子进程再调用c扩展(此时子进程会被扩展卡住),保证主进程可以充分利用cpu。
2.js在c代码中注册回调,c代码中维护多线程,不让同步io操作卡住函数返回,在处理完成后调用回调函数
这两种方案的实现成本都比较高,所以在c代码非纯cpu计算的情况下,笔者提出了一种方案,使得nodejs调用c的开发成本降低,并且能够保证js的单核利用率不受影响。
主体思路:
通过child_process直接创建一个子进程,这个子进程是c的可执行程序,主进程(js)和子进程(c)通过管道进行通信,
这样主进程在需要调用一个c函数的时候会向子进程发送一个消息,并不会卡住主进程js继续执行,在c程序执行完成之后将消息返回回来。
举一个例子:我有一段c代码,这时一段机器学习代码,可能会要访问gpu,我想要用js去调用这段c代码,如果我简单的用addons来封装,做一个同步调用,那么在gpu进行运算的时候,js的线程会被卡住,cpu没有得到利用,如果是后台服务的话会导致并发处理能力降低。这时就非常适合用上面的方式创建一个子进程,js和c程序通过管道消息来协同工作。
当然,这种模式是有适用范围的,前提是你的c程序的分离成子进程后通信的耗时相比于c执行的耗时在一个可接受的范围内。
比如,如果你的c程序只是简单的逻辑运算,每一次计算都只要微秒级别的,但是由于创建了一个子进程,进程切换和通信带来了毫秒级别的代价,这时就不划算了。但是如上面的例子,如果你的c程序中出现了大量的io操作或者需要协处理器工作的情况,并且时间较高(100毫秒级别左右),这时进程切换和通信带来的开销就可以忽略。
实现:
笔者为这种模式实现了一个简单的框架
https://github.com/freelw/nodejscallc
下面介绍一下使用方法
在clone到本地之后你的目录结构应该是这样
首先我们执行
npm install
来安装需要的依赖
之后我们需要定义一个接口描述文件,json格式
{
"func_name" : "test",
"req_params" : [
{
"name" : "param_long",
"type" : "long"
},
{
"name" : "param_string",
"type" : "string"
}
],
"rsp_params" : [
{
"name" : "rsp_param_string",
"type" : "string"
},
{
"name" : "rsp_param_long",
"type" : "long"
}
]
}
这个接口描述文件主要分为3部分
1.func_name,表示你所要调用的函数名称
2.req_params, 是输入参数列表,列表中的每个元素有两个字段,name表示参数名,type表示参数类型,目前仅支持long和string
3.rsp_params,是返回参数列表,内容同2
在这个例子中,有两个输入参数 param_long long类型, param_string string类型
有两个输出参数rsp_param_string string类型, rsp_param_long long类型
我们把这个文件保存为desc.json
然后我们执行初始化工具来生成调用函数所需的js代码和c代码
node init.js -i desc.json
这时便会按照你的接口描述文件创建三个文件
由于func_name是test,所以生成的js和cpp文件的前缀是test
test_proxy.js是js的函数调用代理,这里的代码是用户完全不用修改的
test_imp.cpp是c函数的实现部分,用户需要实现test函数
在这里我们假设需要实现一个逻辑,将param_string从rsp_param_string输出
并且需要rsp_param_long=param_long+1
于是我们有以下代码
这时我们进入build/c/下面进行编译
cd build/c
make
这时会在release下面生成一些文件
test_proxy.js是刚才js文件夹下生成的,make执行是自动拷贝到此处
test是test_imp.cpp编译后生成到可执行文件
我们现在写一个测试脚本来调用以下我们写的函数
const test = new (require('./build/release/test_proxy'))();
test.do({
param_long: 123,
param_string: 'teststring',
}, (rsp) => {
console.log('rsp : ', rsp);
});
这里面test是new出来的一个实例,对应着一个创建出来的c程序进程, test.do可以被调用多次,如果c程序中有全局变量在每次调用时改变, 那么就会影响之后的调用结果, 就是说每new一次是一个执行session,这种接口很适合加载需要很久,执行很快的程序,不用每次创建进程进行初始化。另外,如果你想要让c程序多进程,只要new多个实例就好。
执行
node test.js
rsp : { rsp_param_string: 'teststring', rsp_param_long: 124 }
调用成功
所以整个过程就是这样
“定义接口文件”-->“通过工具生成js和c文件”-->“补充c文件中的实现逻辑”-->“编译c文件”-->“调用js文件”
本框架还有一些欠缺,比如c程序在向js传递消息的时候是使用的同步的write方法,会导致c程序的cpu使用率达不到极限,笔者之后会使用非阻塞io的模型优化这块代码。
如果你喜欢这篇文章,请麻烦帮我star哦。