Skip to main content

Welcome to a Grasp lesson

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

Lesson 30

Fine-Grained Locking for Linked Lists

Recommended resources

Hello! Welcome to your next lesson on building high-performance, thread-safe data structures.

In our last session, we conducted a thorough analysis of coarse-grained locking for linked lists. We concluded that while simple, using a single lock for the entire list creates a severe sequential bottleneck, leading to poor scalability and even performance degradation as thread count increases. This happens because only one thread can operate on the list at a time, regardless of whether the operations conflict.

Today, we will design the solution to this problem. Our learning outcome is to design a fine-grained locking strategy for a linked list using 'hand-over-hand' locking, also known as lock coupling. This powerful technique allows multiple threads to safely and concurrently operate on different parts of the list, breaking the bottleneck we identified and paving the way for true parallelism.

The Core Idea: From One Lock to Many

The fundamental problem with coarse-grained locking is the single point of contention. The solution, therefore, is to distribute the locking mechanism across the data structure itself.

Fine-grained locking for a linked list means we associate a dedicated mutex with each individual node. This immediately opens up the possibility for concurrent operations. For example, one thread could be working on nodes at the beginning of the list, while another thread works on nodes near the end, each holding different locks.

Let's begin by reading a short introduction to this concept and the specific protocol we'll be using.

COLUMNS

The following text from the article 'Programming Workbench: Hand-Over-Hand Locking for Highly Concurrent Collections' by Ryan Kelly introduces the concept of fine-grained locking for linked lists and names the specific access discipline required to make it work correctly.

Please read the first paragraph of the PDF, which starts with 'Fine-grained locking for a linked list...'. Focus on the definition of fine-grained locking and the name of the 'correct access discipline' that we must study.

As the text states, the strategy we need is hand-over-hand locking. Simply having a lock per node is not enough; we need a strict protocol for acquiring and releasing these locks to prevent race conditions and ensure correctness.

The first step in our design is to modify the data structure itself. Each node must contain its own mutex.

typedef struct node {
    pthread_mutex_t m;  // The mutex protecting this specific node
    int             data;
    struct node*    next;
} node_t;

With this structure, we have the building blocks for our fine-grained strategy. Now, we must define the rules for using these locks.


The "Hand-Over-Hand" Locking Protocol

Imagine climbing a ladder or crossing monkey bars. To move forward safely, you grab the next rung before letting go of the one you are currently holding. Hand-over-hand locking in a linked list works on the exact same principle.

To traverse from a predecessor node (pred) to a current node (curr), a thread must:

  1. First, hold the lock on pred.
  2. Then, acquire the lock on curr.
  3. Only after successfully acquiring the lock on curr, release the lock on pred.
Hand-Over-Hand Locking in a Linked List
This diagram illustrates the core concept of hand-over-hand locking. To move from the `pred` node to the `curr` node, a thread must acquire the lock on `curr` while still holding the lock on `pred`.

This protocol ensures that the link between pred and curr (pred->next) is always protected during traversal. A thread always holds at least one lock, and for the brief moment of transition, it holds two. This prevents another thread from, for example, deleting curr while our traversing thread is trying to move to it.

Let's see a visual walkthrough of how this enables concurrent operations.

Fine-grained synchronization & lock-free programming

These slides from a Carnegie Mellon University lecture provide an excellent visual explanation of hand-over-hand locking in action.

Please review the slides under the heading 'Solution 2: “hand-over-hand” locking' (slides 20-23). The diagrams show two threads concurrently deleting nodes from the list. Observe how each thread locks two consecutive nodes to perform its operation and how this allows them to work in parallel on different list segments.


Designing the Traversal Algorithm

Now let's translate this protocol into a concrete algorithm. The logic for traversing a list using hand-over-hand locking forms the basis for all other operations like search, insert, and delete.

The following resource provides a C implementation of a list traversal using this protocol. We will focus on the logic inside the for loop, which is the heart of the design.

COLUMNS

Let's return to the USENIX article. The hoh() function provides a clear, commented implementation of the traversal logic.

Please read the section describing the hoh() function. Pay close attention to the for loop and the comments labeled A, B, and C, which describe the state of the locks at each step.

