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