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)
      default operator ?

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.

A CONSISTENT ORDER OVER DISTINCT LOCK FIELDS IS ACCEPTED

Nesting two DIFFERENT lock fields in one direction everywhere ('always lockA before lockB') compiles and runs: the compiler can prove the order, so there is no cycle. Only a site that contradicts that order (E08252) or nesting two interchangeable same-type locks (E08255) is rejected. The compiler checks the order itself - it does not rely on developer discipline, so a later refactoring that adds a reverse-order site is caught immediately.

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 (the Counter field-on-class 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; a consistent lock order over distinct locks is accepted and any contradicting order is rejected by the compiler, so it does not depend on convention.

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