Double-checked Locking

  • More efficient version of thread-safe lazy Initialization

  • If ptest is not initialized

    • Lock the mutex
    • If ptest is not initialized, initialize it
    • Unlock the mutex
    • Use ptest
  • Otherwise

    • Use ptest
  • ptest is 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 ptest involves 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 ptest and locks the mutex
  • Thread A allocates the memory and assigns to ptest ptest = new sizeof(Test);
  • However, it has not yet called the constructor
  • Thread B checks ptest and 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 ptest ptest = 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 ptest is not required outside the function
    • Otherwise, use std::call_once()