Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 46

Implementing Reader-Writer Locks for Concurrent Graph Access

Recommended resources

Welcome back! In our last lesson, you successfully built a thread pool, a fundamental component for high-performance concurrent programming. By parallelizing the insertion of vectors, you've already taken a significant step toward making our HNSW graph construction much faster.

This brings us to a new and important challenge. A production-grade vector database needs to be available for searches even while it's being updated. If we have multiple threads searching the graph while your thread pool is concurrently inserting new vectors, we open the door to serious race conditions. A search thread might traverse an edge that an insertion thread is in the middle of deleting, leading to corrupted results or a program crash.

Today, you'll learn how to solve this classic "readers-writers problem" using a specialized synchronization primitive: the POSIX read-write lock, or pthread_rwlock_t. By the end of this lesson, you will be able to protect your HNSW graph in a way that allows many simultaneous searches to happen concurrently, while ensuring that any modifications to the graph get exclusive, safe access.

The Problem with Simple Mutexes

Your first instinct might be to protect the entire graph with the pthread_mutex_t you're already familiar with. Before any search or insertion, a thread would lock the mutex, and unlock it upon completion. This would certainly guarantee correctness and prevent race conditions.

However, for a system where performance is paramount, this is a deeply unsatisfying solution. Searches are read-only operations. If ten threads want to search the graph simultaneously, there's no logical reason they shouldn't be able to. Yet, a simple mutex would force them into a queue, executing one by one, destroying any possibility of read parallelism. Given that vector databases are typically "read-mostly" (far more searches than insertions), this would create a massive performance bottleneck.


The Read-Write Lock Solution

A read-write lock is designed precisely for this scenario. It offers a more nuanced approach than a simple mutex by allowing two different kinds of locks: a "read lock" and a "write lock".

The rules are as follows:

  1. Any number of threads can hold a read lock simultaneously.
  2. Only one thread can hold a write lock at any given time.
  3. If a thread holds a write lock, no other thread (reader or writer) can acquire a lock.
  4. If any thread holds a read lock, a thread trying to acquire a write lock will block until all readers have released their locks.

This perfectly matches our requirements: we can have many concurrent searches (readers), but an insertion (a writer) gets exclusive access to modify the graph safely.

Using read write locks (example in C)

To see this concept in action, watch this excellent video by Jacob Sorber. It provides a C demonstration that walks through the exact problem we're discussing.

Watch the entire video. It's a clear, practical demonstration that covers: The problem: Demonstrating how concurrent reads and writes without protection lead to corrupted data. The mutex solution: Showing how a pthread_mutex_t fixes the corruption but needlessly serializes the readers, hurting performance. The read-write lock solution: Implementing a pthread_rwlock_t and showing how it correctly allows multiple readers to proceed in parallel. Trade-offs: A concluding discussion on when to use each type of lock.


The pthread_rwlock_t API

Now that you have a conceptual and practical understanding, let's look at the formal API you'll be using. A read-write lock is a distinct type, pthread_rwlock_t, and it has its own set of functions.

Chapter 11. Threads - Shichao's Notes

This section from "Shichao's Notes" provides a concise overview of the API.

Please read the section titled Reader–Writer Locks. Focus on the three states of the lock and the five key functions presented: pthread_rwlock_init, pthread_rwlock_destroy, pthread_rwlock_rdlock, pthread_rwlock_wrlock, and pthread_rwlock_unlock.

As you read, the core functions to note are:

  • pthread_rwlock_rdlock(): Acquires a read lock. It will block if a writer currently holds the lock.
  • pthread_rwlock_wrlock(): Acquires a write lock. It will block if any thread (reader or writer) currently holds the lock.
  • pthread_rwlock_unlock(): Releases either a read or write lock. The system knows which type of lock the thread holds.

Integrating the Lock into the HNSW Graph

Now, let's apply this to our project. You will add a pthread_rwlock_t directly to your main graph data structure.

typedef struct {
    // ... other graph metadata like entry_point, max_layer, etc. ...
    
    memory_arena_t node_pool; // Pool for allocating nodes
    
    // The new lock to protect the entire graph
    pthread_rwlock_t lock;
    
} hnsw_index_t;

When you initialize your graph, you will also initialize this lock:

// In your hnsw_create function
int hnsw_init(hnsw_index_t *graph, ...) {
    // ... other initializations ...
    
    if (pthread_rwlock_init(&graph->lock, NULL) != 0) {
        // Handle error
        return -1;
    }
    
    return 0;
}

