How do I use multiple MutexLocks correctly in EK9 — when do I need one big lock vs many small ones?

← Concurrency · Ref: Q1300

EK9 supports multiple MutexLocks — but you have to design them so the compiler can prove the program is deadlock-free. There are three idiomatic patterns; pick based on whether the resources need atomic coordination together.

PATTERN 1: ONE LOCK PER INDEPENDENT CONCERN

Use multiple locks when two parts of your state are independent — no operation ever needs both. Each method touches exactly one lock; locks are never nested across concerns.

  defines component
    InventorySystem
      orderLock as MutexLock of Integer: MutexLock(Integer(0))
      inventoryLock as MutexLock of Integer: MutexLock(Integer(0))
      placeOrder() ... require orderLock.enter(orderKey)
      adjustInventory() ... require inventoryLock.enter(invKey)

The deadlock detector sees no cross-lock nesting — no precedence-graph edges, no possible cycle. This is the cleanest pattern when your domain naturally separates.

PATTERN 2: SEQUENTIAL ACQUISITION (RELEASE THEN ACQUIRE)

When one function needs two locks at different times in its body, acquire and release each in turn — never hold both at once. The MutexKey callback completes (and releases) BEFORE the next enter() begins.

  fn()
    ->
      lockA as MutexLock of Integer
      lockB as MutexLock of String
    keyA <- () is MutexKey of Integer as function
      //work with lockA
    keyB <- () is MutexKey of String as function
      //work with lockB
    require lockA.enter(keyA)   //completes and releases
    require lockB.enter(keyB)   //independent of lockA

No overlap = no cycle. Works as long as the two operations are truly independent (no shared invariant that needs to span both critical sections).

PATTERN 3: ONE LOCK OVER A UNIFIED RECORD

When two resources MUST be mutated atomically together — the classic 'transfer money from A to B' case — wrap them in a record and protect with a single MutexLock. The MutexKey body mutates all fields atomically; you never need two locks.

  defines record
    AccountPair
      sourceBalance as Integer: 100
      targetBalance as Integer: 0
      default operator ?
  defines component
    TransferService
      accountLock as MutexLock of AccountPair: MutexLock(AccountPair())
      transfer()
        -> amount as Integer
        key <- (amount) is MutexKey of AccountPair as function
          lockedItem.sourceBalance: lockedItem.sourceBalance - amount
          lockedItem.targetBalance: lockedItem.targetBalance + amount
        require accountLock.enter(key)

Granularity trade-off: this serialises every transfer through one lock. If contention is real, decompose differently — per-transaction-id locks, optimistic concurrency with version numbers, or partition by account-range. But measure first; coarse locking is fine for most workloads.

WHAT TO AVOID

The pattern EK9 deliberately refuses to compile: nest one lock inside another's MutexKey body in a way that creates a cycle elsewhere in the program. Example to AVOID:

  transferAtoB() ... lockA.enter(... lockB.enter(...))   //order A → B
  transferBtoA() ... lockB.enter(... lockA.enter(...))   //order B → A — cycle!

The two functions together form a lock-order cycle: T1 running transferAtoB while T2 runs transferBtoA can deadlock. EK9 fires E08252.

SAME-TYPE LOCKS ON DIFFERENT OBJECTS (E08255) — REJECTED EVEN WITHOUT A REVERSE SITE
A subtler trap: nesting two locks of the SAME type whose objects are caller- or runtime-determined — the same lock field on two different objects (from.getLock() then to.getLock(), two instances of one class, dining-philosophers forks, collection elements), or two different same-type MutexLock parameters. Because the objects are interchangeable at the call site, a SINGLE static direction can instantiate as both orders at runtime — so EK9 rejects it with E08255 (UNPROVABLE_LOCK_ORDER) even when no opposite-order site exists. There is no 'always lock the lower-address one first' escape; the only fix is Pattern 3 (one lock over an owning record).

DURABLE OR CROSS-INSTANCE STATE IS A TRANSACTION CONCERN, NOT A MUTEX CONCERN
If the data you are trying to coordinate is durable (database rows) or shared across processes/instances, an in-memory MutexLock cannot protect it at all — that is a transaction concern (use the database's transaction / optimistic-locking facilities), not something a lock can solve.

The acquisition-order convention pattern ('always lockA before lockB') is NOT trusted by the compiler. Even if your team is disciplined, the convention can break under refactoring pressure. The compiler wants the constraint enforced by code structure (one of the three patterns above), not by developer discipline.

WHEN TO USE WHICH

- Independent concerns, no joint atomicity → Pattern 1.
- Two operations in one method but no overlapping atomicity → Pattern 2.
- Joint atomicity required (one resource depends on another being valid) → Pattern 3.

See Q158 for MutexLock basics (which shows the canonical LockableAddressSet pattern). See Q1298 for E08252 cycle errors. See Q1299 for E08253 cross-thread errors.

Example

defines module qa.concurrency.multi.lock.patterns

  defines record

    //Pattern 3 demo: unified record under one lock for joint atomicity.
    AccountPair
      sourceBalance as Integer: 100
      targetBalance as Integer: 0
      default operator ?

  defines component

    TransferService
      accountLock as MutexLock of AccountPair: MutexLock(AccountPair())

      transfer()
        -> amount as Integer

        transferKey <- (amount) is MutexKey of AccountPair as function
          lockedItem.sourceBalance: lockedItem.sourceBalance - amount
          lockedItem.targetBalance: lockedItem.targetBalance + amount

        require accountLock.enter(transferKey)

      default operator ?

    //Pattern 1 demo: two locks for two independent concerns. Neither
    //method ever holds both — no precedence-graph edges, no cycle.
    OrderInventory
      orderLock as MutexLock of Integer: MutexLock(Integer(0))
      inventoryLock as MutexLock of Integer: MutexLock(Integer(0))

      placeOrder()
        ->
          orderId as Integer
        orderKey <- (orderId) is MutexKey of Integer as function
          lockedItem :=: lockedItem + orderId
        require orderLock.enter(orderKey)

      adjustInventory()
        ->
          delta as Integer
        invKey <- (delta) is MutexKey of Integer as function
          lockedItem :=: lockedItem + delta
        require inventoryLock.enter(invKey)

      default operator ?

  defines program

    MultiLockPatterns()
      stdout <- Stdout()

      //Pattern 3: joint atomicity through one lock.
      service <- TransferService()
      service.transfer(25)
      stdout.println("Pattern 3: transfer complete")

      //Pattern 1: independent concerns, two locks, no nesting.
      orders <- OrderInventory()
      orders.placeOrder(42)
      orders.adjustInventory(-1)
      stdout.println("Pattern 1: independent operations complete")
Other ways to ask this
  • When to use multiple MutexLocks in EK9?
  • How to decide one lock vs many locks?
  • Coarse vs fine-grained MutexLock design
  • Best practices for multiple locks in EK9
  • How to coordinate multiple locks safely?
  • Multi-lock patterns to avoid deadlock

Coming from another language?

Java: developers manage lock ordering by convention (e.g. always grab the lower-hashcode lock first). Tools like FindBugs/SpotBugs detect a subset; most acquisition-order bugs are caught only at runtime or via stress testing. Go / Rust: same convention-driven discipline; no language-level prevention. EK9: three structural patterns that the compiler can verify; the convention-driven approach is rejected because it doesn't survive refactoring.

Keywords: deadlock, atomic, transfer, MutexLock, locks, best, mutex, design, fine, practice, grained, concurrent, structure, coarse, pattern, multiple