Writing Deadlock-Free Code: Mechanics, Simulation, and Total Resource Ordering

Arpit Bhayani

Arpit Bhayani

Jul 21, 2023 • 8 min read

Play

Writing Deadlock-Free Code: Mechanics, Simulation, and Total Resource Ordering

Deadlocks represent one of the most catastrophic failure modes in multithreaded and concurrent systems. When a program enters a deadlocked state, execution stops entirely, threads hang indefinitely, and the operating system cannot automatically resolve the issue without external intervention—such as terminating threads, aborting transactions, or restarting the entire process.

Understanding how deadlocks manifest and how to architect systems that are provably deadlock-free is an essential systems engineering skill.


1. What is a Deadlock?

A deadlock occurs in a concurrent system when two or more execution units (threads, processes, or database transactions) are blocked indefinitely because each holds a lock on a resource that the other requires, forming an unbreakable cycle of dependency.

The Anatomy of a Deadlock

Consider three threads (T1,T2,T3T_1, T_2, T_3) requiring exclusive locks over three resources (R1,R2,R3R_1, R_2, R_3):

  • T1T_1 holds an exclusive lock on R1R_1, but requires R2R_2 to proceed.
  • T2T_2 holds an exclusive lock on R2R_2, but requires R3R_3 to proceed.
  • T3T_3 holds an exclusive lock on R3R_3, but requires R1R_1 to proceed.
graph LR
    T1((Thread T1)) -->|Holds lock on| R1[Resource R1]
    T1 -->|Waiting for lock on| R2[Resource R2]
    
    T2((Thread T2)) -->|Holds lock on| R2
    T2 -->|Waiting for lock on| R3[Resource R3]
    
    T3((Thread T3)) -->|Holds lock on| R3
    T3 -->|Waiting for lock on| R1

Because every thread is waiting on another thread to release a lock, none can make forward progress. The program hangs indefinitely.

Real-World Implications

  • Databases: Relational databases (e.g., MySQL, PostgreSQL) process concurrent queries across shared rows and tables. Unchecked circular wait states can freeze transactional engines.
  • Web Servers: Multithreaded application servers handling high-throughput connection pools or in-memory caches will lock up if shared state is guarded inconsistently.
  • API / Business Logic: In-memory thread synchronization without deterministic locking patterns inevitably causes sporadic, hard-to-reproduce production freezes.

2. Simulating a Deadlock in Practice

To observe a deadlock in action, consider a simulation written in C (using POSIX threads) mimicking database transactions accessing records.

Setup

  • 3 Shared Records: Simulated as an array of structs, each protected by an individual pthread_mutex_t.
  • 6 Worker Threads: Simulating concurrent incoming transactions.
  • Workload Pattern: Each thread repeatedly picks two distinct random records, attempts to acquire an exclusive lock on each sequentially, performs work (simulated with sleep), and releases the locks.
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>

#define NUM_RECORDS 3
#define NUM_THREADS 6

typedef struct {
    int id;
    int val_a;
    int val_b;
    pthread_mutex_t lock;
} Record;

Record db[NUM_RECORDS];

void acquire_lock(int rec_id, const char *tx_name) {
    printf("[%s] Attempting lock on Record %d\n", tx_name, rec_id);
    pthread_mutex_lock(&db[rec_id].lock);
    printf("[%s] Acquired lock on Record %d\n", tx_name, rec_id);
}

void release_lock(int rec_id) {
    pthread_mutex_unlock(&db[rec_id].lock);
}

void* mimic_transaction(void* arg) {
    char* tx_name = (char*)arg;

    while (1) {
        int rec1 = rand() % NUM_RECORDS;
        int rec2 = rand() % NUM_RECORDS;

        if (rec1 == rec2) {
            continue; // Need two distinct records
        }

        // Unordered Lock Acquisition
        acquire_lock(rec1, tx_name);
        acquire_lock(rec2, tx_name);

        // Simulated compute / transaction processing
        sleep(2);

        release_lock(rec2);
        release_lock(rec1);

        sleep(1);
    }
    return NULL;
}

Execution Behavior

When executed, the program logs several successful lock acquisitions before printing ceases entirely. A check using thread inspection tools confirms that all threads are blocked on pthread_mutex_lock. Thread AA holds Record 11 and waits for Record 22, while Thread BB holds Record 22 and waits for Record 11.

The program is permanently deadlocked.


3. Strategies for Handling Deadlocks

Systems generally employ one of three paradigms when dealing with deadlocks:

StrategyMechanismProsCons
Deadlock Detection & RecoveryAllow deadlocks to happen, detect them via cycles, and terminate victimsHigh concurrency; locks acquired on demandAborted work; complex rollback mechanisms
Deadlock Avoidance (Dynamic Prevention)Inspect resource allocation graphs before each lock acquisitionPrevents hangs dynamicallyRuntime overhead; requires centralized coordinator
Deadlock-Free Code (Static Prevention)Design lock acquisition patterns so cycles are mathematically impossibleZero runtime coordinator overhead; deterministicRequires compile-time knowledge or sorting of resource IDs

Strategy 1: Deadlock Detection and Recovery

