C++11 Prefer Locks to Mutexes(译)

If the previous post showed something, it’s, that you should use mutexes with great care. That’s why you should wrap them in a lock.

  • The previous article explained the need to use mutex with special care (otherwise it is prone to deadlock problems). We should use mutex with lock.

Locks

Locks take care of thier resource following the RAII idiom. A lock automatically binds its mutex in the constructor and releases it in the destructor. This considerably reduces risk of a deadlock, because the runtime takes care of the mutex.
Locks are available in two flavours in C++11. std::lock_guard for the simple, and std::unique-lock for the advanced use case.

  • Locks handle resources according to RAII idioms. The lock automatically binds mutex in the constructor and releases it in the destructor. This greatly reduces the risk of deadlock, because the runtime is responsible for processing the mutex.
  • There are two locks to choose from in C++11. The simple use case is std::lock_guard, and the advanced use case is std::unique-lock.
std::lock_guard
  • First, the simple use case.

  • The first is simple usage.

    mutex m;
    m.lock();
    sharedVariable= getVar();
    m.unlock();
    

With so little code mutex m ensures access of the critical section sharedVariable= getVar() is sequential. Sequential means - in this special case - that each thread gains acces to critical section in order. The code is simple, but prone to deadlocks. Deadlock appears if the critical section throws an exception or if the programmer simply forgets to unlock the mutex. With std::lock_guard we can do this more elegant:

  • With so little code, mutex m ensures that access to the critical section sharedVariable = getVar() is sequential. In this special case, Sequential means that each thread enters the critical section in turn. The code is very simple, but it has been proven that a deadlock may occur. If the critical section throws an exception, or the programmer forgets to unlock the mutex, a deadlock will occur. Using std::lock_guard we can do it more elegantly:

    {
          
          
      std::mutex m,
      std::lock_guard<std::mutex> lockGuard(m);
      sharedVariable= getVar();
    }
    

