I got E08253 CROSS_THREAD_SAME_LOCK — what is the thread boundary issue and how do I fix it?

← Concurrency · Ref: Q1299

E08253 fires when the SAME MutexLock is acquired on TWO DIFFERENT threads with the outer hold still in scope. Even though MutexLock is reentrant on the SAME thread (re-entering legally), it is NOT reentrant across threads — the second thread blocks waiting for the first to release. If the first thread is itself waiting on the second to complete (very common with async dispatch), they deadlock.

WHERE THE THREAD BOUNDARY COMES FROM

EK9 creates a thread boundary at three syntactic points:

  1. TCP.accept(handler) — accept loop dispatches the handler on a new connection thread.
  2. Signals.register(name, handler) — signal dispatcher invokes the handler from its dispatch thread.
  3. Stream pipelines with `| async` — the async stage dispatches each element to a thread-pool worker.

Anywhere else (regular function calls, the `| call` stream stage) stays on the same thread.

THE COMMON REFACTORING TRAP

The deadlock often appears after refactoring. A method holds a lock, pushes some work into a helper, the helper grows, eventually someone 'speeds things up' by adding `| async`. The async worker captures a reference to the lock and tries to enter it — by then no one remembers the outer hold is still active. EK9 catches this through the substitution chain even when the helper is many call frames deep.

THREE WAYS TO FIX

1. CHANGE `| async` TO `| call` IF THREAD POOLING ISN'T NEEDED
The `| call` stream stage runs each element on the same thread synchronously. Same lock reacquisition is reentrant and legal:

  outerKey <- (sharedLock) is MutexKey of Integer as function
    cat workers | call | map with toLine > stdout   //SAFE — same thread
  require sharedLock.enter(outerKey)

2. MOVE THE ENTER() OUT OF THE ASYNC BOUNDARY

If you genuinely need async parallelism, don't have the worker reacquire the same lock. Do the locked work BEFORE dispatching to async, then pass the immutable result through the pipeline.

  preparedItems as List of Item: List()
  key <- (preparedItems) is MutexKey of Integer as function
    preparedItems += extractSnapshot()   //all locked work here
  require sharedLock.enter(key)
  cat preparedItems | async with processItem   //no lock needed across boundary

3. USE A DIFFERENT LOCK PER WORKER

If the async workers must protect shared state, give each worker its OWN lock (different MutexLock for that concern), and never hold the outer lock when the workers run. The outer pipeline coordinates without the contended lock.

WHY EK9 DETECTS THIS

Classic Java code that uses ExecutorService.submit() with a synchronized block on the same lock will hang at runtime. The bug is often only visible under load. EK9 makes the thread-boundary visible in the syntax — `| async` is the keyword — and propagates lock identity through call chains and substitution, so the compiler catches the reacquisition even when the worker is several refactoring layers away from the outer hold.

See Q158 for MutexLock basics (which shows the canonical LockableAddressSet pattern). See Q159 for stream pipelines with async. See Q1298 for the same-thread lock-order cycle (E08252). See Q1300 for multi-lock design patterns.

Example

defines module qa.concurrency.cross.thread.fix

  defines function

    asyncWorkerFn() as abstract
      <- rtn as Boolean?

    toLine() as pure
      -> ok as Boolean
      <- rtn as String: $ok

    //Canonical fix: do the locked work BEFORE the async boundary; the
    //resulting snapshot is immutable enough to pass through the pipeline
    //without needing the lock. Workers run on different threads but never
    //touch the outer hold.
    crossThreadSafe()
      -> sharedLock as MutexLock of Integer

      stdout <- Stdout()
      snapshot as Integer: Integer()

      readKey <- (snapshot) is MutexKey of Integer as function
        snapshot :=: lockedItem
      require sharedLock.enter(readKey)

      results <- [ true, false, true ]
      cat results | map with toLine > stdout

Common mistakes

E08253 — The canonical fix does all locked work BEFORE the async boundary and passes immutable data through the pipeline. The incorrect form captures `sharedLock` into a worker and calls `sharedLock.enter(innerKey)` inside it, while `cat workers | async` dispatches that worker to a thread-pool thread and the outer still holds the lock via `sharedLock.enter(outerKey)`. MutexLock is reentrant only on the holding thread, so the worker blocks cross-thread and — if the caller waits on the pipeline — both deadlock. Use `| call` (same thread) if you must reacquire, or keep the lock off the async workers entirely. See ek9 -h E08253.

Incorrect:

worker <- (sharedLock) is asyncWorkerFn as function
        innerKey <- () is MutexKey of Integer as function
          stdout <- Stdout()
          stdout.println(lockedItem)
        rtn: sharedLock.enter(innerKey)
      outerKey <- (worker) is MutexKey of Integer as function
        stdout <- Stdout()
        workers <- [ worker ]
        cat workers | async | map with toLine > stdout
      require sharedLock.enter(outerKey)

Correct:

stdout <- Stdout()
      snapshot as Integer: Integer()

      readKey <- (snapshot) is MutexKey of Integer as function
        snapshot :=: lockedItem
      require sharedLock.enter(readKey)

      results <- [ true, false, true ]
      cat results | map with toLine > stdout
Other ways to ask this
  • How do I fix E08253 cross-thread deadlock?
  • Same lock across thread boundary error in EK9
  • Why does | async cause deadlock with MutexLock?
  • Signals handler cannot reacquire same lock
  • TCP handler deadlocks on outer lock
  • MutexLock not reentrant across threads

Coming from another language?

Java: ExecutorService.submit() with a synchronized block on the same monitor — deadlocks at runtime, often only under load. Goroutines / channels — easy to spawn but lock interactions are the developer's responsibility. Python asyncio — explicit await; lock acquisition across awaits can deadlock with no compile-time warning. EK9: thread boundaries are syntactic (`| async`, TCP.accept, Signals.register); the compiler tracks the lock identity through any depth of call chain and refuses to compile if the same lock would be acquired on a different thread while still held.

Keywords: refactor, deadlock, reentrant, boundary, concurrency, async, E08253, fix, signal, cross-thread, MutexLock, call, handler, TCP, thread