Hello! Welcome back to our series on implementing thread-safe data structures in C.
In the previous lesson, we designed a fine-grained locking strategy for a linked list using the "hand-over-hand" protocol. We established that this design, which embeds a mutex in each node and enforces a strict lock-next-then-unlock-previous traversal order, allows for high concurrency while being inherently deadlock-free.
Today, we transition from design to practice. Our goal is to implement the insertion operation for a linked list using fine-grained locking. We will write the C code that uses our hand-over-hand protocol to navigate the list and safely insert a new element, even while other threads may be operating on the list concurrently.
The Challenge of Concurrent Insertion
Inserting a node into a linked list involves modifying a next pointer. In a concurrent environment, this is a dangerous operation. If one thread is modifying pred->next while another thread is reading it, a data race occurs, leading to undefined behavior—the second thread might read a stale pointer, miss the new node entirely, or even crash.
The hand-over-hand protocol we designed solves this. To insert a new_node between pred and curr, a thread must hold the locks on both pred and curr. This guarantees that no other thread can be traversing past pred or modifying either pred or curr while we rewire the pointers.
Implementing Sorted Insertion
To make this practical, we'll implement insertion into a sorted linked list. This is a common and instructive use case because it forces us to traverse the list to find the correct insertion point, using the full hand-over-hand locking protocol. Insertion into an unordered list is simpler (often just an insertion at the head), but the sorted case better demonstrates the power of this technique.
Let's start by reviewing the basic structure of the node and list, which we'll base on the USENIX article from our last lesson. We'll use a dummy head node to simplify edge cases like inserting at the beginning of the list.
First, let's refresh our memory on the C code for the linked list node from the 'Hand-Over-Hand Locking' article by Terence Kelly. This defines the structure we will be manipulating.
Review the C code under the heading 'The Code'. Focus on the node_t struct definition and the hard-wired list initialization, which shows the use of a dummy head node.
As a reminder, our node structure looks like this:
typedef struct node {
pthread_mutex_t m; // The mutex for this node
int data;
struct node* next;
} node_t;
Our task is to write a function, list_insert_sorted, that takes the list's head and a new value, and correctly inserts a new node while maintaining both sort order and thread safety.
The Insertion Algorithm
The core of the implementation combines traversal with the final insertion logic. Here is the step-by-step algorithm:
- Allocate and Initialize: Create the
new_nodeand initialize itsdata,nextpointer, and its own mutex. - Start Traversal: Begin at the dummy
headnode. Lock it. - Hand-Over-Hand Search: Traverse the list using two pointers,
predandcurr.- Hold the lock on
pred. - Lock
curr. - If
curris the right spot for insertion (i.e.,curr->data >= value), stop. You now hold locks onpredandcurr, the two nodes that will bracket your insertion. - If not, unlock
pred, advance your pointers (pred = curr), and continue the traversal.
- Hold the lock on
- Perform Insertion: With both
predandcurrlocked, safely perform the pointer assignments:new_node->next = curr;pred->next = new_node;
- Release Locks: Unlock
predandcurr. The operation is complete.
The following resource provides excellent C-like pseudocode that illustrates this exact process.
Fine-grained synchronization & lock-free programming
These slides from a Carnegie Mellon University lecture provide a clear pseudocode implementation for a sorted list insertion using hand-over-hand locking. It's a great blueprint for our C code.
Please study the pseudocode in the slide titled 'Solution 2b: Regular List HOH Locking'. Pay close attention to the while loop, which implements the traversal, and the sequence of lock/unlock calls within it. Notice how the insertion happens after the loop terminates, when locks on both prev and cur are held.
Translating to C Code
Let's now translate that algorithm and pseudocode into a complete C function. We'll use a dummy head node, which simplifies the logic by ensuring that pred is never NULL and that insertion at the "start" of the list is handled by the same code path as insertion in the middle.
#include <pthread.h>
#include <stdlib.h>
#include <stdio.h>
// Assume node_t and a global 'head' are defined as in the USENIX article
// typedef struct node { ... } node_t;
// extern node_t head; // Dummy head node
void list_insert_sorted(node_t* head, int value) {
// 1. Allocate and initialize the new node
node_t* new_node = malloc(sizeof(node_t));
if (new_node == NULL) {
perror("malloc");
return; // Or handle error more gracefully
}
new_node->data = value;
pthread_mutex_init(&new_node->m, NULL);
node_t* pred = head;
node_t* curr = NULL;
// 2. Start traversal by locking the predecessor (initially the dummy head)
pthread_mutex_lock(&pred->m);
curr = pred->next;
// 3. Hand-over-hand search for the insertion point
while (curr != NULL) {
pthread_mutex_lock(&curr->m); // Lock the current node
if (curr->data >= value) {
// Found the spot. Break the loop while holding locks on pred and curr.
break;
}
// Didn't find it, so advance pointers.
pthread_mutex_unlock(&pred->m); // Release the old predecessor
pred = curr; // The current node is now the predecessor
curr = curr->next; // Move to the next node (which is currently unlocked)
}
// This block runs after the loop, either because we found a spot or reached the end.
// In all cases, `pred` is locked.
// If `curr` is not NULL, `curr` is also locked.
// 4. Perform insertion
new_node->next = curr;
pred->next = new_node;
// 5. Release locks
if (curr != NULL) {
pthread_mutex_unlock(&curr->m);
}
pthread_mutex_unlock(&pred->m);
}
Let's trace the locking logic in the while loop carefully.
- A thread locks
pred. - It then locks
curr. Now it holds two locks. - It checks if
curris the insertion point.- If yes, it breaks the loop, holding both locks. The state is perfect for insertion.
- If no, it unlocks
pred, makingpredavailable to other threads. It then updatespredto becurr, andcurrto becurr->next. At the end of thewhileblock, it still holds the lock on the newpred(the oldcurr). The next iteration of the loop will then lock the newcurr. This is the "hand-over-hand" motion.
This implementation correctly handles insertion at the beginning (right after the dummy head), in the middle, and at the end of the list.
Test your understanding!
In the code above, consider the state right after the while loop terminates because we reached the end of the list (curr == NULL).
- Which node's mutex is held by the thread?
- What are the values of
pred->nextandnew_node->nextafter the pointer assignments are made?
Show answer
- When the loop terminates because
currisNULL, the thread holds the lock only onpred, which is the last actual node in the list. The finalpthread_mutex_lock(&curr->m)was never attempted. - After the assignments,
new_node->nextwill beNULL(the value ofcurr), andpred->nextwill point tonew_node. This correctly appends the new node to the end of the list.
A Note on Unordered Lists
While we focused on a sorted list, it's worth noting how much simpler insertion is if order doesn't matter. The USENIX article you've seen touches on this.
For completeness, let's look at the USENIX article's much simpler strategy for inserting into an unordered list.
Read the short paragraph that begins with 'Inserting a node into the list doesn’t require anything like hoh()...'. This highlights a major simplification.
For an unordered list, you can simply insert at the head. The logic becomes:
- Lock the dummy head node.
- Set
new_node->next = head->next. - Set
head->next = new_node. - Unlock the dummy head node.
This is extremely fast because it involves only one lock and no traversal. This illustrates an important trade-off in concurrent data structure design: the functional requirements of the data structure (e.g., sorted vs. unordered) can have a dramatic impact on the complexity and performance of its concurrent implementation.
Conclusion
In this lesson, we successfully moved from design to a concrete C implementation. You have now learned how to write a thread-safe insertion function for a sorted linked list using fine-grained, hand-over-hand locking.
Key Takeaways:
- Insertion Point Safety: The critical operation of changing
pred->nextis only performed when the thread holds locks on bothpredandcurr, ensuring the list's integrity. - Algorithm: The implementation combines a hand-over-hand traversal loop to find the insertion point with pointer manipulation inside the locked critical section.
- Dummy Head Nodes: Using a dummy head node is a powerful technique in C to simplify list algorithms, as it eliminates special-case logic for insertions at the beginning of the list or into an empty list.
- Sorted vs. Unordered: The complexity of insertion depends heavily on whether the list must be ordered. Unordered insertion can be a much faster, constant-time operation.
Preview of the Next Lesson:
We have now covered traversal design and insertion implementation. In the next lesson, we will round out the basic operations by tackling implementing the search operation for a linked list using fine-grained locking. This will be similar to the traversal we've already done, but we'll consider the specific requirements of a read-only operation.