Hello! Welcome back to the course.
In our last lesson, we successfully implemented the non-thread-safe insert, lookup, and delete operations for our chained hash table. We now have a complete and functional data structure, but with a critical weakness: it is not safe for concurrent access. If multiple threads try to modify it simultaneously, we risk data corruption and unpredictable behavior.
Today, we'll address this by designing a robust and efficient locking strategy. Our goal is to make the hash table thread-safe while maximizing concurrency. This lesson focuses on the design of this strategy, laying the architectural groundwork for the thread-safe implementation you'll build in the lessons that follow.
The Problem: Race Conditions in the Hash Table
Imagine two threads trying to insert new keys that both hash to the same bucket. Let's say bucket k is initially empty (buckets[k] == NULL).
- Thread A executes
insert(key_A). It calculates the indexk. It sees thatbuckets[k]isNULL. - Before Thread A can allocate and link a new node, the OS scheduler preempts it and runs Thread B.
- Thread B executes
insert(key_B). It also calculates the indexkand sees thatbuckets[k]isNULL. - Thread B allocates a new node for
key_B, sets itsnextpointer toNULL, and updatesbuckets[k]to point to this new node. - Now, the scheduler switches back to Thread A. It resumes where it left off. It still believes
buckets[k]wasNULL, so it allocates a new node forkey_A, sets itsnextpointer toNULL, and updatesbuckets[k]to point to its new node.
The result? The node created by Thread B is lost forever—a memory leak and a lost insertion. The buckets[k] pointer has been overwritten by Thread A. This is a classic race condition. To prevent this, we must ensure that operations on a bucket are atomic.
Approach 1: Coarse-Grained Locking
The simplest solution is to use a single mutex to protect the entire hash table.
- Design: Add one
pthread_mutex_tto theHashTablestruct. - Protocol: Before any thread can perform any operation (
insert,lookup, ordelete), it must lock this single mutex. It unlocks it upon completion.
This approach is easy to implement and guarantees thread safety. However, it comes at a huge performance cost. If Thread A is inserting into bucket 5, and Thread B wants to look up a key in bucket 99, Thread B must wait for Thread A to finish. The lock becomes a bottleneck, serializing all access and eliminating any potential for concurrent operations. We can do much better.
Approach 2: Fine-Grained Locking (Per-Bucket Locking)
A much more scalable approach is to use fine-grained locking. Instead of one lock for the entire table, we use one lock for each bucket.
- Design: Maintain an array of mutexes, with a one-to-one mapping to the array of buckets.
- Protocol: A thread wishing to operate on a bucket first identifies the bucket index. It then locks only the mutex corresponding to that specific bucket.
This design allows two threads to access the hash table concurrently, as long as they are operating on different buckets. This dramatically increases the potential for parallelism, especially for large hash tables with well-distributed keys.
The following reading from the OSTEP book discusses this exact strategy and its impressive performance benefits.
Lock-based Concurrent Data Structures
Let's read a short section from 'Lock-based Concurrent Data Structures' that introduces the concurrent hash table and the rationale behind using a lock per bucket.
Please read section 29.4, 'Concurrent Hash Table'. Focus on the core idea: instead of a single lock, it uses 'a lock per hash bucket'. Also, note the performance graph (Figure 29.11) which starkly contrasts the scalability of this approach against a single-lock linked list.
As the text highlights, this design "scales magnificently." Let's look at what this design means for our C code.
Designing the Data Structure and Initialization
To implement per-bucket locking, we must modify our HashTable structure to include an array of mutexes alongside the array of buckets.