Now, you can protect your search and insert functions. The search function is a "reader" because it only traverses the graph, while the insert function is a "writer" because it modifies nodes and their connections.

Chapter 11. Threads - Shichao's Notes

The same resource provides a concrete code example that is highly analogous to our situation.

Examine the code under the linked file threads/rwlock.c. In this example, the job_find function is a "reader," and the job_insert, job_append, and job_remove functions are "writers." This maps directly to our hnsw_search and hnsw_insert functions. Notice how job_find uses rdlock, while the modifying functions all use wrlock.

Following this pattern, your graph operations will be structured like this:

Search Function (Reader)

void hnsw_search(hnsw_index_t* graph, const float* query_vector, size_t k, result_t* results) {
    // Acquire a read lock. Multiple searches can enter this section concurrently.
    pthread_rwlock_rdlock(&graph->lock);
    
    // ...
    // The entire search logic, from the top layer down to layer 0,
    // happens here while the lock is held.
    // ...

    // Release the read lock.
    pthread_rwlock_unlock(&graph->lock);
}

Insertion Function (Writer)

void hnsw_insert(hnsw_index_t* graph, const float* vector) {
    // Acquire a write lock. Only one insertion can happen at a time,
    // and all searches will wait.
    pthread_rwlock_wrlock(&graph->lock);
    
    // ...
    // The entire insertion logic, including layer selection,
    // finding neighbors, and updating connections, happens here.
    // ...

    // Release the write lock, allowing waiting readers and writers to proceed.
    pthread_rwlock_unlock(&graph->lock);
}

Performance Impact

The benefit of this approach is significant, especially for read-heavy workloads. Let's look at some data.

[PDF] High-Performance Computing

This university lecture slide deck provides performance measurements comparing different locking strategies for a linked list, which is a good proxy for our graph structure.

First, briefly review the code snippets in the section Protecting the Linked-List Functions to see how rdlock and wrlock are applied. Then, focus your attention on the performance tables. Compare Table 4.3 (99.9% read operations) and Table 4.4 (80% read operations). In Table 4.3, notice how the "Read-Write Locks" implementation time decreases as you add more threads (from 1 to 4), showing excellent scalability. In contrast, the "One Mutex for Entire List" time doubles when going from 1 to 2 threads, highlighting the exact bottleneck we discussed.

The data is clear: for read-mostly scenarios, a read-write lock is vastly superior to a single mutex.

This chart shows the raw time cost for locking and unlocking various primitives. Note that read-write locks (`PthreadRWLock_ReadLock` and `PthreadRWLock_WriteLock`) have more inherent overhead than simpler locks like a `PthreadMutex`. This is the price paid for their advanced functionality. Your choice of lock is always a trade-off between features and raw overhead.

A final point on performance is the issue of writer starvation. The POSIX standard doesn't specify whether new readers should be blocked if a writer is waiting. In many common implementations (which are "read-preferring"), a continuous stream of incoming read-lock requests can indefinitely prevent a writer from acquiring the lock. While not something you need to solve right now, it's a critical concept in high-performance systems design, where fairness policies might be needed to guarantee writers eventual access.


Conclusion

In this lesson, you've added a crucial piece of synchronization to our concurrent vector database. By understanding the readers-writers problem, you've moved beyond simple mutexes to a more sophisticated and performant locking strategy.

Key Takeaways:

  • Protecting a read-mostly data structure with a single mutex creates a severe performance bottleneck by serializing all accesses.
  • A read-write lock (pthread_rwlock_t) solves this by allowing concurrent access for multiple readers but exclusive access for a single writer.
  • You apply this to your HNSW graph by taking a read lock (pthread_rwlock_rdlock) for search operations and a write lock (pthread_rwlock_wrlock) for insertion/modification operations.
  • Performance data confirms that for read-heavy workloads, read-write locks provide significantly better scalability than simple mutexes.

The pthread_rwlock_t we've implemented is a "coarse-grained" lock—it locks the entire graph. While effective, it can still be a point of contention. In our next lesson, we will explore an even more granular approach. We will investigate C11 atomic types (<stdatomic.h>), which allow us to perform certain simple operations (like incrementing a counter) in a thread-safe way without using any locks at all. This will open the door to further, fine-grained performance optimizations.