Basics of Locks
Locks are the core synchronization primitives in C++ concurrent programming, used to protect access to shared resources, ranging from simple variables to complex code segments.
The C++11 standard library provides two core types of locks:
mutex: an exclusive mutex, allowing only one thread to hold it at a time.
shared_mutex: a shared-exclusive lock (read-write lock), allowing multiple reader threads to hold it simultaneously, but write threads are exclusive.
The core issue with locks is contention: when multiple threads frequently compete for the same lock, it leads to thread blocking, increased context switching, and ultimately reduces the program’s concurrency performance. The main goal of lock optimization is to reduce the level of contention.
Lock Optimization Strategies
Minimize Lock Granularity
Lock granularity refers to the scope of code/resources protected by the lock. The larger the granularity, the broader the scope, the longer the lock is held, the higher the contention rate, and the lower the performance.
mutex mtx; // Mutex
vector<string> strVec;
void dealstr(const string& data) {
lock_guard<mutex> lock(mtx); // Lock holds for the entire function
string processed;
if (data.substr(0, 4) == "must") {
processed = "header:" + data + "/header";
}
else {
processed = "body:" + data + "/body";
}
processed += ":end";
// Add to shared resource
strVec.push_back(processed);
}
In the above code, the lock protects the entire function, including unrelated string processing operations, resulting in a significant increase in lock hold time.
We should minimize this granularity as much as possible, moving operations that do not involve shared resources out of the lock’s scope, locking only when reading or writing shared resources.
mutex mtx; // Mutex
vector<string> strVec;
void dealstr(const string& data) {
string processed;
if (data.substr(0, 4) == "must") {
processed = "header:" + data + "/header";
}
else {
processed = "body:" + data + "/body";
}
processed += ":end";
lock_guard<mutex> lock(mtx); // Lock only protects the vector's push operation
// Add to shared resource
strVec.push_back(processed);
}
Split Locks for Multiple Resources
If one lock protects multiple independent shared resources, it can be split into multiple locks, each protecting one resource, thereby reducing the probability of contention.
mutex mtx;
vector<string> strvec1;
vector<string> strvec2;
void dealstr1(const string& str1) {
string processed="header:" + str1 + "/header";
lock_guard<mutex> lock(mtx);
strvec1.push_back(processed);
}
void dealstr2(const string& str2) {
string processed = "header:" + str2 + "/header";
lock_guard<mutex> lock(mtx);
strvec2.push_back(processed);
}
strvec1 and strvec2 are two independent resources, each adding strings, but they use the same lock, causing contention between the calling threads of dealstr1 and dealstr2, leading to performance degradation.
We should allocate a separate lock for each independent resource, so that contention only occurs when multiple threads operate on the same resource.
mutex mtx1;
mutex mtx2;
vector<string> strvec1;
vector<string> strvec2;
void dealstr1(const string& str1) {
string processed="header:" + str1 + "/header";
lock_guard<mutex> lock(mtx1); // Use its own lock
strvec1.push_back(processed);
}
void dealstr2(const string& str2) {
string processed = "header:" + str2 + "/header";
lock_guard<mutex> lock(mtx2); // Use its own lock
strvec2.push_back(processed);
}
Use Read-Write Locks
When accessing shared resources with a “read more, write less” pattern, using read-write locks instead of exclusive locks can significantly improve concurrency performance.
Read operations: multiple threads can simultaneously acquire shared locks (std::shared_lock), supporting concurrent read operations.
Write operations: can only be acquired by one thread with an exclusive lock (std::unique_lock), and when a write operation occurs, other threads are prohibited from reading.
mutex mtx; // Shared lock (read-write lock)
vector<string> strvec;
void readstr() {
// Exclusive lock
lock_guard<mutex> lock(mtx);
for (const string& s : strvec) {
cout << s << " ";
}}
void writestr() {
// Exclusive lock
lock_guard<mutex> lock(mtx);
for (string& s : strvec) {
s="header:" + s + "/header";
}}
In the above example, both read and write operations use exclusive locks, which should be changed to use read-write locks, controlling the timing of exclusivity and sharing.
shared_mutex rwMtx; // Shared lock (read-write lock)
vector<string> strvec;
void readstr() {
// Read shared
shared_lock<shared_mutex> lock(rwMtx);
for (const string& s : strvec) {
cout << s << " ";
}}
void writestr() {
// Write exclusive
unique_lock<shared_mutex> lock(rwMtx);
for (string& s : strvec) {
s="header:" + s + "/header";
}}
Avoid Lock Nesting
Common scenario: Thread 1 holds lock A and waits for lock B, while Thread 2 holds lock B and waits for lock A, leading to mutual blocking.
mutex mtx1, mtx2;
void fun1() {
// First acquire mutex 1, then acquire mutex 2
lock_guard<mutex> lock1(mtx1);
// When acquiring mutex 2, it has already been acquired by another thread
lock_guard<mutex> lock2(mtx2);
}
void fun2() {
// First acquire mutex 2, then acquire mutex 1
lock_guard<mutex> lock2(mtx2);
// When acquiring mutex 1, it has already been acquired by another thread
lock_guard<mutex> lock1(mtx1);
}
Optimization:
mutex mtx1, mtx2;
void fun1() {
unique_lock<mutex> lock1(mtx1, defer_lock/*deferred locking*/);
unique_lock<mutex> lock2(mtx2, defer_lock/*deferred locking*/);
lock(lock1, lock2); // Atomically acquire multiple locks
}
void fun2() {
unique_lock<mutex> lock2(mtx2, defer_lock/*deferred locking*/);
unique_lock<mutex> lock1(mtx1, defer_lock/*deferred locking*/);
lock(lock1, lock2); // Atomically acquire multiple locks
}
Use Atomic Operations
If the operations on shared resources are simple “read-modify-write” (like counters, flags), prefer using atomic operations over mutexes—atomic operations are lock-free synchronization, requiring no context switching and offering better performance. If the protected logic is complex (like multi-step operations, container read/write), locks are still necessary.
mutex mtx;
int mycount = 0;
void fun1() {
lock_guard<mutex> lock(mtx);
mycount++;
}
Optimization:
atomic<int> mycount=0;
void fun1() {
mycount++; // Atomic increment, no lock needed
}
