Language Runtimes · FFI and Interop

Attaching Foreign Threads to a Managed Runtime

What a runtime needs from a thread before managed code can run on it, why a library-spawned worker thread has none of it, and how attach and detach bracket the transition.

A managed runtime keeps per-thread state. The exact contents vary, but the categories are consistent: an allocation context so that allocation does not need a global lock, a pointer to the current exception or handler chain, a stack-limit or stack-map record so that the collector can scan the thread’s frames precisely, and a safepoint state so the runtime knows whether this thread is at a location where it may be paused. None of this exists on a thread the runtime did not create.

A C library that spawns worker threads creates exactly such threads. When the library invokes a callback on one of them, control arrives at native code that then attempts to enter managed code. The managed code will allocate, and allocation needs an allocation context; it may trigger a collection, and the collector needs to see the thread’s stack. Without registration, the first of these needs is unmet.

What a thread must carry before managed code runs

The allocation context is the first requirement and the most immediate. Runtimes that give each thread a local allocation buffer do so to avoid synchronizing every allocation. The buffer pointer lives in thread-local storage, initialized when the runtime creates the thread. On a foreign thread that location holds zero or garbage; an allocation through it either faults or, worse, writes into an address derived from garbage.

The second requirement is stack visibility. A precise collector must distinguish references from integers in a frame, and it does so using maps emitted by the compiler that describe each instruction address. The collector also needs to know where the thread’s stack begins and ends, so it can walk frames from the current stack pointer up to the stack base. A foreign thread’s stack was allocated by pthread_create or by the platform’s thread API, and the runtime has no record of its bounds. Without a record, the collector either skips the thread entirely, which is a correctness bug, because the thread may hold the only reference to an object, or scans memory it should not touch.

The third is the safepoint protocol. A runtime that moves objects must stop threads at points where their references are known to be in registers or in described stack slots. Each thread records whether it is inside managed code, inside a runtime call, or running native code that holds no references. A foreign thread has no such record, so a collection that starts while it is running will either wait forever for it to reach a safepoint, or proceed without it and miss its references.

The fourth is the exception and handler state. Entering managed code on a thread with no handler chain means the first exception raised has nowhere to go, and the runtime’s search for a handler walks a null or stale pointer.

Attachment is the operation that establishes all four. It is not a single assignment; it allocates the thread’s runtime state, links it into the runtime’s list of threads, and marks the thread as being inside managed code for the duration. Detachment reverses it and, critically, must happen before the native thread exits, because the runtime’s list would otherwise retain a pointer to a stack that no longer exists.

The bracket, and what may happen inside it

The correct shape is a bracket: attach once at the start of the native thread’s work, detach once at the end, and never in between. Attaching and detaching around each individual callback is legal in most runtimes but expensive, because attachment allocates and registers, and because a collection may need to wait for a thread that keeps entering and leaving.

#include <stdint.h>

/* Binding-side helpers; the names stand in for whatever the runtime provides. */
int  binding_attach(void);                 /* registers this thread, allocates state */
void binding_detach(void);                 /* unregisters; must precede thread exit */
void binding_invoke(uint64_t token, void *arg);  /* enters managed code */

struct job {                 /* what the library passes to its thread entry point */
    uint64_t token;          /* rooted handle for the managed callback */
    void    *payload;        /* library-owned argument, valid for this call only */
};

/* Runs on a thread the library created, not one the runtime created. */
static void *worker(void *arg) {
    struct job *j = arg;

    if (!binding_attach()) return NULL;   /* without state, no managed call is legal */

    binding_invoke(j->token, j->payload); /* may allocate, may trigger a collection */

    binding_detach();                     /* must run before this function returns */
    return NULL;
}

Two properties of that fragment carry most of the weight. binding_detach runs on the normal path and must also run on every error path; a thread that returns without detaching leaves the runtime’s thread list pointing at a dead stack. And binding_invoke is a runtime entry point, not a direct call to a managed function pointer, because entry must check whether a collection is pending and, if so, park the thread at a safepoint before proceeding.

The detach call has a second obligation in runtimes that support it: it may need to run pending finalizers or release thread-local handles before the thread state is destroyed. Runtimes differ in whether detach performs this work or merely unregisters, and the difference matters for a thread that created managed objects. A binding that detaches immediately before pthread_exit gives the runtime no chance to run anything after detach, so any deferred work must happen before the call.

