Skip to content

ObjectPool: synchronize mutating methods with a Mutex on threaded targets#2058

Open
AxGord wants to merge 2 commits into
openfl:developfrom
soccertutor:bugfix/objectpool-thread-safety
Open

ObjectPool: synchronize mutating methods with a Mutex on threaded targets#2058
AxGord wants to merge 2 commits into
openfl:developfrom
soccertutor:bugfix/objectpool-thread-safety

Conversation

@AxGord
Copy link
Copy Markdown

@AxGord AxGord commented May 11, 2026

Summary

lime.utils.ObjectPool mutates shared state (__pool, __inactiveObject0/1,
__inactiveObjectList, activeObjects, inactiveObjects) from get /
release / add / remove / clear / size-setter without
synchronization. When two threads drive the same pool concurrently, the
counters drift and the same instance can be handed out twice.

This PR adds a sys.thread.Mutex guarded behind #if target.threaded so
single-threaded targets (js, flash) keep the existing zero-overhead path,
and adds unit tests covering both the regular API surface and the
multi-thread regression.

The bug

Both get() and release() decrement-and-increment activeObjects /
inactiveObjects and reassign __inactiveObject0/1 without locking.
Classic interleaving:

  • Thread A reads inactiveObjects > 0 → true, enters __getInactive().
  • Thread B reads inactiveObjects > 0 → still true (A hasn't decremented yet),
    also enters __getInactive().
  • Both threads pull __inactiveObject0, the same instance is returned to
    both callers; or inactiveObjects is double-decremented and ends up negative.

Fix

  • Add sys.thread.Mutex __mutex field under #if target.threaded.
  • Wrap the mutating public methods (add, clear, get, release,
    remove, set_size) with two inline helpers __lock() / __unlock()
    that compile to no-ops on non-threaded targets.
  • Internal helpers __addInactive / __getInactive / __removeInactive
    are only called from already-locked public paths — they don't lock again.

One small behaviour change

In #if debug mode release() now also calls __unlock(); return; after
each Log.error so a release with an invalid object can't continue into
activeObjects-- and __pool.remove(object). Without the early return,
the function continued and corrupted pool state further past the detection.
Only observable in debug builds.

Tests

New tests/unit/src/utils/ObjectPoolTest.hx (registered in TestMain.hx):

  • Eight single-threaded functional tests: get/release reuse, size cap,
    clean callback, clear, remove, size-setter pre-fill.
  • One multi-thread regression testConcurrentGetReleaseDoesNotDriftCounters
    guarded by #if target.threaded: 8 worker threads × 1000 get/release
    iterations on a 4-slot pool, with a 5-second deadline in the waiter
    loop so the test fails cleanly if pool state corrupts (no CI hang).

Result on neko (un-mutexed run is from develop, run via temporary checkout):

Variant Counters after run testConcurrentGetReleaseDoesNotDriftCounters
unpatched active=5, inactive=-1 (typical) FAIL (counter drift)
patched active=0, inactive=4 (every run) PASS

Total: 9 tests, 23 assertations, ~11 ms on neko in both builds.

Risk

  • Non-threaded targets (js, flash) — zero runtime cost. __lock() /
    __unlock() are inline and their bodies are #if target.threaded-gated,
    so every call site (get, release, add, clear, remove, set_size)
    compiles away completely. Verified by inspecting generated JS: no
    __lock / __unlock / __mutex references at any call site. Two
    empty function definitions remain in the output (~40 bytes of dead code,
    never invoked) — happy to #if-gate those away too if reviewers prefer
    zero bytes.

  • Threaded targets — single-thread use pays an uncontended-lock cost.
    Benchmarked on neko (5M get/release iterations, single thread, 64-slot
    pool): unpatched ~330 ns per get/release pair, patched ~565 ns — about
    +70% on neko, where Mutex is heavy. On cpp/hl with native
    uncontended mutex (std::mutex / pthread futex) the cost is typically
    in the low-tens-of-ns per acquire/release, so the percent overhead is
    much smaller. In real OpenFL hot paths (Event pool, Graphics
    shaderBuffer pool, etc.) the call rate is ~tens-to-hundreds per frame,
    so the absolute cost stays in the microsecond range per frame even on
    neko.

  • Multi-thread use is the entire point of the PR. Without the mutex,
    the test above demonstrates the pool ends up with inactiveObjects=-1
    and hands out duplicates; the patched version is stable.

  • If reviewers want to keep single-thread users opt-out, I'm happy to
    add a -D lime_objectpool_no_mutex compile flag in a follow-up.

AxGord added 2 commits May 11, 2026 21:50
ObjectPool's get/release/remove/add/clear/set_size methods mutate
shared state (__pool map, __inactiveObject0/1, __inactiveObjectList,
activeObjects, inactiveObjects) without synchronization. When OpenFL
or user code drives the same pool from multiple hxcpp threads
(software renderer + GL renderer, or any custom thread pool), the
counters drift and the inactive-object slots can hand the same
instance to two callers.

This change adds a sys.thread.Mutex (guarded behind #if target.threaded
so js / flash builds keep the existing zero-overhead path) and wraps
the public mutating methods with __lock()/__unlock() inline helpers.
The internal __addInactive / __getInactive / __removeInactive
helpers are not locked separately — they are only ever called from
already-locked public paths.

In #if debug release() now also unlocks and returns after Log.error,
matching the intent of the existing guard (don't decrement counters
for an invalid object). Without the early return the function continued
into activeObjects-- and __pool.remove on an invalid argument,
corrupting pool state further.
Covers the API surface of lime.utils.ObjectPool with single-threaded
functional tests (get/release reuse, size cap, clean callback, clear,
remove, size setter pre-fill) and one multi-thread stress test guarded
by #if target.threaded.

The threaded test runs 8 worker threads x 1000 get/release iterations
on a 4-slot pool, with a 5-second deadline in the main waiter loop so
that pool-state corruption surfaces as a hard test failure instead of
hanging the CI run. On the un-mutexed (pre-patch) ObjectPool the
counters drift past the pool size (typical observation: active=5
inactive=-1 after all workers complete); on the mutex-guarded version
the counters return to active=0 inactive=POOL_SIZE every run.

Registered in TestMain.hx alongside the existing utils.* test cases.
@joshtynjala
Copy link
Copy Markdown
Member

I wonder if it would make sense to add a separate ThreadSafeObjectPool.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants