Welcome back to our series on building a high-performance vector database. In our last lesson, we took a significant step toward finer-grained concurrency by refactoring parts of our graph to use C11 atomic operations. This allowed us to update simple metadata like the total vector count and the graph's entry point without the overhead of a full read-write lock.
However, our system now employs a hybrid model: fast, lock-free atomics for simple counters and pointers, and a pthread_rwlock_t for complex structural changes. As we move to parallelize graph construction, threads will need to modify connections between multiple nodes, which may require acquiring locks on those nodes. This introduces the risk of one of the most classic and frustrating concurrency problems: deadlock.
This lesson is dedicated to understanding, preventing, and analyzing deadlocks. You will learn about the precise conditions that cause deadlocks to occur and, more importantly, the design patterns and strategies to prevent them from ever freezing your application. For a project focused on high-performance and reliability, mastering these concepts is non-negotiable.
What is a Deadlock?
At its core, a deadlock is a state where two or more threads are stuck in a circular waiting pattern. Each thread is waiting for a resource that is held by another thread in the cycle. Because no thread can release its resource until it acquires the next one, and it can't acquire the next one until it's released, all threads in the cycle are blocked forever. The program hangs.
This scenario is perfectly illustrated by the following diagram, which shows two threads each holding one lock while needing the lock held by the other.

A deadlock isn't guaranteed to happen every time you run a vulnerable program. It depends on the precise timing and scheduling of the threads by the operating system. This non-determinism makes deadlocks particularly difficult to debug.
This video provides an excellent walkthrough of a deadlock scenario in C++. The concepts are directly applicable to our C and Pthreads context, as std::mutex is analogous to pthread_mutex_t.
First, watch the segment from the explanation of how a reversed lock acquisition order between two threads creates the potential for deadlock. Then, see how adding a small sleep can make the deadlock reliably reproducible by controlling the thread timing, from this demonstration.
Here is a minimal C program using pthreads that demonstrates the same potential for deadlock.
Detecting, Managing, and Preventing Deadlocks in C/C++
The introduction to this article presents a concise C code example of a deadlock.
Read the section from the beginning of the code down to the end of the first paragraph of explanation. Notice how s_worker_1 locks locks[0] then locks[1], while s_worker_2 does the reverse.
The Four Necessary Conditions for Deadlock
Deadlocks don't happen by chance; they are the result of four specific conditions being met simultaneously. These are often called the Coffman conditions. If you can prevent even one of these conditions from holding true, you can prevent deadlocks.
The foundational textbook "Operating Systems: Three Easy Pieces" offers a clear and formal definition of these conditions.
Please read the short section titled Conditions for Deadlock.
As you've just read, the four conditions are:
- Mutual Exclusion: Resources (in our case, locks) can only be held by one thread at a time. This is fundamental to what mutexes are for, so we generally cannot eliminate this condition without moving to entirely lock-free designs.
- Hold and Wait: A thread holds at least one resource while it is waiting to acquire another resource.
- No Preemption: A resource cannot be forcibly taken away from the thread that holds it. A lock is only released voluntarily by the thread.
- Circular Wait: A set of waiting threads {T1, T2, ..., Tn} exists such that T1 is waiting for a resource held by T2, T2 is waiting for one held by T3, and so on, with Tn waiting for the resource held by T1.
These four conditions form a cycle, as shown in the diagram below. All four must be present for a deadlock to occur.

