Janet: Buy Runtime Get GC Free
Over the years, I’ve tried using many scripting languages with C/Zig. The following are the languages that I remember using, along with the pain I felt when embedding them.
- Lua/LuaJIT: Stack shenanigans.
- CPython: INCREF/DECREF shenanigans. The language is pretty good though.
- Wren: Slot shenanigans.
- Chibi-Scheme: “Batteries, where?” The standard library doesn’t even have a way to replace substring in string.
- Gambit: It tries its best not to be embedded.
Recently, I was troubled by the lack of memory management in Zig, and by accident, I tried Janet. The memory model of Janet fit surprisingly well with Zig. That made me thinking. What if I only use Janet for its GC?
Here’s what I came up with (demo source code). The article and my code use Zig and jzignet, but you can follow along with C and libjanet (the official C API), since the functions are the same ones. Please note that error handling in C will be more verbose than in Zig.
In the rest of this article, I’ll show you how to store Zig data in Janet. It’s very easy. You do need to read my code (linked above) to follow along.
Janet has a simple mark-and-sweep garbage collector. The following are the GC roots:
janet.Environment, and anything reachable from them how to free: call
- Janet fibers, and anything reachable from them how to free: automatically, when fiber terminates
You can also add GC roots with
Here’s Janet’s promise to you:
- When no Janet code is running, nothing will be freed (unless your force a collection cycle with
- Anything reachable from any GC root will not be freed
As you can see, there are a bijillion ways you can make sure that your stuff don’t disappear. One way is to create a
janet.gcRoot(table), and store stuff in it.
How to store Janet data in Zig
If you are using Janet’s C API, you are already doing it. Just make sure the Janet values are rooted somehow. Janet’s GC is non-moving, so you can keep
janet.Janet terms as long as they are not garbage collected.
How to store Zig data in Janet
Janet string/buffer is a chunk of memory. It can contain null byte. Therefore, you can put anything in it. Call
janet.string(slice) and you are done.
You can also wrap pointer in
janet.Janet, but then you have to manage memory yourself.
That’s it! That’s all you need to know to use Janet as the data layer of your application.
As long as you keep Janet values from being eaten by the GC, I don’t see how you can mess it up.
Janet Embedding Advanced
Here are some implementation details of Janet that may help you play it like a fiddle.
JanetVM is thread-local, but not pinned to a thread
JanetVM has its own GC context. You can think of it as a language runtime.
JanetVM are indenpendent from each other.
The active VM is stored as a thread-local variable. However, you can move them to another thread. Please make sure you are not running the same VM on two different threads.
Here’s what the C API VM functions do.
// init vm, set to thread-local variable JANET_API int janet_init(void); // deinit thread-local vm JANET_API void janet_deinit(void); // get pointer to the thread-local variable holding the vm JANET_API JanetVM *janet_local_vm(void); // get thread-local variable JANET_API void janet_vm_save(JanetVM *into); // set thread-local variable JANET_API void janet_vm_load(JanetVM *from); // Don't use the following. Just use a local variable to store JanetVM, mate. // allocate memory for vm, but does not initialize it. JANET_API JanetVM *janet_vm_alloc(void); // free memory taken up by vm. JANET_API void janet_vm_free(JanetVM *vm);
How to pump event loop
You only need to worry about if your code uses Janet’s file or network API.
(ev/sleep 0) when idle. The fibers spawned by
ev/go won’t run unless you yield control somehow.
JanetVM has an event loop. It also tracks a list of fibers alive. Here’s the gist about fibers.
- Only one fiber can be running at once (in one
janet.doBytes(code)spawns a fiber to run that code
- Fibers can be used to catch runtime errors, like
xpcallin Lua, or
- If there’s only one fiber, everything looks like blocking code
- Execution context is never automatically transfered. If you do
janet.doBytes("(+ 1 2)"), it will return immediately, without giving other fibers a chance to do things.
janet.doBytes("(ev/sleep 0)")to give other fibers a chance to do things. It does not block.