Double-checked Locking
-
More efficient version of thread-safe lazy Initialization
-
If
ptestis not initialized- Lock the mutex
- If
ptestis not initialized, initialize it - Unlock the mutex
- Use
ptest
-
Otherwise
- Use
ptest
- Use
-
ptestis checked twice (why?)
if (!ptest) { // (1)
std::lock_guard luck_guard(mut); // (2)
ptest = new Test; // (3)
}
// use ptest...
-
Statement (1) checks
ptest -
Statement (2) locks the mutex
-
Another thread could interleave between these operations
- Race condition
-
So we have this extra check here
if (!ptest) { // (1)
std::lock_guard luck_guard(mut); // (2)
if (!ptest)
ptest = new Test; // (3)
}
// use ptest...
- (1) First check of
ptest - (2) Second check of
ptest - (3) Initialize
ptest
Is That Not Enough?
- There is still a race condition
ptest = new Test; - The initialization of
ptestinvolves several operations- Allocate enough memory to store a Test object
- Construct a Test object in the memory
- Store the address in
ptest
- C++ allows these to be performed in a different order, e.g.
- Allocate enough memory to store a Test object
- Store the address in
ptest - Construct a Test object in the memory
Undefined behavior
- Thread A checks
ptestand locks the mutex - Thread A allocates the memory and assigns to
ptestptest = new sizeof(Test); - However, it has not yet called the constructor
- Thread B checks
ptestand it is not null - Thread B does not lock the mutex
- Thread B jumps out of the if statement
- Thread B calls a member function of an uninitialized object
- Undefined behavior
std::call_once
-
One way to solve this is to use
std::call_once()- A given function is only called once
- It is done in one thread
- The thread cannot be interrupted until the function call completes
-
We use it with a global instance of
std::once_flag -
We pass the instance and the function to
std::call_once()
#include <iostream>
#include <mutex>
#include <thread>
class Test {
public:
// the constructor is only called once
Test() { std::cout << "Test constructor called\n"; }
void func() { /*...*/ }
};
Test *ptest = nullptr; // Variable to be lazily initialized
// The flag stores synchronization data
std::once_flag ptes_flag;
// Pass a callable object which performs the initialization
void process() {
std::call_once(ptes_flag, []() { ptest = new Test; });
ptest->func();
}
int main() {
std::thread thr1(process);
std::thread thr2(process);
thr1.join();
thr2.join();
}
Double checked locking with std::call_once
// The flag stores synchronization data
std::once_flag ptes_flag;
// Pass a callable object which performs the initialization
void process() {
std::call_once(ptes_flag, []() { ptest = new Test; });
ptest->func();
}
- Thread safe
- Less overhead than a mutex
Double-checked Locking in C++17
- C++17 defines the order of initialization
- Allocate enough memory to store a Test object
- Construct a test object in the memory
- Store the address in
ptestptest = new Test;
- Double-checked locking no longer causes a data race
// Using Meyers singleton
void process() {
static Test ptest;
ptest.func();
}
Conclusion
-
Four ways to do thread-safe lazy initialization
- Naive use of a mutex
- Use
std::call_once() - Double-checked locking with a C++17 compiler or later
- Meyers singleton with static local variable
-
Recommendations
- Use Meyers singleton, if
ptestis not required outside the function - Otherwise, use
std::call_once()
- Use Meyers singleton, if