Summary
From 2.9 any thread may call a Turf library, one at a time (TDP-2-030). That is safe but serial, and runtimes built on thread pools (the JVM, .NET, Go, the BEAM) want their calls to run in parallel. On the thread-aware runtime (TDP-3-001) this proposal adds parallel entry:
- A library opts in with
[lib] entry = "parallel". Host threads then run Turf code at the same time. - Each foreign thread is registered with the collector on its first call.
- Only threads currently inside Turf take part in a collection.
- A handle is locked for the length of each call, so two threads never use one Turf object at once unless its type is safe to share.
Four generator repositories, built on the export description, give those languages idiomatic bindings. It ships in 3.3, after threads (3.1) and sync (3.2).
Motivation
Java, Kotlin, C#, Go, Erlang and Elixir run most of the world’s servers. Each can call a C ABI, so each can call a Turf library from 2.9, but serially. A JVM service with fifty request threads calling a Turf parser would run its parses one at a time. These hosts also want bindings in their own idiom: try-with-resources, SafeHandle, Go cleanups, Erlang resources. They should not have to write raw FFI against a C header.
Design
Why parallel entry is safe in Turf
Two Turf calls on different threads can only race on memory both can reach. In Turf that is little:
- No mutable globals in Turf code. A top-level
mutis E087, and module constants areimmut. Two exported calls share no Turf state unless it is passed in. The runtime’s own process-wide state (therandommodule’s generator inturfrt_sys.c, the allocation statistics) is made thread-safe by TDP-3-001’s runtime work, which this proposal depends on. - Arguments are copied or borrowed per call. Strings are copied in. A
Sliceis a view valid for one call, which the host must not change during the call, as every C ABI already requires. - Handles are the one shared thing. Two host threads holding the same handle could reach the same Turf object at once. So, by default, the runtime locks a handle for the length of any call that receives it. Calls on different handles run in parallel, and calls on the same handle queue.
- A type safe to share opts out of the lock by being built only from TDP-3-001’s shareable types (
Mutex<T>,RwLock<T>, atomics, immutable values). The compiler checks that structurally, the same predicatethread::spawnuses, and marks the handlesharedin the export description.
The runtime
- Entry modes.
[lib] entry = "serial"is the 2.9 behaviour and stays the default.entry = "parallel"needs a 3.3 compiler. - Registering threads. A foreign thread’s first entry registers its stack bounds with the collector. It is unregistered when the thread exits, through a thread-local destructor (
pthread_key_create, andDLL_THREAD_DETACHon Windows), or explicitly with<package>_detach_thread(). - Collections. Stop-the-world handshakes include only threads that are inside Turf at that moment. A thread outside Turf holds nothing but rooted handles, so the collector ignores it. A thread that arrives during a collection waits at the entry.
- The single-thread fast path. It holds as TDP-3-001 requires: a
seriallibrary, or aparallelone entered by one thread, compiles out the handshakes and locks it does not use.
Generated bindings
Each tool lives in its own repository and reads only the export description and the built library.
| Tool | Host | Calls through | Handles | Slices | Errors | Packaging |
|---|---|---|---|---|---|---|
turf-jvm |
Java, Kotlin | Panama FFM (Java 22+) | AutoCloseable plus a Cleaner |
Off-heap MemorySegment without copy; Java arrays copied |
Exceptions per error | A JAR with native libraries per platform |
turf-dotnet |
C#, F# | LibraryImport source generator |
SafeHandle subclasses |
Span<T> pinned for the call |
Exceptions per error | A NuGet package with runtimes/<rid>/native |
turf-go |
Go | cgo | Go types with runtime.AddCleanup and Close() |
Go slices, per cgo’s pointer rules | (T, error) returns |
A Go module with prebuilt static libraries |
turf-beam |
Erlang, Elixir | NIFs on dirty CPU schedulers | Resource objects with destructors | Binaries | {error, Reason} tuples |
A Hex package |
Notes for each host:
- JVM. Virtual threads are pinned to their carrier during a native call, which is correct here. A call may receive a Java heap array without a copy only if it is marked short and makes no callback (FFM’s critical calls).
- .NET. A call that can call back into .NET keeps arguments pinned for its whole length.
- Go. cgo forbids C, and so Turf, from keeping a Go pointer after a call returns. Turf’s call-scoped
Slicerule is the same rule. - BEAM. Every Turf NIF runs on a dirty scheduler by default, since a Turf collection can last longer than the 1 ms a normal scheduler may be held.
@export(fast)marks a function the author has measured as short.
Constitution check
- I. Safe by default, unsafe by declaration. Parallel entry cannot create a data race in Turf code: there are no mutable globals, and handles are locked unless their type is structurally shareable. Each generator holds its host’s
unsafeFFI code; its users write none. - II. Native performance, with costs you can read. Parallel entry exists for throughput. The cost of a handle lock is documented per call, and a shareable type avoids it.
- III. The compiler is the authority. Whether a type is shareable is the compiler’s structural check, the one
thread::spawnuses. - IV. Nothing crosses a boundary implicitly. Parallel entry is an explicit manifest setting; a shared handle is marked in the export description.
- V. A few general mechanisms, not many special cases. It reuses TDP-3-001’s runtime and shareability predicate, and the export description of TDP-2-030.
- VI. It works when you install it. Each generator’s package carries prebuilt native libraries.
- VII. Stability is a promise. Additive in a 3.x minor;
serialstays the default, so no library changes behaviour. - VIII. A claim is not true until it is demonstrated. See Verification.
The Non-Goals hold: no borrow checker (shareability is a structural type predicate, not ownership analysis), no no-runtime target, no AI in the compilation path.
Migration
Nothing breaks.
Verification
- The runtime. Under ThreadSanitizer and
TURF_GC_STRESS, 64 foreign threads calling aparallellibrary: distinct handles in parallel, a shared handle serialized, a shareable handle in parallel, threads exiting mid-run and being unregistered, collections during entry storms. - The fast path. The benchmark floor holds for a
seriallibrary and for aparallelone entered from one thread. - Each generator. A conformance suite of one call per conversion row, run from a thread pool on each Tier 1 platform; handles released by the host collector; errors mapped.
Drawbacks
- A per-handle lock costs an uncontended atomic per call on a non-shareable handle.
- Four more repositories, each tracking a host runtime’s release cycle.
- Dirty schedulers by default on the BEAM add a scheduler hop to every call.
Alternatives
- Parallel entry for every library, with no lock on handles. Rejected: two threads using one handle would race in safe Turf code.
- Keep serial entry and tell hosts to use several processes. Workable, but it gives up the throughput these hosts exist for.
- One generic generator with templates per language. Possible later, if the four share enough. Separate tools first keep each idiomatic.
Unresolved questions
- Whether a host thread that has never entered Turf must be able to release a handle (the release function itself needs no registration, but it does touch the root table).
- Whether
turf-goshould also offer a pure-Go fallback for platforms without cgo, which would need a different calling mechanism.