Concurrent Lock Policies in Index Merging
In a search engine, the merge process runs continuously in the background. When merge completes, old index segments must be replaced with a new, unified segment. This switching operation seems straightforward but presents a sharp concurrency challenge: without locking, readers may see inconsistent data or encounter prematurely deleted files; with coarse, long-held locks, queries stall.
The solution hinges on choosing when to lock. We examine two strategies—OnStart (pessimistic) and OnSwitch (optimistic)—then implement a working OnSwitch approach in Modern C++ (C++20).
The Strategy Game: OnStart vs. OnSwitch
When designing merge locks, two main choices present themselves:
OnStart Policy (Pessimistic Lock):
- Acquire the lock at the very beginning of the merge task to prevent other processes from modifying these segments.
- Pros: Simple implementation, high safety.
- Cons: Extremely low concurrency. Merging is time-consuming (potentially minutes), and holding a lock this long blocks all structural changes.
OnSwitch Policy (Optimistic/Late Lock):
- Do not hold a lock for the majority of the merge time (reading old segments, writing new ones to a temporary directory).
- Acquire the write lock only at the very last moment to "flip" the pointers.
- Pros: Drastically reduces critical section time, with almost zero impact on queries.
- Cons: Complex implementation. What if an old segment was deleted by another task during the merge? Requires complex reference counting or version checking mechanisms.
Modern C++ Implementation: RAII and Late-Locking
We will use C++20's std::shared_mutex to simulate a reader-writer lock and demonstrate how to implement a safe OnSwitch logic.
#include <iostream>
#include <vector>
#include <string>
#include <thread>
#include <mutex>
#include <shared_mutex>
#include <chrono>
#include <atomic>
#include <memory>
// Simulate an index segment
struct Segment {
std::string id;
size_t doc_count;
};
// Core state of the search system
class SearchIndex {
std::vector<Segment> active_segments;
mutable std::shared_mutex index_mutex; // Reader-Writer lock
public:
SearchIndex() {
active_segments = {{"seg_1", 100}, {"seg_2", 200}};
}
// Simulate query operation (Reader): Requires shared lock
void search(const std::string& query) const {
std::shared_lock lock(index_mutex);
std::cout << "[Query] Searching for '" << query << "' in segments: ";
for (const auto& seg : active_segments) {
std::cout << seg.id << " ";
}
std::cout << std::endl;
// Simulate query latency
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
// Get a snapshot of current segments (preparation for merge)
std::vector<Segment> get_segments_snapshot() const {
std::shared_lock lock(index_mutex);
return active_segments;
}
// Key: Atomic Switch (OnSwitch Policy)
// Acquire exclusive lock only at this final step
bool apply_merge_result(const std::vector<std::string>& old_ids, const Segment& new_merged_segment) {
std::unique_lock lock(index_mutex); // Acquire write lock
// Double-Check:
// Ensure old segments still exist and haven't been deleted by other threads
for (const auto& old_id : old_ids) {
bool found = false;
for (const auto& seg : active_segments) {
if (seg.id == old_id) {
found = true;
break;
}
}
if (!found) {
std::cerr << "[Merge] Failed! Target segments disappeared." << std::endl;
return false;
}
}
// Execute Swap: Remove old segments, add new segment
std::cout << "[Merge] Critical Section: Swapping segments..." << std::endl;
auto it = active_segments.begin();
while (it != active_segments.end()) {
bool is_old = false;
for (const auto& old_id : old_ids) {
if (it->id == old_id) {
is_old = true;
break;
}
}
if (is_old) {
it = active_segments.erase(it);
} else {
++it;
}
}
active_segments.push_back(new_merged_segment);
return true;
}
};
// Simulate background merge task
void background_merge_task(SearchIndex& index) {
// 1. Preparation Phase (No lock or short lock)
auto snapshot = index.get_segments_snapshot();
std::vector<std::string> target_ids;
for (const auto& seg : snapshot) target_ids.push_back(seg.id);
std::cout << "[Merge] Background merging started for " << target_ids.size() << " segments..." << std::endl;
// Simulate expensive merge calculation (Holding NO locks!)
std::this_thread::sleep_for(std::chrono::milliseconds(200));
Segment new_seg{"seg_merged_v1", 300};
// 2. Switch Phase (OnSwitch - Short Write Lock)
if (index.apply_merge_result(target_ids, new_seg)) {
std::cout << "[Merge] Success! Index updated." << std::endl;
}
}
int main() {
SearchIndex index;
// Start background merge thread
std::jthread merger(background_merge_task, std::ref(index));
// Simulate foreground high-concurrency queries
for (int i = 0; i < 5; ++i) {
index.search("user_query_" + std::to_string(i));
std::this_thread::sleep_for(std::chrono::milliseconds(60));
}
return 0;
}
Code Walkthrough
This C++ example demonstrates the essence of the OnSwitch policy:
- Lock-Free Calculation: The majority of the time in
background_merge_task(the simulatedsleep_for) is spent without holding any locks. Query threads (search) run unimpeded while the merge progresses. - Atomic Switch:
apply_merge_resultusesstd::unique_lock. This is the only place in the entire process that blocks readers, but the operations are extremely fast (in-memoryvectormanipulation), typically in the microsecond range. - Safety Check: After acquiring the write lock, we must verify the state again ("Double-Check"). During the lock-free computation period, the outside world may have changed (e.g., an admin deleted an old index).
Conclusion
In high-concurrency systems, locking is fundamentally a concurrency trade-off. The OnSwitch (Late-Locking) policy drastically improves system concurrency by minimizing the scope of critical sections.
Although implementing it is more complex than a global lock—you must handle state invalidation and version conflicts—in search systems demanding ultra-low latency, this complexity is justified. Modern C++'s shared_mutex and RAII patterns enable these synchronization semantics to be expressed cleanly and safely.