Skip to content

Latest commit

 

History

History
202 lines (121 loc) · 7.59 KB

File metadata and controls

202 lines (121 loc) · 7.59 KB

Thread Safety Considerations for Java FFM (and how this repo applies them)

This document focuses on thread safety when using Java’s Foreign Function & Memory (FFM) API (java.lang.foreign) to call native libraries.

FFM gives you memory safety primitives (bounded segments, explicit lifetimes), but it does not magically make your code thread-safe. You still need to define and enforce concurrency rules around:

  1. your Java wrapper’s state and lifecycle,
  2. FFM lifetimes / confinement rules, and
  3. the native library’s own thread-safety guarantees.

1) Three layers of “thread safety” you must reason about

1.1) Java object lifecycle (who can call close() and when)

Any wrapper that owns a native resource has the same hard rule as sockets/files:

Never let an operation race with close() on the same underlying native handle.

If close() frees a native context while another thread is in a downcall using it, you can get use-after-free behavior in native code.

1.2) FFM confinement and lifetime rules (Arena / MemorySegment)

Key facts you should design around:

  • Arena.ofConfined() produces segments that are thread-confined (access from other threads is illegal).
  • Arena.ofShared() produces segments that are shareable across threads, but you still need normal synchronization if multiple threads read/write the same memory.
  • Closing an arena invalidates segments allocated from it; FFM will throw if you use them after close.

Practical consequence:

Prefer per-operation confined arenas for scratch memory. Avoid storing confined segments in fields of objects that might be used from multiple threads.

1.3) Native library guarantees (reentrancy, per-context safety, global state)

Native libraries vary widely:

  • Some are thread-safe only if you use one context per thread/connection.
  • Some require external locking around a context.
  • Some have global mutable state and are not thread-safe at all.

Your wrapper must reflect that reality—either by:

  • enforcing single in-flight usage (locking), or
  • providing per-thread/per-session contexts, or
  • documenting “not thread-safe”.

2) Recommended patterns

2.1) “Per-operation scratch” pattern (safe default)

Inside each method:

  • create try (Arena a = Arena.ofConfined()) { ... }
  • allocate temporary buffers/outputs
  • call native
  • copy results into Java arrays/DTOs/String
  • return Java values (no segment leakage)

This avoids:

  • memory leaks (arena closes deterministically)
  • use-after-close (segments die with the scope)
  • cross-thread segment access (scratch segments never escape)

You’ll see this pattern throughout this repo.

2.2) “Single in-flight per native context” pattern

When your native resource is stateful (socket/session/opaque handle), define a rule like:

One native context supports at most one in-flight operation at a time.

Then enforce it with a lock so that operations and close() are mutually exclusive.

2.3) Concurrency by pooling (scale out, don’t share)

If you need parallelism:

  • don’t try to make one native context concurrent
  • create N independent sessions/contexts and distribute work across them (pool)

This is often the cleanest way to stay correct with libraries that are not re-entrant per context.

2.4) Virtual threads vs native downcalls (pinning / isolation)

Native calls can block and may pin a virtual thread to its carrier while inside the native frame. This is not a correctness problem, but it can be a scalability concern.

A pragmatic strategy is to offer two modes:

  • “simple” mode (virtual threads) when concurrency is low/moderate
  • “isolated” mode (platform thread executor per session) to confine any pinning impact

See docs/concurrency/jdk25-concurrency.md for broader concurrency/performance notes.


3) How simple-ffm-demo aligns (and what to watch out for)

3.1) Scratch memory is per-operation

SvcNativeWrapper allocates output buffers with Arena.ofConfined() inside each method:

  • simple-ffm-demo/demo-app/src/main/java/es/omarall/ffm/wrapper/SvcNativeWrapper.java

This is the recommended baseline: scratch segments never escape the method, so you don’t accidentally share them across threads.

3.2) The native library is stateless, but lifecycle still matters

The demo C library is intentionally simple and stateless:

  • simple-ffm-demo/native-lib/src/main/native/svc.c

However, the context (svc_ctx*) is still freed in svc_destroy. If one thread calls close() while another is mid-operation, you risk native use-after-free.

In this repo, SvcNativeWrapper prevents that by serializing operations and close() with a lock. The result is:

  • safe concurrent callers (no close() race with an operation)
  • no parallelism on the same native context (everything is executed one-at-a-time)

If you need throughput, scale out by creating multiple wrapper instances (or use a pooled/session approach like mymodbus).

3.3) Library loading is a shared global

System.load(...) is a one-time VM-level action. The demo wrapper guards it with a static flag. In production systems, prefer loading during application bootstrap (composition root) to avoid any lazy-load races.


4) How mymodbus aligns: ports/adapters + explicit session safety

4.1) The outbound “native port” isolates thread safety decisions

ModbusNative is the native-backend abstraction:

  • mymodbus/src/main/java/es/omarall/mymodbus/nativeport/ModbusNative.java

This enables:

  • a real adapter (FFM+jextract) for production/integration testing
  • a fake adapter for unit tests/benchmarks

4.2) Session enforces single in-flight and serializes close()

ModbusSession enforces one in-flight request per connection and serializes operations with a fair lock:

  • mymodbus/src/main/java/es/omarall/mymodbus/session/ModbusSession.java

Crucially, close() also takes the same lock. That means:

  • no operation can run concurrently with nativeApi.close()
  • the native context cannot be freed while in use

This is the core “thread safety boundary” in the module.

4.3) Concurrency is achieved by pooling sessions, not sharing one context

PooledModbusClient provides fan-out reads by distributing partitions across multiple ModbusSession instances:

  • mymodbus/src/main/java/es/omarall/mymodbus/session/PooledModbusClient.java

This is a canonical approach for native dependencies:

  • each session has its own connection and native context
  • each session is internally serialized
  • parallelism comes from multiple independent contexts

4.4) FFM adapter uses confined arenas for per-call scratch buffers

The jextract-based adapter allocates native buffers per call (confined), then copies results into Java arrays:

  • mymodbus/src/main/java-jextract/es/omarall/mymodbus/nativeport/LibModbusJextract.java

This aligns with the “per-operation scratch” pattern and avoids leaking segments to callers.


5) Checklist for safe FFM adapters

Use this as a quick pre-merge checklist:

  • Public APIs expose only Java types (no MemorySegment / Arena in signatures).
  • Each operation uses confined scratch allocations and copies results out.
  • Any stateful native context has an explicit concurrency contract:
    • not thread-safe (documented), or
    • single in-flight (lock), or
    • per-thread/per-session contexts.
  • close() cannot race with operations on the same context (shared lock or equivalent).
  • Error mapping captures native error state immediately (and on the same thread as the failing call).
  • Concurrency scaling uses pooling instead of sharing one native context.