That was easy. But what’s about the opening and closing brackets? The lifetime of std::lock_guard is limited by the brackets (http://en.cppreference.com/w/cpp/language/scope#Block_scope). That means, its lifetime ends when it leaves the critical section. Exactly at that time point, the destructor of std::lock_guard is called and - I guess, you know it - the mutex is released. It happens automatically, and, in addition, it happens if getVar() in sharedVariable = getVar() throws an exception. Of course, function body scope or loop scope also limit the lifetime of an object.

  • It's easy. But what do the opening and closing brackets mean? The life cycle of std::lock_guard is limited by brackets (http://en.cppreference.com/w/cpp/language/scope#Block_scope). This means that when it leaves the critical zone, its life cycle is over. At that moment, the destructor of std::lock_guard is called, and—I guess, you know—the mutex is released. It happens automatically, and if the getVar() in sharedVariable = getVar() throws an exception, it will also happen. Of course, function body scope or loop scope also limits the life cycle of the object.
std::unique_lock

std::unique_lock is mightier but more expansive than its small brother std::lock_guard.
A std::unique_lock enables you in addition to std::lock_guard

  • create it without an associated mutex
  • create it without a locked associated mutex
  • explicitly and repeatedly set or release the lock of the associated mutex
  • move the mutex
  • try to lock the mutex
  • delayed lock the associated mutex

But why is it necessary? Remember the deadlock from the post Risks of mutexes? The reason for the deadlock was the mutexes were locked in different sequence.

  • unique_lock is stronger and more scalable than its sibling std::lock_guard.

  • A std::unique_lock allows you in addition to the functions of std::lock_guard, and

    • No need to associate mutex when creating it
    • No need to lock the associated mutex when creating it
    • Explicitly and repeatedly set or release associated mutex
    • Mobile mutex
    • Try to lock mutex
    • Delay lock associated mutex
  • But why is this necessary? Remember the deadlock in the previous Risks of mutexes ? The cause of the deadlock is that the mutex is locked in a different thread.

    // deadlock.cpp
    
    #include <iostream>
    #include <chrono>
    #include <mutex>
    #include <thread>
    
    struct CriticalData{
          
          
      std::mutex mut;
    };
    
    void deadLock(CriticalData& a, CriticalData& b){
          
          
    
      a.mut.lock();
      std::cout << "get the first mutex" << std::endl;
      std::this_thread::sleep_for(std::chrono::milliseconds(1));
      b.mut.lock();
      std::cout << "get the second mutex" << std::endl;
      // do something with a and b
      a.mut.unlock();
      b.mut.unlock();
      
    }
    
    int main(){
          
          
    
      CriticalData c1;
      CriticalData c2;
    
      std::thread t1([&]{
          
          deadLock(c1,c2);});
      std::thread t2([&]{
          
          deadLock(c2,c1);});
    
      t1.join();
      t2.join();
    
    }
    

The solution is easy. The function deadlock has to lock their mutex in an atomic fashion. That’s exactly what happens in the following example.

  • The solution is simple. The deadlock function must lock their mutex atomically. The following example is like this.

    // deadlockResolved.cpp
    
    #include <iostream>
    #include <chrono>
    #include <mutex>
    #include <thread>
    
    struct CriticalData{
          
          
      std::mutex mut;
    };
    
    void deadLock(CriticalData& a, CriticalData& b){
          
          
    
      std::unique_lock<std::mutex>guard1(a.mut,std::defer_lock);
      std::cout << "Thread: " << std::this_thread::get_id() << " first mutex" <<  std::endl;
    
      std::this_thread::sleep_for(std::chrono::milliseconds(1));
    
      std::unique_lock<std::mutex>guard2(b.mut,std::defer_lock);
      std::cout << "    Thread: " << std::this_thread::get_id() << " second mutex" <<  std::endl;
    
      std::cout << "        Thread: " << std::this_thread::get_id() << " get both mutex" << std::endl;
      std::lock(guard1,guard2);
      // do something with a and b
    }
    
    int main(){
          
          
    
      std::cout << std::endl;
    
      CriticalData c1;
      CriticalData c2;
    
      std::thread t1([&]{
          
          deadLock(c1,c2);});
      std::thread t2([&]{
          
          deadLock(c2,c1);});
    
      t1.join();
      t2.join();
    
      std::cout << std::endl;
    
    }
    

In case you call the constructor of std::unique_lock with the argument std::defer_lock, the lock will not be locked automatically. It happens in line 14 and 19. The lock operation is performed atomically in line 23 by using the variadic template std::lock. A variadic template is a template which can accept an arbitrary number of arguments. Here, the arguments are locks. std::lock tries to get the all locks in an atomic step. So, he fails or gets all of them.

  • If you call the constructor of std::unique_lock with the parameter std::defer_lock, the lock will not be automatically locked. It happens on lines 14 and 19. By using the variable parameter template std::lock, the lock operation is performed atomically on line 23. A variadic template is a template that can accept any number of parameters. The parameter here is lock. std::lock tries to acquire all locks in one atomic step. So, he either failed or got it.

In this example, std::unique_lock takes care of the lifetime of the resources, std::lock locks the associated mutex. But, you can do it the other way around. In the first step you lock the mutexes, in the second std::unique_lock takes care of the lifetime of resources. Here is a sketch of the second approach.

  • In this example, std::unique_lock is responsible for the life cycle of the resource, and std::lock locks the associated mutex. However, you can do the reverse. In the first step, the mutex is locked, and in the second step, std::unique_lock handles the life cycle of the resource. Below is an overview of the second method.

    std::lock(a.mut, b.mut);
    std::lock_guard<std::mutex> guard1(a.mut, std::adopt_lock);
    std::lock_guard<std::mutex> guard2(b.mut, std::adopt_lock);
    

Now, all is fine. The program runs without deadlock.

  • Now everything is fine. There is no deadlock when the program is running.
    Insert picture description here
A side note: Special deadlocks

It’s an illusion that only a mutex can produce a deadlock. Each time a thread has to wait for a resource, while it is holding a resource, a deadlock lurks near.
Even a thread is a resource.

  • It is an illusion to think that only mutex can produce deadlock. Whenever a thread needs to wait for a resource and is currently holding a resource, a deadlock will be lurking nearby.
  • Threads are also resources.
    // blockJoin.cpp
    
    #include <iostream>
    #include <mutex>
    #include <thread>
    
    std::mutex coutMutex;
    
    int main(){
          
          
    
      std::thread t([]{
          
          
        std::cout << "Still waiting ..." << std::endl;
        std::lock_guard<std::mutex> lockGuard(coutMutex);
        std::cout << std::this_thread::get_id() << std::endl;
        }
      );
    
      {
          
          
        std::lock_guard<std::mutex> lockGuard(coutMutex);
        std::cout << std::this_thread::get_id() << std::endl;
        t.join();
      }
    
    }
    

The program immediately stands still.

  • The program stopped moving immediately.
    Insert picture description here

What’s happening? The lock of output stream std::cout and the waiting of the main thread for its child t are the cause for the deadlock. By observing the output, you can easily see, in which order the statements will be performed.

  • What happened? The locking of the output stream std::cout and the main thread's waiting for its child thread t are the cause of the deadlock. By observing the output, you can easily see the execution order of the statements.

In the first step, the main thread executes the lines 19 - 21. It waits in line 21 by using the call t.join(), until its child t is done with its work package. The main thread is waiting, while it is locks the output stream. But that’s exactly the resource the child is waiting for. Two ways to solve this deadlock come to mind.

  • In the first step, the main thread executes lines 19-21. It calls t.join() in line 21 and waits until its child thread t completes its work. The main thread is waiting, and it has locked the output stream. But this is the resource that the child thread is waiting for. There are two ways to resolve this deadlock.

    • The main thread locks the output stream std::cout after the call t.join().
    • The main thread locks the output stream std::cout after calling t.join().
      {
              
              
        t.join();
        std::lock_guard<std::mutex> lockGuard(coutMutex);
        std::cout << std::this_thread::get_id() << std::endl;
      }
      
    • The main thread releases its lock by an additional scope. This is done before the t.join() call.
    • The main thread releases its lock through an extra scope. This is done before calling t.join().

      {
              
              
        {
              
              
          std::lock_guard<std::mutex> lockGuard(coutMutex);
          std::cout << std::this_thread::get_id() << std::endl;
        }
        t.join();
      }
      

What’s next?

In the next post I’ll talk about reader-writer locks. Reader-writer locks empowers you since C++14, to distinguish between reading and writing threads. So, the contention on the shared variable will be mitigated, because an arbitrary number of reading threads can access the shared variable at the same time.

  • In the next article , I will discuss the read-write lock. Since C++14, read-write locks allow you to distinguish between reader threads and writer threads. Therefore, contention for shared variables will be reduced, because any number of reader threads can access shared variables at the same time.

Guess you like

Origin blog.csdn.net/luoshabugui/article/details/110429770