Concurrency and Striped Hashing
Scale the Swiss Table across multiple CPU cores with sharded striping, reader-writer locks, and cache-line alignment.
In the previous lesson, we completed our single-threaded SwissSet. By splitting our container into a structure of arrays and inspecting 16, 32, or 64 control bytes simultaneously using xsimd, we built a hash table that outperforms std::unordered_set by nearly an order of magnitude.
However, our SwissSet assumes a single thread is executing operations sequentially. Modern servers and game engines run across dozens of physical CPU cores. If two threads simultaneously call Insert() and Contains() on our SwissSet, they will corrupt the control bytes, tear key writes, or trigger an immediate segmentation fault when Grow() reallocates the underlying buffers.
In this final lesson of the chapter, we will build a thread-safe ConcurrentSwissSet. We will start with a simple global lock, see why it fails under multithreaded contention, and incrementally evolve our class to use striped sharding, reader-writer locks, and cache-line alignment.
Why Not Lock-Free?
Earlier in the course, we built lock-free data structures like the using hardware loops. It is natural to wonder if we can make SwissSet lock-free by simply turning our Metadata and Keys arrays into std::atomic variables.
Unfortunately, our SIMD Swiss Table relies on two physical mechanisms that conflict directly with lock-free CAS instructions.
First, a hardware CAS instruction can only update a single contiguous memory location. In our SwissSet, inserting a single element requires updating two separate heap allocations -Metadata[Target] and Keys[Target] - plus a potential third write to the mirrored tail byte at Metadata[Capacity + Target]. We cannot update three disjointed addresses atomically in a single hardware instruction.
Second, and even more importantly, our lock-free ring buffer had a fixed capacity, whereas a hash table must dynamically reallocate via Grow(). If one thread allocates a new buffer and starts rehashing keys, any threads simultaneously working with the old buffer will be severely disrupted.
Starting with a Global Mutex
Since lock-free resizing is incompatible with our SIMD layout, we need . Let's begin by building a baseline version of ConcurrentSwissSet that wraps a single SwissSet instance with a std::mutex.
First, so that we can verify our multithreaded insertions in our test programs, let's add a simple GetSize() method to the bottom of SwissSet.h from the previous lesson:
dsa_core/include/dsa/SwissSet.h
// ...
class SwissSet {
// ...
public:
// ...
void Erase(int key) {
// ...
}
size_t GetSize() const { return Size; }
};Now let's create a new class in ConcurrentSwissSet.h that will manage concurrent access to our SwissSet.
In this initial implementation, every call to Insert(), Contains(), Erase(), or GetSize() acquires a std::lock_guard on the Mutex member before interacting with the underlying SwissSet.
Note that we mark Mutex with the mutable keyword so that const methods like Contains() and GetSize() can still lock and unlock it:
dsa_core/include/dsa/ConcurrentSwissSet.h
#pragma once
#include <mutex>
#include <cstddef>
#include <dsa/SwissSet.h>
class ConcurrentSwissSet {
private:
SwissSet Set;
mutable std::mutex Mutex;
public:
ConcurrentSwissSet() = default;
void Insert(int key) {
std::lock_guard<std::mutex> lock{Mutex};
Set.Insert(key);
}
bool Contains(int key) const {
std::lock_guard<std::mutex> lock{Mutex};
return Set.Contains(key);
}
void Erase(int key) {
std::lock_guard<std::mutex> lock{Mutex};
Set.Erase(key);
}
size_t GetSize() const {
std::lock_guard<std::mutex> lock{Mutex};
return Set.GetSize();
}
};Let's verify that this class protects our container across multiple threads.
In main.cpp, we will spawn 4 worker threads using std::jthread. Each thread will concurrently insert 10,000 integers into ConcurrentSwissSet, forcing the underlying SwissSet through multiple Grow() reallocations while other threads are actively inserting:
dsa_app/main.cpp
#include <iostream>
#include <thread>
#include <vector>
#include <dsa/ConcurrentSwissSet.h>
int main() {
ConcurrentSwissSet Set;
constexpr int kNumThreads{4};
constexpr int kItemsPerThread{10'000};
// Spawn 4 threads inserting 40,000 keys concurrently
{
std::vector<std::jthread> workers;
for (int t{0}; t < kNumThreads; ++t) {
workers.emplace_back([&Set, t]() {
int start{t * kItemsPerThread};
int end{start + kItemsPerThread};
for (int i{start}; i < end; ++i) {
Set.Insert(i);
}
});
}
} // All jthreads automatically join here
std::cout << "Total elements inserted: "
<< Set.GetSize() << "\n";
std::cout << "Contains 42? "
<< (Set.Contains(42) ? "Yes" : "No") << "\n";
std::cout << "Contains 35000? "
<< (Set.Contains(35'000) ? "Yes" : "No") << "\n";
Set.Erase(42);
std::cout << "Contains 42 after erase? "
<< (Set.Contains(42) ? "Yes" : "No");
}Total elements inserted: 40000
Contains 42? Yes
Contains 35000? Yes
Contains 42 after erase? NoWhy the Global Mutex Kills Scaling
Our ConcurrentSwissSet is now thread-safe, and all 40,000 keys are accounted for without a single race condition. However, from a hardware perspective, wrapping a fast data structure in a single global mutex destroys multicore performance.
When 8 CPU cores call Contains() or Insert() at the same time, 1 core acquires Mutex while the other 7 cores are forced to wait. Even though we have 8 physical cores available, our hash table can run on only 1 core at a time.
Worse still, because a SIMD SwissSet operation only takes a few nanoseconds, all 8 cores are constantly fighting to modify the same 64-byte cache line holding Mutex.
As we learned in our lesson on , this triggers a storm of MESI cache invalidations across the CPU interconnect. The processor spends far more clock cycles bouncing the mutex's cache line between cores than it spends probing our hash table.
Striped Hashing (Sharding)
To let multiple CPU cores operate on ConcurrentSwissSet in parallel without giving up our SIMD group probing, we need to split our single point of contention.
We already solved a version of this problem in the previous chapter when we built our . When a single server could not handle the network traffic of millions of players, we used a hash function to partition the keys across an array of independent servers.
We can apply that same architecture inside our ConcurrentSwissSet. This pattern is called Striped Hashing (or Sharding).
Instead of storing one giant SwissSet guarded by one global std::mutex, we divide ConcurrentSwissSet into an array of independent sub-tables called stripes (or shards). Each stripe owns its own SwissSet and its own dedicated mutex.
Every possible key gets routed to only one of the stripes, allowing multiple threads to interact with different keys concurrently. The following diagram shows this architecture with our data divided across only four shards, but we typically use many more:
Suppose we partition our ConcurrentSwissSet into NumStripes = 64 stripes. When Thread A wants to insert a key, it hashes the key to select one of the 64 stripes - say, Stripes[12] - locks Stripes[12].Mutex, and inserts the key into Stripes[12].Set.
If Thread B simultaneously queries a key that routes to Stripes[19], it locks Stripes[19].Mutex and runs its SIMD probe completely in parallel with Thread A.
Even if Stripes[12] hits its load factor limit and triggers Grow(), only Stripes[12] pauses to reallocate. Additionally, because each stripe only stores of the total dataset, we don't need to rehash every key to grow our container. Our sharded design therefore has the added benefit of smoothing out our reallocation spikes.
Orthogonal Hash Bit Slicing
To route a key to one of our 64 () stripes, we need 6 bits from the key's 64-bit hash, but we should be careful about which 6 bits we choose. Because both ConcurrentSwissSet and SwissSet are using the same keys and the same Mix64() function, these allocations risk becoming correlated, which can drastically degrade performance.
Remember, we want to keep our hashing mechanisms as randomized as possible - distributing keys throughout the container to minimize collisions and to minimize the length of primary clusters when collisions do occur.
The worst case of correlation would be if we used the same bits from our hash to assign both the shard index within ConcurrentSwissSet and the bucket index within SwissSet.
This could create a situation where, for example, every key routed to Stripes[5] by our top-level ConcurrentSwissSet would get assigned to bucket 5 within that stripe's SwissSet, causing 100% of them to collide and form a huge, single primary cluster.
To avoid correlation, we should pick bits for our stripe index that never overlap with the bits used by H1 or H2 within the SwissSet. Because 64 stripes only require 6 bits, we can use the top 6 bits to allocate hashes to a shard:
This relies on a hash function that avalanches across all 64 bits. Our Mix64() function meets this requirement - the top 6 bits are statistically independent of the lower bits.
Implementing the Striped Container
Let's update ConcurrentSwissSet.h to replace our single Set and Mutex with a std::array<Stripe, 64>.
We will include <array> and <cstdint>, add the Mix64() function to compute the routing hash, and use GetStripeIndex() to direct Insert(), Contains(), and Erase() to the appropriate stripe.
For GetSize(), we iterate over all 64 stripes and sum their individual sizes:
dsa_core/include/dsa/ConcurrentSwissSet.h
#pragma once
#include <array>
#include <mutex>
#include <cstdint>
#include <cstddef>
#include <dsa/SwissSet.h>
class ConcurrentSwissSet {
private:
// 2^6 = 64 independent stripes
static constexpr size_t StripeBits{6};
static constexpr size_t NumStripes{1ULL << StripeBits};
struct Stripe {
SwissSet Set;
mutable std::mutex Mutex;
};
std::array<Stripe, NumStripes> Stripes;
uint64_t Mix64(uint64_t x) const {
x ^= x >> 30;
x *= 0xbf58476d1ce4e5b9ULL;
x ^= x >> 27;
x *= 0x94d049bb133111ebULL;
x ^= x >> 31;
return x;
}
// Extract the top 6 bits (Bits 58..63) to select
// a stripe without overlapping H1 or H2
size_t GetStripeIndex(int key) const {
uint64_t hash{Mix64(key)};
return static_cast<size_t>(
hash >> (64 - StripeBits)
);
}
public:
ConcurrentSwissSet() = default;
void Insert(int key) {
Stripe& stripe{Stripes[GetStripeIndex(key)]};
std::lock_guard<std::mutex> lock{stripe.Mutex};
stripe.Set.Insert(key);
}
bool Contains(int key) const {
const Stripe& stripe{Stripes[GetStripeIndex(key)]};
std::lock_guard<std::mutex> lock{stripe.Mutex};
return stripe.Set.Contains(key);
}
void Erase(int key) {
Stripe& stripe{Stripes[GetStripeIndex(key)]};
std::lock_guard<std::mutex> lock{stripe.Mutex};
stripe.Set.Erase(key);
}
size_t GetSize() const {
size_t total{0};
for (const Stripe& stripe : Stripes) {
std::lock_guard<std::mutex> lock{stripe.Mutex};
total += stripe.Set.GetSize();
}
return total;
}
};Eliminating Redundant Hashing
Let's look closely at what happens on the CPU whenever we call Contains(key) in the code above.
First, ConcurrentSwissSet calls GetStripeIndex(key), which runs the Mix64(key) bit-mixer to compute the 64-bit hash and shifts it right by 58 bits to locate the Stripe.
Then, inside that stripe, stripe.Set.Contains(key) runs Mix64(key) a second time on the same integer to extract H1 and H2. We are executing twice as many hash multiplications and shifts as necessary on every single operation, and duplicating the Mix64() code across two classes.
We can eliminate this wasted work by computing the 64-bit hash once in ConcurrentSwissSet and passing that precomputed hash directly into SwissSet.
Let's update SwissSet to make Mix64() a public static method, and split Insert(), Contains(), and Erase() into overloads that accept a precomputed uint64_t hash:
dsa_core/include/dsa/SwissSet.h
// ...
class SwissSet {
// ...
private:
// ...
static constexpr size_t InvalidIndex{
std::numeric_limits<size_t>::max()
};
// Expose Mix64 as a public static helper
public:
static uint64_t Mix64(uint64_t x) {
x ^= x >> 30;
x *= 0xbf58476d1ce4e5b9ULL;
x ^= x >> 27;
x *= 0x94d049bb133111ebULL;
x ^= x >> 31;
return x;
}
private:
public:
SwissSet()
: Metadata(Capacity + GroupWidth, Ctrl::kEmpty),
Keys(Capacity) {}
// Pre-hashed overload for zero-overhead stripe routing
void Insert(int key, uint64_t hash) {
uint64_t hash{Mix64(key)};
}
void Insert(int key) {
Insert(key, Mix64(key));
}
bool Contains(int key, uint64_t hash) const {
uint64_t hash{Mix64(key)};
}
bool Contains(int key) const {
return Contains(key, Mix64(key));
}
void Erase(int key, uint64_t hash) {
uint64_t hash{Mix64(key)};
}
void Erase(int key) {
Erase(key, Mix64(key));
}
size_t GetSize() const { return Size; }
};Now we can simplify ConcurrentSwissSet so that it calls SwissSet::Mix64(key) once and forwards hash into the target stripe:
dsa_core/include/dsa/ConcurrentSwissSet.h
// ...
class ConcurrentSwissSet {
private:
static constexpr size_t StripeBits{6};
static constexpr size_t NumStripes{1ULL << StripeBits};
struct Stripe {
SwissSet Set;
mutable std::mutex Mutex;
};
std::array<Stripe, NumStripes> Stripes;
static size_t GetStripeIndex(uint64_t hash) {
return static_cast<size_t>(
hash >> (64 - StripeBits)
);
}
public:
ConcurrentSwissSet() = default;
void Insert(int key) {
uint64_t hash{SwissSet::Mix64(key)};
Stripe& stripe{Stripes[GetStripeIndex(hash)]};
std::lock_guard<std::mutex> lock{stripe.Mutex};
stripe.Set.Insert(key, hash);
}
bool Contains(int key) const {
uint64_t hash{SwissSet::Mix64(key)};
const Stripe& stripe{Stripes[GetStripeIndex(hash)]};
std::lock_guard<std::mutex> lock{stripe.Mutex};
return stripe.Set.Contains(key, hash);
}
void Erase(int key) {
uint64_t hash{SwissSet::Mix64(key)};
Stripe& stripe{Stripes[GetStripeIndex(hash)]};
std::lock_guard<std::mutex> lock{stripe.Mutex};
stripe.Set.Erase(key, hash);
}
// ...
};Reader-Writer Locks
Splitting our container into 64 stripes dramatically reduces lock contention, but we are still leaving performance on the table when multiple threads read from the same stripe.
Right now, each stripe uses a full-blown std::mutex. A std::mutex is an exclusive lock: it treats a read-only Contains() query identically to a mutating Insert() or Erase() call.
In most production systems, hash table traffic is heavily dominated by reads. A server might check if a session ID or asset handle exists 99% of the time, and only insert or remove entries 1% of the time.
If two threads concurrently call Contains() on keys that map to Stripes[12], neither thread is modifying a single byte of memory, yet std::mutex forces the second thread to wait until the first thread finishes.
Using std::shared_mutex
C++17 provides std::shared_mutex in the <shared_mutex> header, which acts as a Reader-Writer Lock.
When a thread wants to mutate a stripe, it acquires an exclusive lock using std::unique_lock<std::shared_mutex>. While an exclusive lock is held, no other writer or reader can enter that stripe. If Insert() triggers Grow(), it has exclusive ownership of that stripe's buffers.
When a thread only wants to read from a stripe using Contains() or GetSize(), it acquires a shared lock using std::shared_lock<std::shared_mutex>. Any number of reader threads can hold a std::shared_lock on the same stripe at the same time, running their SIMD Contains() probes concurrently without blocking each other.
Let's update ConcurrentSwissSet to use std::shared_mutex, std::unique_lock, and std::shared_lock:
dsa_core/include/dsa/ConcurrentSwissSet.h
#pragma once
#include <array>
#include <mutex>
#include <shared_mutex>
#include <cstdint>
#include <cstddef>
#include <dsa/SwissSet.h>
class ConcurrentSwissSet {
private:
static constexpr size_t StripeBits{6};
static constexpr size_t NumStripes{1ULL << StripeBits};
struct Stripe {
SwissSet Set;
mutable std::shared_mutex Mutex;
};
std::array<Stripe, NumStripes> Stripes;
static size_t GetStripeIndex(uint64_t hash) {
return static_cast<size_t>(
hash >> (64 - StripeBits)
);
}
public:
ConcurrentSwissSet() = default;
void Insert(int key) {
uint64_t hash{SwissSet::Mix64(key)};
Stripe& stripe{Stripes[GetStripeIndex(hash)]};
// Exclusive lock: only one writer allowed in this stripe
std::unique_lock<std::shared_mutex> lock{
stripe.Mutex
};
stripe.Set.Insert(key, hash);
}
bool Contains(int key) const {
uint64_t hash{SwissSet::Mix64(key)};
const Stripe& stripe{Stripes[GetStripeIndex(hash)]};
// Shared lock: multiple readers can probe simultaneously
std::shared_lock<std::shared_mutex> lock{
stripe.Mutex
};
return stripe.Set.Contains(key, hash);
}
void Erase(int key) {
uint64_t hash{SwissSet::Mix64(key)};
Stripe& stripe{Stripes[GetStripeIndex(hash)]};
// Exclusive lock for mutation
std::unique_lock<std::shared_mutex> lock{
stripe.Mutex
};
stripe.Set.Erase(key, hash);
}
size_t GetSize() const {
size_t total{0};
for (const Stripe& stripe : Stripes) {
std::shared_lock<std::shared_mutex> lock{
stripe.Mutex
};
total += stripe.Set.GetSize();
}
return total;
}
};Defeating False Sharing
Our ConcurrentSwissSet is now logically partitioned into 64 stripes with reader-writer locks. However, we must check one final hardware detail: how does std::array<Stripe, 64> sit in physical memory?
Because std::array stores its elements contiguously, Stripes[0], Stripes[1], and Stripes[2] are packed back-to-back in RAM.
Because the size of Stripe is unlikely to be a clean multiple of a 64-byte CPU cache line, the end of one stripe may sit in the same 64-byte physical cache line as the beginning of the next stripe:
This creates situations where, for example, Core 0 might lock Stripes[0].Mutex, causing a write operation that invalidates the shared 64-byte cache line across all other cores. When Core 1 tries to read Stripes[1].Set.Mask from that same cache line, it suffers a cache miss.
Even though the two threads are operating on completely different stripes, they are sabotaging each other through .
Padding Stripes
To guarantee that no two stripes ever share a physical 64-byte cache line, we can add the alignas(64) specifier to our Stripe struct:
dsa_core/include/dsa/ConcurrentSwissSet.h
// ...
class ConcurrentSwissSet {
private:
static constexpr size_t StripeBits{6};
static constexpr size_t NumStripes{1ULL << StripeBits};
// Align each stripe to a 64-byte cache line boundary
// to eliminate false sharing between adjacent stripes
struct alignas(64) Stripe {
SwissSet Set;
mutable std::shared_mutex Mutex;
};
std::array<Stripe, NumStripes> Stripes;
// ...
};Marking Stripe as alignas(64) forces the compiler to pad the size of every Stripe to a multiple of 64 bytes and align the start of every Stripe to a 64-byte address boundary. Now, modifying a stripe never invalidates a single byte of a different stripe.
We're using alignas(64) here for simplicity, but 64 could be replaced with std::hardware_destructive_interference_size from #include <new>. We explain why we might want this (portability) in the dedicated
Benchmarking
Let's benchmark our ConcurrentSwissSet across multiple threads, as well as using our SwissSet directly to measure the overhead of our ConcurrentSwissSet layer.
Each container is pre-populated with 2,000,000 integers, and the worker threads execute a realistic workload of roughly 99% Contains() lookups and 1% Insert() mutations:
benchmarks/main.cpp
#include <benchmark/benchmark.h>
#include <vector>
#include <random>
#include <dsa/SwissSet.h>
#include <dsa/ConcurrentSwissSet.h>
constexpr int kInitialElements = 2'000'000;
std::vector<int> GenerateInitialData() {
std::vector<int> data(kInitialElements);
std::mt19937 rng{42};
std::uniform_int_distribution<int> dist{1, 50'000'000};
for (int i{0}; i < kInitialElements; ++i) {
data[i] = dist(rng);
}
return data;
}
const std::vector<int> InitialData = GenerateInitialData();
SwissSet* Swiss = nullptr;
ConcurrentSwissSet* ConcurrentSwiss = nullptr;
static void BM_SwissSet(benchmark::State& state) {
Swiss = new SwissSet();
for (int v : InitialData) Swiss->Insert(v);
for (auto _ : state) {
for (int i{0}; i < kInitialElements; ++i) {
int key = InitialData[i];
if ((i % 100) == 0) {
Swiss->Insert(key + 60'000'000);
} else {
benchmark::DoNotOptimize(Swiss->Contains(key));
}
}
}
delete Swiss;
}
static void BM_Concurrent(benchmark::State& state) {
if (state.thread_index() == 0) {
ConcurrentSwiss = new ConcurrentSwissSet();
for (int v : InitialData) ConcurrentSwiss->Insert(v);
}
for (auto _ : state) {
for (int i{0}; i < kInitialElements; ++i) {
int key = InitialData[i];
if ((i % 100) == 0) {
ConcurrentSwiss->Insert(key + 60'000'000);
} else {
benchmark::DoNotOptimize(
ConcurrentSwiss->Contains(key)
);
}
}
}
if (state.thread_index() == 0) delete ConcurrentSwiss;
}
BENCHMARK(BM_SwissSet)
->Unit(benchmark::kMillisecond)
->UseRealTime();
BENCHMARK(BM_Concurrent)
->Unit(benchmark::kMillisecond)
->UseRealTime()
->Threads(1)
->Threads(2)
->Threads(4)
->Threads(8)
->Threads(16);--------------------------------------------
Benchmark Time
--------------------------------------------
BM_SwissSet/real_time 110 ms
BM_Concurrent/real_time/threads:1 135 ms
BM_Concurrent/real_time/threads:2 76.8 ms
BM_Concurrent/real_time/threads:4 37.9 ms
BM_Concurrent/real_time/threads:8 21.7 ms
BM_Concurrent/real_time/threads:16 15.2 msWe can see from these tests that making our SwissSet thread-safe through the ConcurrentSwissSet wrapper adds a small performance cost.
However, as soon as we have a second thread working on our task, the concurrent implementation pulls comfortably ahead, and that advantage widens with every additional core we allocate to the work.
Complete Code
Here are the complete implementations of our ConcurrentSwissSet and updated SwissSet:
Files
Summary
In this lesson, we scaled our SIMD Swiss Table across multiple CPU cores by wrapping it in a ConcurrentSwissSet.
We started by examining why lock-free CAS instructions struggle with dynamic hash table reallocations and the split structure of array writes, and built an initial ConcurrentSwissSet protected by a single global std::mutex. While thread-safe, a single lock serialized execution and triggered severe MESI cache-line invalidations across cores.
We then partitioned the container into 64 independent stripes, routing keys using the top 6 bits of Mix64() (Bits 58..63) so that stripe selection remained completely independent of H1 and H2, and passed the precalculated hash directly into SwissSet to avoid hashing keys twice.
Finally, we replaced std::mutex with std::shared_mutex so multiple readers can probe the same stripe simultaneously, and marked each Stripe with alignas(64) to eliminate false sharing across adjacent cache lines.