Class NeoLock

java.lang.Object
org.ores.async.NeoLock

public class NeoLock extends Object
An async (non-blocking) mutex.

Unlike synchronized blocks and ReentrantLock, NeoLock does not park a thread while waiting. Waiters enqueue an error-first callback; when the lock becomes free, the next waiter's callback fires with a fresh Unlock token. This makes it suitable for callback chains where the acquire and the release happen on different threads (or different virtual threads), which is the common case with async I/O.

Why not just use synchronized?

  1. Cross-thread release. A synchronized block must be released by the same thread that acquired it. Inside a callback chain, the release usually happens inside an async completion handler running on a different worker. NeoLock models this directly: acquire returns a token via callback, release happens via that token.
  2. VT-friendly under all JDKs. On JDK 21 with VTs, synchronized used to pin the carrier thread; JEP 491 (JDK 24) removes that constraint, but NeoLock never had it.

The continuation that acquire(long, org.ores.async.Asyncc.IAsyncCallback<org.ores.async.Unlock, java.lang.Object>) fires is conventionally named c in code examples (short for continuation). It receives the Unlock token instead of a value.

Usage

   NeoLock lock = new NeoLock("inventory");

   lock.acquire((err, unlock) -> {
       try {
           // critical section. Safe to await async work inside here; the lock is held until
           // unlock.releaseLock() is called.
           mutateInventory(itemId);
       } finally {
           unlock.releaseLock();
       }
   });
 

For synchronous critical sections, use withLock(Runnable) which auto-releases on normal return and on exception, eliminating the finally{} boilerplate:

   lock.withLock(() -> mutateInventory(itemId));
   // even if mutateInventory throws, the lock is released.
 

For a critical section that awaits async work:

   lock.acquire((err, unlock) -> {
       fetchCurrentBalance(accountId, (e, balance) -> {
           updateBalance(accountId, balance.subtract(amount), (e2, ok) -> {
               unlock.releaseLock(); // released after the async update completes
               reply.send(ok);
           });
       });
   });
 

Non-reentrance

NeoLock is not reentrant. Calling acquire(long, org.ores.async.Asyncc.IAsyncCallback<org.ores.async.Unlock, java.lang.Object>) from inside an acquire callback that has not yet been released will enqueue the new request behind the current holder — if the current holder is the same logical "owner" waiting on its own release, the callback chain stalls indefinitely. There is no general way to detect this in an async/callback world (release may be on a different thread or scheduled later); if you need a defensive check, use tryAcquire() which returns immediately when the lock is held.

VT-pinning audit (JDK 21 - 23)

Internally NeoLock uses synchronized (this) blocks to guard the locked flag and the waiter queue. On JDK 21 - 23, synchronized pins the carrier thread while the monitor is held; on JDK 24+ ([JEP 491]) it does not. The critical sections are microseconds (one flag check, one deque push or pop) so the pinning impact is negligible in practice. Audited at v0.2.5 with a 1 000-acquirer stress test (NeoLockFairnessTest#stress_1000_acquirers_no_lost_wakeups) on JDK 17; no observable latency floor or thread-starvation. A future major release may migrate the internal monitor to a ReentrantLock for explicit VT-friendliness on older JDKs.

Concurrency contract

  • FIFO fairness: acquirers receive the lock in the order they called acquire(...). Pinned by NeoLockFairnessTest (100 sequential acquirers in order). Internally backed by an ArrayDeque popped from head, pushed to tail.
  • The waiter queue and the locked flag are both guarded by the NeoLock instance's monitor. (Earlier versions split those across two locks, allowing two concurrent acquirers in a tight race — fixed in v0.2.0.)
  • Unlock is a public sibling class (since v0.2.2) so NeoLock is usable from any package.
  • v0.2.5 adds tryAcquire(), acquire(long, Asyncc.IAsyncCallback) with timeout, withLock(Runnable), isLocked(), queueDepth().
See Also:
  • Constructor Details

    • NeoLock

      public NeoLock()
    • NeoLock

      public NeoLock(String name)
  • Method Details

    • getNamespace

      public String getNamespace()
    • isLocked

      public boolean isLocked()
      Snapshot of whether the lock is currently held. Useful for diagnostics / metrics; not a substitute for tryAcquire() in a check-then-act sequence (the lock state may change between the isLocked() read and a subsequent acquire(...) call).
      Since:
      0.2.5
    • queueDepth

      public int queueDepth()
      Snapshot of the number of waiters currently queued behind the holder. Useful for diagnostics and metrics. The count excludes the holder itself.
      Since:
      0.2.5
    • tryAcquire

      public Optional<Unlock> tryAcquire()
      Non-blocking attempt to acquire the lock. Returns an Optional containing an Unlock token if the lock was free and is now held by this caller, or an empty Optional if the lock was already held.

      Callers must Unlock.releaseLock() the token when done. The lock is held until release; subsequent tryAcquire() calls (and queued acquire(...) calls) will be denied/wait respectively until then.

      Since:
      0.2.5
    • acquire

      public void acquire(long timeoutMs, Asyncc.IAsyncCallback<Unlock,Object> cb)
      Acquire the lock with a bounded wait. If the lock is not acquired within timeoutMs milliseconds, fires the callback with a TimeoutException as the error. A pending acquire is cancelled cleanly (removed from the waiter queue) on timeout.

      If timeoutMs <= 0, this is equivalent to tryAcquire(): succeeds immediately if available, fails immediately with TimeoutException otherwise.

      If the lock is granted just as the timeout fires (a race), the lock is released on the caller's behalf and the callback fires with the TimeoutException only once.

      Since:
      0.2.5
    • withLock

      public void withLock(Runnable criticalSection)
      Acquire the lock, run the criticalSection synchronously, release. The lock is released even if the critical section throws — eliminating the standard try / finally / unlock.releaseLock() boilerplate.

      The critical section is synchronous: it runs to completion on the acquire-callback thread, and the lock is released as soon as it returns or throws. If you need to await async work inside a critical section, use acquire(Asyncc.IAsyncCallback) directly so you can decide when to call Unlock.releaseLock().

      Any exception thrown by criticalSection propagates to the caller of the acquire-callback thread after the lock is released. The lock will never leak due to a sync throw.

      Since:
      0.2.5
    • makeUnlock

      public Unlock makeUnlock(boolean isImmediate)
    • acquire

      public void acquire(Asyncc.IAsyncCallback<Unlock,Object> cb)