In this model, the system maintains a Wait-For Graph (WFG) or Resource Allocation Graph (RAG). A background monitor periodically runs cycle-detection algorithms (e.g., Tarjan’s or Kosaraju’s strongly connected components):

  1. If a cycle is detected, the engine selects a “victim” thread or transaction.
  2. The victim is aborted or killed, releasing its locks and allowing remaining threads to proceed.
  3. Common victim selection heuristics include:
    • Oldest Transaction: Prioritizes completing transactions that have consumed the most compute.
    • Youngest Transaction: Minimizes the cost of rolled-back work.
    • Least/Most Active: Targets threads holding the fewest or most resources.

Drawback: Killing threads or rebooting processes results in degraded user experiences and complex rollback management.

Strategy 2: Dynamic Deadlock Avoidance

Before a thread is granted a lock, the system checks whether granting the request could transition the system into an unsafe state.

For example, relational databases like MySQL InnoDB perform lock wait checks. If a newly requested lock creates an immediate circular wait in the internal graph, the database immediately fails the query with an error (ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction), preventing the entire system from locking up.


4. Writing Deadlock-Free Code: Total Ordering

Rather than reacting to deadlocks after they occur, the cleanest design is to write code that guarantees a deadlock can never form in the first place.

The Root Cause: Out-of-Order Acquisition

A circular dependency requires an out-of-order acquisition pattern. If Thread T1T_1 acquires R1R_1 then R2R_2, while Thread T2T_2 acquires R2R_2 then R1R_1, the cross-acquisition creates the cycle.

T1: Lock(R1) -> Lock(R2)
T2: Lock(R2) -> Lock(R1)   <-- Inversion creates the circular wait

The Solution: Global Total Ordering

A system enforces Total Ordering if all resources are assigned a strict, globally consistent linear order (e.g., by unique ID, memory address, or alphabetical key), and every thread is mandated to acquire locks in ascending order according to that sequence.

If any thread needs resources RAR_A and RBR_B, and A<BA < B, the thread must acquire RAR_A before acquiring RBR_B.

graph TD
    subgraph Total Order: R1 < R2 < R3
        direction LR
        R1[Record 1] --> R2[Record 2] --> R3[Record 3]
    end

Why Total Ordering Eliminates Deadlocks

Suppose two threads T1T_1 and T2T_2 both require access to R1R_1 and R2R_2.

  1. Both T1T_1 and T2T_2 are constrained to acquire R1R_1 first, then R2R_2.
  2. Whichever thread acquires R1R_1 first (say, T1T_1) blocks the other thread (T2T_2) from acquiring R1R_1.
  3. Since T2T_2 is blocked at R1R_1, it cannot proceed to acquire R2R_2.
  4. Therefore, R2R_2 remains completely free for T1T_1 to acquire.
  5. T1T_1 completes its critical section, releases both locks, and unblocks T2T_2.

A circular wait condition becomes mathematically impossible because all lock request edges in the resource graph point strictly in one direction.


5. Implementing Total Ordering in Code

To make our simulation deadlock-free, we add a total ordering constraint before acquiring locks. In our example, records have natural integer identifiers (0, 1, 2). We can guarantee total ordering simply by ensuring the lower identifier is always locked first.

The Fix

void* mimic_transaction_deadlock_free(void* arg) {
    char* tx_name = (char*)arg;

    while (1) {
        int rec1 = rand() % NUM_RECORDS;
        int rec2 = rand() % NUM_RECORDS;

        if (rec1 == rec2) {
            continue;
        }

        // Enforce Total Ordering: Always lock in ascending order of Record ID
        if (rec1 > rec2) {
            int temp = rec1;
            rec1 = rec2;
            rec2 = temp;
        }

        // Lock acquisition is now deterministic across all threads
        acquire_lock(rec1, tx_name);
        acquire_lock(rec2, tx_name);

        // Critical Section
        sleep(2);

        // Release locks
        release_lock(rec2);
        release_lock(rec1);

        sleep(1);
    }
    return NULL;
}

Result

Running this updated routine indefinitely produces continuous, unhalted output. Even with multiple competing threads running indefinitely over a small pool of shared records, execution never stalls.


6. Trade-offs and Real-World Constraints

While total ordering is the gold standard for deadlock prevention, applying it in practice depends on system constraints:

When Total Ordering is Ideal

  • Known Resource Sets: When a batch process or transaction knows all resources it needs prior to acquisition (e.g., batch transfers between bank accounts where you can sort account IDs: lock(min(acc1, acc2)); lock(max(acc1, acc2));).
  • Hierarchical System Resources: Operating system kernels frequently establish lock hierarchies (e.g., process lock must always precede memory map lock).

When Total Ordering is Hard

  • Ad-Hoc Relational Queries: In a transactional database, rows to be updated may depend on the results of intermediate reads. A database engine cannot always predict which rows an arbitrary query will touch next, making upfront sorted acquisition difficult.
  • In such scenarios, systems fall back to dynamic prevention (wait-die / wound-wait schemes) or periodic detection via wait-for graphs.
Arpit Bhayani

Principal Engineer II at Razorpay - building Agent Studio, Ex-staff engg at GCP Memorystore & Dataproc, Creator of DiceDB, ex-Amazon Fast Data, ex-Director of Engg. SRE and Data Engineering at Unacademy. I spark engineering curiosity through my no-fluff engineering videos on YouTube and my courses