Interop with other languages (FFI)

Nim comes with powerful interoperability options, both when integrating Nim code in other languages and vice versa.

Acting as a complement to the manual, this section of the book covers interoperability / FFI: how to integrate Nim into other languages and how to use libraries from other languages in Nim.

While it is possible to automate many things related to FFI, this guide focuses on core functionality - while tooling, macros and helpers can simplify the process, they remain a cosmetic layer on top of the fundamentals presented here.

The focus of this guide is on pragmatic solutions available for the currently supported versions of Nim - 1.6 at the time of writing - the recommendations may change as new libraries and Nim versions become available.

For examples, head to the interop folder in the style guide repository.

Basics

In interop, we rely on a lowest common denominator of features between languages - for compiled languages, this is typically the mutually overlapping part of the ABI.

Nim is unique in that it also allows interoperability at the API level with C/C++ - however, this guide focuses on interoperability via ABI since this is more general and broadly useful.

Most languages define their FFI in terms of a simplified version of the C ABI - thus, the process of using code from one language in another typically consists of two steps:

  • exporting the source library functions and types as "simple C"
  • importing the "simple C" functions and types in the target language

We'll refer to this part of the process as ABI wrapping.

Since libraries tend to use the full feature set of their native language, we can see two additional steps:

  • exposing the native library code in a "simple C" variant via a wrapper
  • adding a wrapper around the "simple C" variant to make the foreign library feel "native"

We'll call this API wrapping - the API wrapper takes care of:

  • conversions to/from Nim integer types
  • introducing Nim idioms such as generics
  • adapting the error handling model

The C ABI serves as the "lingua franca" of interop - the C guide in particular can be studied for topics not covered in the other language-specific sections.

Calling Nim code from other languages

Nim code can be compiled both as shared and static libraries and thus used from other languages.

Exporting Nim functions to other languages

To export functions to other languages, the function must be marked as exportc, dynlib - in addition, the function should not raise exceptions and use the cdecl calling convention typically.

We can declare a helper pragma to set all the options at once:

{.pragma: exported, exportc, cdecl, raises: [].}

Importing other language functions to Nim

Similar to when exporting functions, imported functions need to be annotated with several pragmas to ensure they are imported correctly. Since imported functions don't interact with Nim exceptions or the garbage collector, they should be marked with raises[], gcsafe.

{.pragma: imported, importc, cdecl, raises: [], gcsafe.}

Runtime library initialization

When calling Nim from other languages, the Nim runtime must first be initialized. Additionally, if using garbage collected types, the garbage collector must also be initialized once per thread.

Runtime initialization is done by calling the NimMain function. It can be called either separately from the host language or guarded by a boolean from every exported function.

Garbage collector initialization is a two-step process:

  • the garbage collector itself must be inititialized with a call to setupForeignThreadGc
  • nimGC_setStackBottom must be called to establish the starting point of the stack
    • this function must be called in all places where it is possible that the exported function is being called from a "shorter" stack frame

Typically, this is solved with a "library initialization" call that users of the library should call near the beginning of every thread (ie in their main or thread entry point function):

proc NimMain() {.importc.} # This function is generated by the Nim compiler

var initialized: Atomic[bool]

proc initializeMyLibrary() {.exported.} =
  if not initialized.exchange(true):
    NimMain() # Every Nim library needs to call `NimMain` once exactly
  when declared(setupForeignThreadGc): setupForeignThreadGc()
  when declared(nimGC_setStackBottom):
    var locals {.volatile, noinit.}: pointer
    locals = addr(locals)
    nimGC_setStackBottom(locals)

proc exportedFunction {.exported.} =
  assert initialized, "You forgot to call `initializeMyLibrary"

  echo "Hello from Nim

In languages such as Go, it is hard to anticipate which thread code will be called from - in such cases, you can safely initialize the garbage collector in every exported function instead:

proc exportedFunction {.exported.} =
  initializeMyLibrary() # Initialize the library on behalf of the user - this is usually more convenient
  echo "Hello from Nim

The garbage collector can be avoided using manual memory management techniques, thus removing the requirement to initialize it in each thread - the runtime must always be initialized.

See also the Nim documentation on this topic.

Globals and top-level code

