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