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’s GC

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 deinit manually
  • Janet fibers, and anything reachable from them how to free: automatically, when fiber terminates

You can also add GC roots with janet.gcRoot(_) and janet.gcUnroot(_).

Here’s Janet’s promise to you:

  • When no Janet code is running, nothing will be freed (unless your force a collection cycle with janet.collect())
  • 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.Table, call 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

Every 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.

tl;dr: Call (ev/sleep 0) when idle. The fibers spawned by ev/go won’t run unless you yield control somehow.

Each 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 JanetVM)
  • janet.doBytes(code) spawns a fiber to run that code
  • Fibers can be used to catch runtime errors, like xpcall in Lua, or catch_unwind in Rust
  • 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.
  • Do janet.doBytes("(ev/sleep 0)") to give other fibers a chance to do things. It does not block.