This is a (very) simple experiment with writing native node modules with Golang and node-gyp.
To try this out, you will need to have Go
and node-gyp
installed. Of course, you will also need
Node.js
installed.
To start, we first take the a simple Go program that exposes functions via cgo.
Using the C
package, we can utilize the special export
comment to tell the compiler that a function is going
to be exported.
lib/main.go
package main
import "C"
//export Hello
func Hello () *C.char {
return C.CString("Hello world!")
}
// required to build
func main () {}
Note: As you may have noticed, the function returns a C string instead of a regular Go string. You can export a Go string, but this was a little easier to work with in the native code.
Now, we can build a shared library. We will export the shared object file as libgo.so
. Doing this will
also export a libgo.h
file, which we will need.
# in the lib directory
$ go build -buildmode=c-archive -o libgo.a
We build with the c-archive
build mode because it links the library at compile time. Another option would be
to use the c-shared
build mode, but depending on the platform, you may run into issues when attempting to run
the final script.
If you are adventurous and want to try out the c-shared
build mode and happen to be using MacOS, you
might need to set the DYLD_FALLBACK_LIBRARY_PATH
environment variable to include the project's lib
directory.
Now, lets create the native code that will bridge our Go and Javascript.
goAddon.cc
#include <node.h>
// include the header file generated from the Go build
#include "lib/libgo.h"
using v8::FunctionCallbackInfo;
using v8::Isolate;
using v8::Local;
using v8::Object;
using v8::String;
using v8::Value;
void HelloMethod (const FunctionCallbackInfo<Value> &args) {
Isolate *isolate = args.GetIsolate();
// Call exported Go function, which returns a C string
char *c = Hello();
// return the value
args.GetReturnValue().Set(String::NewFromUtf8(isolate, c));
delete c;
}
// add method to exports
void Init (Local<Object> exports) {
NODE_SET_METHOD(exports, "hello", HelloMethod);
}
// create module
NODE_MODULE(myGoAddon, init)
As you can see, we can simply import the correct header and call the exported Hello
function.
Now that we have all of the native components ready, we can link everything together in our binding.gyp
file.
binding.gyp
{
'targets': [
{
'target_name': 'go-addon',
# import all necessary source files
'sources': [
'lib/libgo.h', # this file was generated by go build
'go-addon.cc'
],
# libraries are relative to the 'build' directory
'libraries': [ '../lib/libgo.a' ] # this file was also generated by go build
}
]
}
Now that we have all of the native portions in place, we just need to compile our native code into something
that node
can use.
# Generate appropriate build files for platform
$ node-gyp configure
# build the project to create our bindings file
$ node-gyp build
After this, you should see that that a build
directory was created. If you take a peek into the directory,
you will see that we now have a go-addon.node
file under the build/Release
folder.
Finally, we can write some Javascript.
index.js
const goAddon = require('./build/Release/go-addon');
console.log(goAddon.hello());
Now, we can run our script and see it output the string that our Go function returned.
$ node index.js
Hello world!
If you want to see in action, but don't want to go through the trouble of running the commands, you can cd
into the
helloworld
directory and run the provided run-build.sh
script to get a working build.
$ ./run-build.sh
It is well known that there is quite a bit of overhead when switching context between Go and C with cgo
, but
is it enough deter devs from writing native modules with Go?
To start let's try running some benchmarks to see if the overhead has noticable effect on performance.
First, we will start with a simple test to see if there is much of a difference when it comes to invoking simple functions. Here are the functions that were invoked in each language for the test:
function jsAdd (a, b) {
return a + b;
}
void CppAdd (const FunctionCallbackInfo<Value> &args) {
Isolate *isolate = args.GetIsolate();
bool valid = validateArgs(isolate, args);
if (!valid) {
isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "Wrong args")));
return;
}
double sum = args[0]->NumberValue() + args[1]->NumberValue();
Local<Number> value = Number::New(isolate, sum);
args.GetReturnValue().Set(value);
}
//export Add
func Add (a, b float64) float64 {
return a + b
}
let's not forget the glue that is needed for the Go function to be invoked.
// glue for the Go bindings
void GoAdd (const FunctionCallbackInfo<Value> &args) {
Isolate *isolate = args.GetIsolate();
bool valid = validateArgs(isolate, args);
if (!valid) {
isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "Wrong args")));
return;
}
GoFloat64 sum = Add(args[0]->NumberValue(), args[1]->NumberValue());
Local<Number> value = Number::New(isolate, sum);
args.GetReturnValue().Set(value);
}
Running these with benchmark
, we get the following results:
js add x 91,215,401 ops/sec ±2.72% (85 runs sampled)
cpp add x 29,007,733 ops/sec ±3.02% (84 runs sampled)
go add x 376,173 ops/sec ±1.04% (89 runs sampled)
As expected, the Javascript implementation comes out on top with the most ops/sec. The C++ implementation is quite slower, which is to be expected since there is some overhead when switching contexts. The Go implementation ends up with a staggeringly low 376,173 ops/sec. Even with the small amount of code needed to invoke the Go function from the C++ code, this is much slower than expected. It looks like there is a decent amount of overhead when invoking Go functions from within C++, so Go might not be the best option for relatively hot code.
Next, let's try some simple looping. In this test, we will increment and set a variable for every iteration of the loop. We will also log the amount of time that it takes to go through the loop to get an idea of how much time is spent in each function.
function jsIncrement () {
let startDate = Date.now();
let v = 0
for (let i = 0; i < 2147483600; i++) {
v = i
}
console.log(`js: Time in ms to complete loop ${Date.now() - startDate} ms`);
return v
}
void CppIncrement (const FunctionCallbackInfo<Value> &args) {
Isolate *isolate = args.GetIsolate();
high_resolution_clock::time_point start = high_resolution_clock::now();
int value = 0;
for (int i = 0; i < 2147483600; i++) {
value = i;
}
high_resolution_clock::time_point end = high_resolution_clock::now();
auto diff = duration_cast<milliseconds>(end - start);
cout << "cpp: Time in ms to complete loop " << diff.count() << "ms\n";
args.GetReturnValue().Set(Number::New(isolate, value));
}
//export Increment
func Increment () int {
start := getTimestamp()
v := 0
for i := 0; i < 2147483600; i++ {
v = i
}
fmt.Printf("go: Time in ms to complete loop %v ms\n", getTimestamp() - start)
return v
}
Some more glue:
void GoIncrement (const FunctionCallbackInfo<Value> &args) {
Isolate *isolate = args.GetIsolate();
args.GetReturnValue().Set(Number::New(isolate, Increment()));
}
Now the results:
js: Time in ms to complete loop 4270 ms
js: Time in ms to complete loop 5339 ms
js: Time in ms to complete loop 4965 ms
js: Time in ms to complete loop 4931 ms
js: Time in ms to complete loop 4973 ms
js: Time in ms to complete loop 4933 ms
js: Time in ms to complete loop 4909 ms
js: Time in ms to complete loop 4913 ms
js: Time in ms to complete loop 4947 ms
js: Time in ms to complete loop 4940 ms
js increment x 0.20 ops/sec ±4.54% (5 runs sampled)
cpp: Time in ms to complete loop 934ms
cpp: Time in ms to complete loop 932ms
cpp: Time in ms to complete loop 938ms
cpp: Time in ms to complete loop 932ms
cpp: Time in ms to complete loop 939ms
cpp: Time in ms to complete loop 949ms
cpp: Time in ms to complete loop 918ms
cpp: Time in ms to complete loop 955ms
cpp: Time in ms to complete loop 922ms
cpp: Time in ms to complete loop 935ms
cpp: Time in ms to complete loop 939ms
cpp: Time in ms to complete loop 918ms
cpp: Time in ms to complete loop 928ms
cpp: Time in ms to complete loop 925ms
cpp increment x 1.07 ops/sec ±1.29% (7 runs sampled)
go: Time in ms to complete loop 970 ms
go: Time in ms to complete loop 968 ms
go: Time in ms to complete loop 970 ms
go: Time in ms to complete loop 953 ms
go: Time in ms to complete loop 982 ms
go: Time in ms to complete loop 957 ms
go: Time in ms to complete loop 959 ms
go: Time in ms to complete loop 968 ms
go: Time in ms to complete loop 971 ms
go: Time in ms to complete loop 956 ms
go: Time in ms to complete loop 948 ms
go: Time in ms to complete loop 965 ms
go: Time in ms to complete loop 973 ms
go: Time in ms to complete loop 986 ms
go increment x 1.04 ops/sec ±1.08% (7 runs sampled)
Fastest is cpp increment
In this test, there were very few function calls being made. As you can see, the Javascript for loop was quite slow, taking about 5 seconds to complete. We know that heavy, blocking computations like this shouldn't really be done with Javascript anyway, so this isn't a huge surprise. The interesting thing is that both the Go and C++ functions are almost neck and neck in terms of speed, with the C++ implementation having a slight lead over Go. Both are a little over 5 times faster than the JS variant. Maybe using Go for heavy processing can be somewhat feasible? I think more exploring should be done before a solid conclusion can be made.
Note: Both of the above benchmarks test some extreme cases and are not really representative of what you would see in the wild. Some more interesting tests (especially some involving goroutines) would be helpful in determining whether Go can be a decent alternative to straight C++ for native modules.
Also, the machine I used for running these benchmarks is a 2016 Macbook Pro with a 2.7 GHz i7 and 16GB of RAM.
If you want to try running the benchmarks on your own, cd
into the benchmark
dir and run the run-build.sh
script.
Then run node benchmark.js
to start the tests.
- Add more benchmarks
- Introduce more realistic examples