std::lock_guard example, explanation on why it works

44,319

Solution 1

myMutex is global, which is what is used to protect myList. guard(myMutex) simply engages the lock and the exit from the block causes its destruction, dis-engaging the lock. guard is just a convenient way to engage and dis-engage the lock.

With that out of the way, mutex does not protect any data. It just provides a way to protect data. It is the design pattern that protects data. So if I write my own function to modify the list as below, the mutex cannot protect it.

void addToListUnsafe(int max, int interval)
{
    for (int i = 0; i < max; i++) {
        if( (i % interval) == 0) myList.push_back(i);
    }
}

The lock only works if all pieces of code that need to access the data engage the lock before accessing and disengage after they are done. This design-pattern of engaging and dis-engaging the lock before and after every access is what protects the data (myList in your case)

Now you would wonder, why use mutex at all, and why not, say, a bool. And yes you can, but you will have to make sure that the bool variable will exhibit certain characteristics including but not limited to the below list.

  1. Not be cached (volatile) across multiple threads.
  2. Read and write will be atomic operation.
  3. Your lock can handle situation where there are multiple execution pipelines (logical cores, etc).

There are different synchronization mechanisms that provide "better locking" (across processes versus across threads, multiple processor versus, single processor, etc) at a cost of "slower performance", so you should always choose a locking mechanism which is just about enough for your situation.

Solution 2

Let’s have a look at the relevant line:

std::lock_guard<std::mutex> guard(myMutex);

Notice that the lock_guard references the global mutex myMutex. That is, the same mutex for all three threads. What lock_guard does is essentially this:

  • Upon construction, it locks myMutex and keeps a reference to it.
  • Upon destruction (i.e. when the guard's scope is left), it unlocks myMutex.

The mutex is always the same one, it has nothing to do with the scope. The point of lock_guard is just to make locking and unlocking the mutex easier for you. For example, if you manually lock/unlock, but your function throws an exception somewhere in the middle, it will never reach the unlock statement. So, doing it the manual way you have to make sure that the mutex is always unlocked. On the other hand, the lock_guard object gets destroyed automatically whenever the function is exited – regardless how it is exited.

Solution 3

Just to add onto what others here have said...

There is an idea in C++ called Resource Acquisition Is Initialization (RAII) which is this idea of binding resources to the lifetime of objects:

Resource Acquisition Is Initialization or RAII, is a C++ programming technique which binds the life cycle of a resource that must be acquired before use (allocated heap memory, thread of execution, open socket, open file, locked mutex, disk space, database connection—anything that exists in limited supply) to the lifetime of an object.

C++ RAII Info

The use of a std::lock_guard<std::mutex> class follows the RAII idea.

Why is this useful?

Consider a case where you don't use a std::lock_guard:

std::mutex m; // global mutex
void oops() {
   m.lock();
   doSomething();
   m.unlock();
}

in this case, a global mutex is used and is locked before the call to doSomething(). Then once doSomething() is complete the mutex is unlocked.

One problem here is what happens if there is an exception? Now you run the risk of never reaching the m.unlock() line which releases the mutex to other threads. So you need to cover the case where you run into an exception:

std::mutex m; // global mutex
void oops() {
   try {
      m.lock();
      doSomething();
      m.unlock();
   } catch(...) {
      m.unlock(); // now exception path is covered
      // throw ...
   }
}

This works but is ugly, verbose, and inconvenient.

Now lets write our own simple lock guard.

class lock_guard {
private:
   std::mutex& m;
public: 
   lock_guard(std::mutex& m_):(m(m_)){ m.lock(); }  // lock on construction
   ~lock_guard() { t.unlock(); }}                   // unlock on deconstruction
}

When the lock_guard object is destroyed, it will ensure that the mutex is unlocked. Now we can use this lock_guard to handle the case from before in a better/cleaner way:

std::mutex m; // global mutex
void ok() {
      lock_guard lk(m); // our simple lock guard, protects against exception case 
      doSomething(); 
} // when scope is exited our lock guard object is destroyed and the mutex unlocked

This is the same idea behind std::lock_guard.

Again this approach is used with many different types of resources which you can read more about by following the link on RAII.

Solution 4

This is precisely what a lock does. When a thread takes the lock, regardless of where in the code it does so, it must wait its turn if another thread holds the lock. When a thread releases a lock, regardless of where in the code it does so, another thread may acquire that lock.

Locks protect data, not code. They do it by ensuring all code that accesses the protected data does so while it holds the lock, excluding other threads from any code that might access that same data.

Share:
44,319
Deviatore
Author by

Deviatore

Updated on October 16, 2021

Comments

  • Deviatore
    Deviatore over 2 years

    I've reached a point in my project that requires communication between threads on resources that very well may be written to, so synchronization is a must. However I don't really understand synchronization at anything other than the basic level.

    Consider the last example in this link: http://www.bogotobogo.com/cplusplus/C11/7_C11_Thread_Sharing_Memory.php

    #include <iostream>
    #include <thread>
    #include <list>
    #include <algorithm>
    #include <mutex>
    
    using namespace std;
    
    // a global variable
    std::list<int>myList;
    
    // a global instance of std::mutex to protect global variable
    std::mutex myMutex;
    
    void addToList(int max, int interval)
    {
        // the access to this function is mutually exclusive
        std::lock_guard<std::mutex> guard(myMutex);
        for (int i = 0; i < max; i++) {
            if( (i % interval) == 0) myList.push_back(i);
        }
    }
    
    void printList()
    {
        // the access to this function is mutually exclusive
        std::lock_guard<std::mutex> guard(myMutex);
        for (auto itr = myList.begin(), end_itr = myList.end(); itr != end_itr; ++itr ) {
            cout << *itr << ",";
        }
    }
    
    int main()
    {
        int max = 100;
    
        std::thread t1(addToList, max, 1);
        std::thread t2(addToList, max, 10);
        std::thread t3(printList);
    
        t1.join();
        t2.join();
        t3.join();
    
        return 0;
    }
    

    The example demonstrates how three threads, two writers and one reader, accesses a common resource(list).

    Two global functions are used: one which is used by the two writer threads, and one being used by the reader thread. Both functions use a lock_guard to lock down the same resource, the list.

    Now here is what I just can't wrap my head around: The reader uses a lock in a different scope than the two writer threads, yet still locks down the same resource. How can this work? My limited understanding of mutexes lends itself well to the writer function, there you got two threads using the exact same function. I can understand that, a check is made right as you are about to enter the protected area, and if someone else is already inside, you wait.

    But when the scope is different? This would indicate that there is some sort of mechanism more powerful than the process itself, some sort of runtime environment blocking execution of the "late" thread. But I thought there were no such things in c++. So I am at a loss.

    What exactly goes on under the hood here?