Strategies for Deadlock Prevention
Since we need all four conditions for a deadlock, we can prevent deadlocks by ensuring at least one of them is never met. Let's explore the most practical strategies, which will be directly applicable to our HNSW graph.
1. Breaking Circular Wait via Lock Ordering
This is by far the most common and effective strategy for preventing deadlocks. The idea is to impose a global order on all locks in the system and require every thread to acquire locks in that predefined order. If every thread follows the same order, a circular wait is impossible.
For example, if the rule is "always lock A before locking B", a situation where one thread holds A and waits for B, while another holds B and waits for A, can never happen.
The OSTEP chapter discusses this technique in detail.
Read the section on preventing circular wait. Pay special attention to the practical programming tip it provides: Enforce Lock Ordering By Lock Address.
This address-based ordering is a fantastic low-level technique. Given your interest in systems programming, you can see how it provides a simple, robust, and efficient way to establish a total order without any complex bookkeeping. When a thread needs to lock two nodes in our HNSW graph, say node_A and node_B, it can do so safely with the following logic:
// Assume node_A and node_B are pointers to structs,
// each containing a pthread_mutex_t member named 'lock'.
if ((uintptr_t)node_A > (uintptr_t)node_B) {
// Acquire locks in high-to-low address order
pthread_mutex_lock(&node_A->lock);
pthread_mutex_lock(&node_B->lock);
} else if ((uintptr_t)node_A < (uintptr_t)node_B) {
// Acquire locks in low-to-high address order
pthread_mutex_lock(&node_B->lock);
pthread_mutex_lock(&node_A->lock);
} else {
// The pointers are the same; just lock once.
pthread_mutex_lock(&node_A->lock);
}
// ... critical section ...
By consistently applying this pattern everywhere multiple locks are required, you eliminate the possibility of circular wait and thus prevent deadlocks.
2. Breaking "No Preemption" via trylock
Another approach is to attack the "No Preemption" condition. While we can't forcibly take a lock from another thread, we can design our code to voluntarily give up a lock if we can't acquire all the ones we need. The pthread_mutex_trylock() function is key here. It attempts to acquire a lock but returns immediately with an error if the lock is already held, instead of blocking.
A thread can use this to implement a "back-off" strategy:
- Lock the first resource, L1.
- Use
trylockon the second resource, L2. - If
trylocksucceeds, the thread holds both locks and can proceed. - If
trylockfails, the thread immediately unlocks L1, waits for a short (often randomized) period, and then retries the entire sequence from the beginning.
This strategy is also covered in the OSTEP chapter.
Read the section on breaking No Preemption to understand the trylock pattern and the new problem it can introduce: livelock.
A livelock is a situation where threads are not blocked, but they are constantly retrying an operation that fails, making no real progress. While technically different from a deadlock, the outcome (a non-functional system) is similar. Randomized back-offs help reduce the probability of livelock. This approach is more complex than strict lock ordering but can be useful in certain dynamic scenarios.
Other Strategies
- Breaking Hold-and-Wait: This involves acquiring all necessary locks in a single, atomic operation. While conceptually simple, it's often impractical. It can require a master lock that serializes all multi-lock acquisitions, hurting concurrency, or requires knowing the complete set of locks a thread will ever need upfront, which is difficult in a dynamic graph.
- Avoiding Mutual Exclusion: As we saw in the last lesson, we can use lock-free data structures built on atomic operations. This is the ultimate solution where applicable, but designing complex, correct, lock-free algorithms for operations like graph rewiring is extremely challenging and often reserved for very specific, well-understood problems.
For our HNSW project, enforcing a strict lock ordering by node address or ID is the most practical and robust solution for preventing deadlocks during concurrent insertions and modifications.
What If a Deadlock Happens Anyway? Debugging with GDB
Even with careful design, bugs happen. When your concurrent application mysteriously hangs, you need a strategy to diagnose the problem. The GNU Debugger (GDB) is an indispensable tool for this.
The general process is to attach GDB to the frozen process and play detective:
- List all threads to see what they are doing.
- Get a backtrace for each thread. You'll likely see several threads stuck inside a system call related to lock acquisition (e.g.,
__lll_lock_waitorfutex_wait). - For each stuck thread, examine the
pthread_mutex_tobject it's trying to lock. On many systems, this structure contains the thread ID of its current owner. - By following this chain of "thread A is waiting for a lock held by thread B," you can uncover the cycle.
Detecting, Managing, and Preventing Deadlocks in C/C++
The article we saw earlier has a fantastic, step-by-step guide to using GDB for this exact purpose.
Read the section titled Inspecting pthread locks using a debugger. Follow the GDB commands and output to see how the author identifies that thread 2 is waiting on a lock owned by thread 3, and thread 3 is waiting on a lock owned by thread 2, thus confirming the deadlock.
This hands-on debugging skill is invaluable. Being able to dive into a running process and inspect low-level synchronization primitives is a hallmark of a proficient systems programmer.
Conclusion
In this lesson, we have demystified one of the most feared problems in concurrent programming. You now have a solid framework for both preventing and diagnosing deadlocks in our HNSW project and beyond.
Key Takeaways:
- A deadlock is a circular waiting dependency where multiple threads are permanently blocked.
- It can only occur if the four conditions of Mutual Exclusion, Hold-and-Wait, No Preemption, and Circular Wait are all met.
- The most practical prevention strategy is to break the Circular Wait condition by enforcing a strict lock acquisition order. Using memory addresses provides a simple and effective ordering mechanism.
- Alternative strategies like using
pthread_mutex_trylockcan break the No Preemption condition but introduce the risk of livelock. - Should a deadlock occur, GDB is your primary tool for diagnosing it by inspecting thread backtraces and the owner fields of
pthread_mutex_tobjects.
In the next module, we will pivot from in-memory concurrency to the equally critical topic of durability and serialization. We have built a fast, concurrent graph structure, but it only exists in RAM. Our next challenge will be to design a way to save this graph to a file and load it back efficiently, ensuring that no data is lost even if the application crashes.