c++11 multithreading programming (3) -- competition and mutual exclusion

Keywords: C++

Competitive conditions

One of the most common errors in concurrent code is race condition. The most common is data race. On the whole, the problem of sharing data between all threads is caused by modifying data. If all shared data are read-only, there will be no problem. But this is impossible. Most of the shared data needs to be modified.

The common cout in c + + is a shared resource. If multiple threads execute cout at the same time, you will find strange problems:

#include <iostream>
#include <thread>
#include <string>
using namespace std;

// Ordinary functions have no parameters
void function_1() {
    for(int i=0; i>-100; i--)
        cout << "From t1: " << i << endl;
}

int main()
{
    std::thread t1(function_1);

    for(int i=0; i<100; i++)
        cout << "From main: " << i << endl;

    t1.join();
    return 0;
}

You have a good chance of finding strange print results like From t1: From main: 64. cout is stream based. It will first put the content you want to print into the buffer. Maybe a thread just put it into From t1:  , Another thread executes, resulting in disordered output. printf in c does not have this problem.

Protect shared data with mutex

The solution is to protect cout, a shared resource. In c + +, you can use the mutex std::mutex for resource protection. The header file is #include < mutex >. There are two operations: lock and unlock. Repackage cout into a thread safe function:

#include <iostream>
#include <thread>
#include <string>
#include <mutex>
using namespace std;

std::mutex mu;
// Use lock protection
void shared_print(string msg, int id) {
    mu.lock(); // Lock
    cout << msg << id << endl;
    mu.unlock(); // Unlock
}

void function_1() {
    for(int i=0; i>-100; i--)
        shared_print(string("From t1: "), i);
}

int main()
{
    std::thread t1(function_1);

    for(int i=0; i<100; i++)
        shared_print(string("From main: "), i);

    t1.join();
    return 0;
}

After modification, you can find that there is no problem printing. But there is also a hidden problem. What happens if an exception occurs in the statement between mu.lock() and mu.unlock()? The unlock() statement has no chance to execute! As a result, Mu is always locked, and others use shared_ The thread of the print () function will block.

It is also very simple to solve this problem. Use the common RAII technology in c + +, that is, resource acquisition is initialization technology, which is a common way to manage resources in c + +. Simply put, create resources in the constructor of the class and release resources in the destructor, because even if an exception occurs, c + + can ensure that the destructor of the class can be executed. We don't need to write a class wrapper mutex. The c + + library already provides STD:: lock_ The guard class template is used as follows:

void shared_print(string msg, int id) {
    //Help lock when constructing and release the lock when destructing
    std::lock_guard<std::mutex> guard(mu);
    //mu.lock(); //  Lock
    cout << msg << id << endl;
    //mu.unlock(); //  Unlock
}

You can implement your own std::lock_guard, like this:

class MutexLockGuard
{
 public:
  explicit MutexLockGuard(std::mutex& mutex)
    : mutex_(mutex)
  {
    mutex_.lock();
  }

  ~MutexLockGuard()
  {
    mutex_.unlock();
  }

 private:
  std::mutex& mutex_;
};

Carefully organize code to protect shared data

The above std::mutex mutex is a global variable. It is shared_print() is prepared. At this time, we'd better bind them together. For example, they can be encapsulated into a class. Because cout is a globally shared variable, it cannot be completely encapsulated. Even if you encapsulate it, cout can still be used outside without locking. The following is an example of using a file stream:

#include <iostream>
#include <thread>
#include <string>
#include <mutex>
#include <fstream>
using namespace std;

std::mutex mu;
class LogFile {
    std::mutex m_mutex;
    ofstream f;
public:
    LogFile() {
        f.open("log.txt");
    }
    ~LogFile() {
        f.close();
    }
    void shared_print(string msg, int id) {
        std::lock_guard<std::mutex> guard(mu);
        f << msg << id << endl;
    }
};