Let's break down the logic from the hoh function's loop. We use two pointers, p (previous) and n (next).

// Simplified for clarity
node_t *p, *n;

// 1. Start by locking the head node
p = &head;
LOCK(p);

// Loop as long as there's a next node to visit
while ((n = p->next) != NULL) {
    
    // 2. The "Hand-Over": Lock the next node 'n'
    // We are now holding locks on both 'p' and 'n'.
    LOCK(n);

    // This is the critical moment. With both nodes locked, we can
    // safely perform operations that involve both, like deleting 'n'.

    // 3. The "Let-Go": Release the previous node 'p'
    // We now hold the lock only on 'n'.
    UNLK(p);

    // This is the safe place to read or modify the contents of 'n',
    // since we hold its lock exclusively. Other threads can now
    // acquire the lock on 'p' or nodes before it.
    
    // 4. Advance the pointer for the next iteration
    p = n;
}

// 5. Release the final lock after the loop
UNLK(p);

This sequence—lock p, lock n, unlock p—is the fundamental pattern of hand-over-hand locking.

Test your understanding!

A thread is traversing a list and currently holds the lock on node C. Its goal is to delete the next node, D.

Describe the sequence of lock and unlock operations the thread must perform, starting from its current state. At which point in the process is it safe to change C->next to point to D->next?

Show answer
  1. Initial State: The thread holds the lock on node C.
  2. Acquire Next Lock: The thread must first acquire the lock on node D (the node it wants to delete). Now it holds locks on both C and D.
  3. Perform Modification: With both C and D locked, it is now safe to modify the list structure. The thread can change C->next to point to D->next (which could be node E or NULL). No other thread can be looking at C->next or modifying D at this time.
  4. Release Locks: After the pointer is updated, the thread can release the locks. It can first unlock(C) and then unlock(D).
  5. Free Memory: Finally, the memory for node D can be freed.

The key is that the pointer modification (C->next = D->next) can only happen when the thread holds the locks on both the node being modified (C) and the node being unlinked (D).


Properties of the Hand-Over-Hand Design

This design isn't just a clever trick; it has important formal properties that make it both correct and efficient.

  1. Concurrency: It achieves high concurrency. Multiple threads can be active in the list simultaneously, as long as they are in different sections (i.e., not trying to lock the same node). This directly solves the sequential bottleneck of the coarse-grained approach.
  2. Deadlock-Free: This is a critical feature. Since every thread acquires locks in the same, strict order (from head to tail), a circular wait condition—one of the four necessary conditions for deadlock—is impossible. A thread at node C might have to wait for a thread at node D to release its lock, but the reverse will never happen.
  3. Limitation: The primary limitation is that traversal is strictly one-directional. This makes some algorithms, like moving a node from the middle of the list to the front, more complex to implement efficiently, as you can't easily go "backward."

COLUMNS

The USENIX article concludes by summarizing these properties. This section provides a concise review of the benefits and trade-offs of our design.

Please read the section 'Properties and Benefits'. Focus on why this pattern achieves parallelism and why deadlock is impossible.


Conclusion

In this lesson, we have designed a robust, fine-grained locking strategy for a linked list. We've moved beyond the theory of why coarse-grained locking is slow and have laid out the architectural blueprint for a high-performance, concurrent solution.

Key Takeaways:

  • Fine-Grained Locking Design: The core design involves embedding a pthread_mutex_t within each node of the linked list.
  • Hand-Over-Hand Protocol: Correctness is ensured by the "lock-next, then-unlock-previous" protocol, which is analogous to climbing a ladder. A thread holds locks on two adjacent nodes (p and n) during the critical transition period.
  • Benefits: This design allows for a high degree of concurrency, enabling multiple threads to work on different parts of the list simultaneously.
  • Deadlock Prevention: By enforcing a strict lock acquisition order (list traversal order), the design is inherently free from deadlocks.

Preview of the Next Lesson:
With the design firmly in place, it's time to put it into practice. In our next lesson, we will implement the insertion operation for a linked list using fine-grained locking. We'll write the C code to traverse the list using the hand-over-hand protocol to find the correct insertion point and safely modify the list structure.