Why attaching per call is a trap in both directions

The first trap is cost. Attachment is not a counter increment in most designs; it allocates a state block, inserts it into a list under a lock, and may interact with the collector’s thread registry. Doing it per callback converts a cheap event into a synchronizing operation, and if the callback fires at high frequency the cost dominates the work.

The second trap is subtler and concerns the safepoint protocol. A thread that attaches, runs a short managed call, and detaches repeatedly spends much of its time in the “outside the runtime” state. A collector that needs all threads at a safepoint must either wait for the thread to attach again, which may be arbitrarily far in the future, or treat it as a thread that holds no references. The second is correct only if the thread truly holds none between calls, and the handle table entry for a callback argument typically means it does hold one. Runtimes resolve this by keeping the attachment alive for the thread’s lifetime and by requiring that handles be rooted independently of the thread.

The third trap appears at thread exit. A runtime that tracks threads must be told when one ends, or its registry grows without bound in a process that creates and destroys threads. Detach is that notification, and it must be paired with attach on every path including cancellation. A thread cancelled by the platform’s cancellation mechanism may never reach the detach call, which is one reason runtimes document that their managed entry points must not be cancelled asynchronously.

Thread-local state that the runtime does not own

The native library may have its own thread-local storage, and so may the C runtime. Attaching does not disturb it, but detaching can. A runtime that reclaims thread-local slots on detach may invalidate a slot the library expected to keep, and the library’s next access reads a value that was replaced. The reverse also occurs: a library that registers a thread-local destructor and a runtime that calls it at detach can produce a destructor running after the runtime state it depends on has been freed.

The ordering rule that avoids most of this is that detach happens last: the native code finishes its library work, releases its library resources, and only then detaches. That way the library’s destructors run while the runtime state is still valid, and the runtime’s teardown runs when nothing else needs the thread.

There is a further asymmetry in how runtimes identify a thread. Some use the platform thread identifier, some use a runtime-assigned token, and some use a pointer to the state block stored in thread-local storage. Code that caches the platform identifier and compares it later can be wrong when identifiers are reused after a thread exits, which POSIX permits. The runtime’s own token is the only safe key.

Entering from a thread the runtime already knows

Not every entry needs attachment. A thread that the runtime created and that is currently executing native code through an FFI call is already registered; the runtime knows its stack bounds and its safepoint state. Entering managed code from a callback on such a thread requires no attach, and calling attach anyway is at best redundant and at worst an error, because some runtimes reject a second attachment of the same thread.

The distinction is not visible from the callback’s signature. A library that invokes callbacks on both its own threads and on threads that called into it exercises both paths, and the binding must know which is which. The usual solution is for the attach operation to be idempotent, meaning it checks whether the current thread is already registered and does nothing if so, which is why most runtime attach functions tolerate a redundant call. Where it is not idempotent, the binding must track entry itself, and a re-entrant callback path will then attach twice and fail.

The practical rule is that thread attachment belongs to the boundary design, not to the callback implementation. An interface that documents which thread its callbacks run on, the caller’s or one it creates, lets the binding choose the right bracket. An interface that does not forces the binding to attach defensively on every entry, paying the cost and accepting the re-entrancy hazard. Designing a stable C ABI treats the threading contract as part of the interface for this reason, and native callbacks into managed code covers the trampoline that sits on top of it.

The last constraint is stack size. A runtime may require a minimum stack for managed code, because its own frames are larger than C’s and because it may run a stack probe on entry. A library that creates worker threads with a small stack, as some thread pools do by defaulting to a fraction of the process default, can satisfy every requirement above and still fail when the managed frame does not fit. The failure is a stack overflow inside managed code, reported at an address that has nothing to do with the library that created the thread. The only reliable defence is to create the worker threads from the runtime’s side, or to specify a stack size large enough for the deepest managed call path the callback can reach.

References

  1. POSIX.1-2017, System Interfaces: pthread_create, pthread_key_create — IEEE Std 1003.1
  2. JNI Specification — Attaching to the VM — Oracle
  3. ECMA-335: Common Language Infrastructure (CLI), Partition I — Ecma International