void function_1(LogFile& log) {
    for(int i=0; i>-100; i--)
        log.shared_print(string("From t1: "), i);
}

int main()
{
    LogFile log;
    std::thread t1(function_1, std::ref(log));

    for(int i=0; i<100; i++)
        log.shared_print(string("From main: "), i);

    t1.join();
    return 0;
}

The above LogFile class encapsulates a mutex and an ofstream object, and then shares_ The print function is thread safe under the protection of mutex. When using, first define an instance log of LogFile, which can be used directly in the main thread and passed by reference in the sub thread (you can also use a single instance), so as to ensure that the resources are protected by mutex lock. There is no way to use them outside, but the resources are used.

But you still have to be careful at this time! Using mutex to protect data is not only to protect each function as above, but also to fully ensure thread safety. If the resource pointer or reference is accidentally passed out, all protection will be in vain! Two things to remember:

  1. Do not provide functions for users to obtain resources.

    std::mutex mu;
    class LogFile {
        std::mutex m_mutex;
        ofstream f;
    public:
        LogFile() {
            f.open("log.txt");
        }
        ~LogFile() {
            f.close();
        }
        void shared_print(string msg, int id) {
            std::lock_guard<std::mutex> guard(mu);
            f << msg << id << endl;
        }
        // Never return f to the outside world
        ofstream& getStream() {
            return f;  //never do this !!!
        }
    };

  2. Do not use functions that pass resources to users.

    class LogFile {
        std::mutex m_mutex;
        ofstream f;
    public:
        LogFile() {
            f.open("log.txt");
        }
        ~LogFile() {
            f.close();
        }
        void shared_print(string msg, int id) {
            std::lock_guard<std::mutex> guard(mu);
            f << msg << id << endl;
        }
        // Never return f to the outside world
        ofstream& getStream() {
            return f;  //never do this !!!
        }
        // Never pass f as an argument to user provided function
        void process(void fun(ostream&)) {
            fun(f);
        }
    };

Both of the above methods will expose resources to users, resulting in unnecessary security risks.

There are also competitive conditions in interface design

The stack class in STL is thread unsafe, so you want to write your own thread safe class stack. So, when you push and pop, you add a mutex to protect the data. However, when using your stack class in a multithreaded environment, it may still be thread unsafe. why?

Suppose the interface of your Stack class is as follows:

class Stack
{
public:
    Stack() {}
    void pop(); //Pop up stack top element
    int& top(); //Get stack top element
    void push(int x);//Put elements on the stack
private:
    vector<int> data; 
    std::mutex _mu; //Protect internal data
};

Each function in the class is thread safe, but not when combined. There are 4 elements including 9, 3, 8 and 6 added to the stack. You want to use two threads to take out the elements in the stack for processing, as shown below:

   Thread A                Thread B
int v = st.top(); // 6
                      int v = st.top(); // 6
st.pop(); //Pop up 6
                      st.pop(); //Pop up 8
                      process(v);//Treatment 6
process(v); //Treatment 6

It can be found that in this execution order, the top element of the stack is processed twice, and one more element 8 pops up, resulting in ` 8 not being processed! This is the competition caused by improper interface design. The solution is to merge the two interfaces into one interface! You can get a thread safe stack.

class Stack
{
public:
    Stack() {}
    int& pop(); //Pop up the top element and return
    void push(int x);//Put elements on the stack
private:
    vector<int> data; 
    std::mutex _mu; //Protect internal data
};

//This will not cause problems
int v = st.pop(); // 6
process(v);

Note: this modification is thread safe, but not abnormally safe, which is why the stack out operation in STL is divided into two steps. (I haven't figured out why it's not abnormally safe.)

Therefore, in order to protect shared data, the interface must be well designed.

reference resources

  1. C + + concurrent programming practice
  2. C++ Threading #3: Data Race and Mutex

Posted by francoisp on Wed, 01 Dec 2021 13:32:32 -0800