Code written outside of a proc / func is executed as part of import:ing the module, or, in the case of the "main" module of the program, as part of executing the module itself similar to the main function in C.

This code will be run as part of calling NimMain as noted above!

Exceptions

You must ensure that no exceptions pass to the foreign language - instead, catch all exceptions and covert them to a different error handling mechanism, annotating the exported function with {.raises: [].}.

Memory

Nim is generally a GC-first language meaning that memory is typically managed via a thread-local garbage collector.

Nim also supports manual memory management - this is most commonly used for threading and FFI.

Garbage-collected types

Garbage-collection applies to the following types which are allocated from a thread-local heap:

  • string and seq - these are value types that underneath use the GC heap for the payload
    • the string uses a dedicated length field but also ensures NULL-termination which makes it easy to pass to C
    • seq uses a similar in-memory layout without the NULL termination
    • addresses to elements are stable as long as as elements are not added
  • ref types
    • types that are declared as ref object
    • non-ref types that are allocated on the heap with new (and thus become ref T)

ref types and pointers

The lifetime of garbage-collected types is undefined - the garbage collector generally runs during memory allocation but this should not be relied upon - instead, lifetime can be extended by calling GC_ref and GC_unref.

ref types have a stable memory address - to pass the address of a ref instance via FFI, care must be taken to extend the lifetime of the instance so that it is not garbage-collected

proc register(v: ptr cint) {.importc.}
proc unregister(v: ptr cint) {.importc.}

# Allocate a `ref cint` instance
let number = new cint
# Let the garbage collector know we'll be creating a long-lived pointer for FFI
GC_ref(number)
# Pass the address of the instance to the FFI function
register(addr number[])

# ... later, in reverse order:

# Stop using the instance in FFI - address is guaranteed to be stable
unregister(addr number[])
# Let the garbage collector know we're done
GC_unref(number)

Manual memory management

Manual memory management is done with create (by type), alloc (by size) and dealloc:

proc register(v: ptr cint) {.importc.}
proc unregister(v: ptr cint) {.importc.}

# Allocate a `ptr cint` instance
let number = create cint
# Pass the address of the instance to the FFI function
register(number)

# ... later, in reverse order:

# Stop using the instance in FFI - address is guaranteed to be stable
unregister(number)
# Free the instance
dealloc(number)

To allocate memory for cross-thread usage, ie allocating in one thread and deallocating in the other, use createShared / allocShared and deallocShared instead.

Threads

Threads in Nim are created with createThread which creates the thread and prepares the garbage collector for use on that thread.

See above for how to initialize the garbage collector when calling Nim from threads created in other languages.

Passing data between threads

The primary method of passing data between threads is to encode the data into a shared memory section then transfer ownership of the memory section to the receiving thread either via a thread-safe queue, channel, socket or pipe.

The queue itself can be passed to thread either at creation or via a global variable, though we generally seek to avoid global variables.

# TODO pick a queue

type ReadStatus = enum
  Empty
  Ok
  Done

proc read(queue: ptr Queue[pointer], var data: seq[byte]): ReadStatus =
  var p: pointer
  if queue.read(p):
    if isNil(p):
      ReadStatus.Done
    else:
      var len: int
      copyMem(addr len, p, sizeof(len))
      data = newSeqUninitalized[byte](len)
      copyMem(addr data[0], cast[pointer](cast[uint](data) + sizeof(len)), len)
    ReadStatus.Ok
  else:
    ReadStatus.Empty

proc write(queue: ptr Queue[pointer], data: openArray[byte]) =
  # Copy data to a shared length-prefixed buffer
  let
    copy = allocShared(int(len) + sizeof(len))
  copyMem(copy, addr len, sizeof(len))
  copyMem(cast[pointer](cast[uint](copy) + sizeof(len)), v, len)

  # Put the data on a thread-safe queue / list
  queue.add(copy)

proc reader(queue: ptr Queue[pointer]):
  var data: seq[byte]
  while true:
    case queue.read(data)
    of Done: return
    of Ok: process(data)
    of Empty:
      # Polling should usually be replaced with an appropriate "wake-up" mechanism
      sleep(100)

async / await

When chronos is used, execution is typically controlled by the chronos per-thread dispatcher - passing data to chronos is done either via a pipe / socket or by polling a thread-safe queue.

See the async example.

Resources