The following resource provides a concrete C implementation of this design. We will focus on the structure definition and the initialization function.
Thread-Safe Hash Table Implementation
This PDF provides a full implementation of a thread-safe hash table. We'll examine how it defines the data structure and initializes the per-bucket locks.
First, find the definition of the HashTable struct. Notice the pthread_mutex_t locks[TABLE_SIZE]; member. This is the core of our design. Next, examine the init_table function to see how it iterates through this array to initialize each mutex individually.
Let's break down the key changes required for our design.
1. Modifying the HashTable Struct
Based on the resource, our new HashTable struct should look like this. We are assuming a fixed TABLE_SIZE for simplicity, just as in the example.
#define TABLE_SIZE 101
// Node for the linked list (separate chaining)
typedef struct Node {
char *key;
int value; // Or a generic void*
struct Node *next;
} Node;
// The hash table structure
typedef struct HashTable {
Node *buckets[TABLE_SIZE];
pthread_mutex_t locks[TABLE_SIZE]; // One mutex per bucket
} HashTable;
The crucial addition is pthread_mutex_t locks[TABLE_SIZE];. This array holds the locks that will protect each bucket's linked list.
2. Updating Initialization and Destruction Logic
An array of mutexes must be properly initialized before use and destroyed upon cleanup.
-
Initialization: The
init_tablefunction must loop fromi = 0toTABLE_SIZE - 1and callpthread_mutex_init(&table->locks[i], NULL)for each element in thelocksarray. This prepares each mutex for use. -
Destruction: Similarly, a
free_tablefunction must not only free the nodes and keys but also loop through thelocksarray and callpthread_mutex_destroy(&table->locks[i])on each one to release system resources.
The Locking Protocol for Hash Table Operations
With the structure defined and the mutexes initialized, the final piece of the design is the protocol for using them. The rule is simple and must be followed by insert, lookup, and delete:
- Given a
key, compute the bucketindex = hash(key) % TABLE_SIZE. - Acquire the specific lock for that bucket:
pthread_mutex_lock(&table->locks[index]); - Perform the required linked list operation (traverse, add node, remove node) within the now-protected critical section.
- Release the lock for that bucket:
pthread_mutex_unlock(&table->locks[index]);
The insert and get functions in the LINK resource you just reviewed demonstrate this protocol perfectly. For example, the get function looks like this:
int get(HashTable* table, const char* key) {
unsigned int index = hash(key);
// Lock the specific bucket
pthread_mutex_lock(&table->locks[index]);
// Critical Section: Safe to access the linked list
Node* current = table->buckets[index];
while (current != NULL) {
if (strcmp(current->key, key) == 0) {
int val = current->value;
// Unlock before returning
pthread_mutex_unlock(&table->locks[index]);
return val;
}
current = current->next;
}
// Unlock if key not found
pthread_mutex_unlock(&table->locks[index]);
return -1; // Not found
}
Notice how the lock is acquired at the beginning and released on all possible exit paths from the function.
Test your understanding!
Consider a hash table with TABLE_SIZE = 100 and our fine-grained locking strategy. Four threads are running concurrently:
- Thread 1:
insert("apple", 10)which hashes to bucket5. - Thread 2:
delete("banana")which hashes to bucket33. - Thread 3:
insert("cherry", 30)which hashes to bucket5. - Thread 4:
lookup("date")which hashes to bucket90.
Which threads can run truly in parallel, and which thread(s) might have to wait? Explain your reasoning based on the locking protocol.
Show answer
-
Thread 1 (
apple, bucket 5) and Thread 3 (cherry, bucket 5) will conflict. Since they both operate on bucket5, they will contend for the same mutex,locks[5]. Whichever thread acquires the lock first will proceed, and the other will be blocked until the lock is released. -
Thread 2 (
banana, bucket 33) and Thread 4 (date, bucket 90) can run in parallel with each other, as they are locking different mutexes (locks[33]andlocks[90]). -
Furthermore, Threads 2 and 4 can also run in parallel with one of the threads operating on bucket 5 (either Thread 1 or 3, whichever gets the lock). For instance, a possible parallel execution could be (Thread 1, Thread 2, Thread 4) running at the same time, while Thread 3 waits for Thread 1 to finish.
Reasoning: The fine-grained locking protocol only serializes access to the same bucket. Threads accessing different buckets can proceed concurrently because they acquire different, independent mutexes.
Conclusion
In this lesson, we have designed an efficient, fine-grained locking strategy for our hash table. By moving from a single, coarse-grained lock to a per-bucket locking model, we unlock significant potential for concurrency and performance.
Key Takeaways:
- A coarse-grained lock (one for the whole table) is simple to implement but creates a major performance bottleneck, serializing all operations.
- A fine-grained locking strategy uses one mutex per hash bucket, allowing multiple threads to operate on different buckets concurrently.
- The design requires adding an array of
pthread_mutex_tto theHashTablestruct. - The table initialization and destruction functions must be updated to call
pthread_mutex_initandpthread_mutex_destroyfor every mutex in the array. - The core locking protocol is: identify the target bucket, lock the corresponding mutex, perform the operation, and then unlock the mutex.
Preview of the Next Lesson:
We have our blueprint. In the next lesson, we will put this design into action by implementing the thread-safe insert operation for the hash table. You will modify your existing insert function to correctly acquire and release the per-bucket lock, making it safe for concurrent execution.