Concurrency Deep Dive

Lock Ordering and Circular Dependencies

January 31, 2026
5 min read
Featured image for blog post: Lock Ordering and Circular Dependencies

Deadlocks: Sync to Same Queue covered the classic queue reentry deadlock. This one's sneakier: two locks, two threads, wrong order.

Follow along with the code: iOS-Practice on GitHub

The Setup

A bank account with two locks—one for balance, one for transaction logging:

class BankAccount {
    private let balanceLock = NSLock()
    private let transactionLock = NSLock()

    private var balance: Double = 1000.0
    private var transactionHistory: [String] = []

    func transfer(amount: Double, to other: BankAccount) {
        balanceLock.lock()
        defer { balanceLock.unlock() }

        if balance >= amount {
            logTransaction("Sending $\(amount)")
            balance -= amount
            other.receive(amount: amount, from: self)
        }
    }

    private func logTransaction(_ message: String) {
        transactionLock.lock()
        defer { transactionLock.unlock() }

        // Need to read balance for the log
        balanceLock.lock() // đź’€ Already held by caller!
        transactionHistory.append("\(message) - Balance: \(balance)")
        balanceLock.unlock()
    }
}

The Deadlock

Thread A calls transfer():

  1. Acquires balanceLock
  2. Calls logTransaction()
  3. Acquires transactionLock
  4. Tries to acquire balanceLock... but Thread A already holds it

Even with a single thread, this deadlocks. The same thread tries to lock a non-recursive lock it already holds. Instant freeze.

With two threads doing concurrent transfers, you get the classic ordering problem:

  • Thread A: holds balanceLock, wants transactionLock
  • Thread B: holds transactionLock, wants balanceLock

Neither can proceed. Both wait forever.

Why Lock Ordering Matters

When code acquires multiple locks in different orders, you create a circular dependency. Thread A waits for what Thread B holds, and Thread B waits for what Thread A holds. The dependency graph forms a cycle with no escape.

The rule: Always acquire locks in the same order everywhere.

If every thread acquires balanceLock before transactionLock, no circular dependency can form. One thread may wait, but it will eventually proceed.

The Fix: Don't Nest Locks

The cleanest solution is to avoid holding multiple locks simultaneously:

func transfer(amount: Double, to other: BankAccount) {
    let message: String
    let canTransfer: Bool

    balanceLock.lock()
    if balance >= amount {
        balance -= amount
        message = "Sent $\(amount) - Balance: \(balance)"
        canTransfer = true
    } else {
        message = "Insufficient funds"
        canTransfer = false
    }
    balanceLock.unlock()

    // Log after releasing balance lock
    if canTransfer {
        logTransaction(message)
        other.receive(amount: amount, from: self)
    }
}

private func logTransaction(_ message: String) {
    transactionLock.lock()
    defer { transactionLock.unlock() }
    // No need for balanceLock - balance already in message
    transactionHistory.append(message)
}

Key changes:

  1. Capture the balance while holding the lock
  2. Release the lock before calling other methods
  3. logTransaction no longer needs to access balance

Alternative: Lock Ordering Convention

If you must hold multiple locks, establish a strict order:

// Convention: Always acquire balanceLock before transactionLock
func transfer(amount: Double, to other: BankAccount) {
    balanceLock.lock()
    transactionLock.lock()
    defer {
        transactionLock.unlock()
        balanceLock.unlock()
    }

    if balance >= amount {
        balance -= amount
        transactionHistory.append("Sent $\(amount) - Balance: \(balance)")
        other.receive(amount: amount, from: self)
    }
}

Document the order. Enforce it in code review. Every method that touches these locks must follow the convention.

Alternative: Use Actors

Swift actors eliminate these problems entirely:

actor BankAccount {
    private var balance: Double = 1000.0
    private var transactionHistory: [String] = []

    func transfer(amount: Double, to other: BankAccount) async {
        guard balance >= amount else { return }

        balance -= amount
        transactionHistory.append("Sent $\(amount) - Balance: \(balance)")
        await other.receive(amount: amount)
    }

    func receive(amount: Double) {
        balance += amount
        transactionHistory.append("Received $\(amount) - Balance: \(balance)")
    }
}

Actors provide mutual exclusion automatically. No locks to manage, no ordering to remember. The compiler ensures thread safety.

Spotting Lock Ordering Issues

In code review, watch for:

  • Methods that acquire multiple locks
  • Nested lock acquisitions (lock inside a method that's called while another lock is held)
  • Recursive calls while holding a lock
  • Callbacks that might acquire locks

The Rule

One lock at a time. If you need multiple locks, acquire them in the same order everywhere. Or better: use actors and let Swift handle it.

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch