Data protection in C++ multi threaded programming

  • 2020-04-02 03:10:09
  • OfStack

  In this article, we will introduce data protection in C++11 multi-threaded programming.
Data loss

Let's start with a simple example that looks like this:
 


#include <iostream>
#include <string>
#include <thread>
#include <vector>
 
using std::thread;
using std::vector;
using std::cout;
using std::endl;
 
class Incrementer
{
  private:
    int counter;
 
  public:
    Incrementer() : counter{0} { };
 
    void operator()()
    {
      for(int i = 0; i < 100000; i++)
      {
        this->counter++;
      }
    }
 
    int getCounter() const
    {
      return this->counter;
    }   
};
 
int main()
{
  // Create the threads which will each do some counting
  vector<thread> threads;
 
  Incrementer counter;
 
  threads.push_back(thread(std::ref(counter)));
  threads.push_back(thread(std::ref(counter)));
  threads.push_back(thread(std::ref(counter)));
 
  for(auto &t : threads)
  {
    t.join();
  }
 
  cout << counter.getCounter() << endl;
 
  return 0;
}

The purpose of this program is to count up to 300,000. Some stupid programmer wants to optimize the counting process, so he creates three threads, USES a Shared variable counter, and each thread is responsible for adding 100,000 counts to this variable.

This code creates a class named Incrementer that contains a private variable counter with a simple constructor that sets counter to 0.

This is followed by an operator overload, which means that each instance of the class is called as a simple function. Usually we call a method of a class like object.foomethod (), but now you're actually calling an object directly, like object(), because we're passing the entire object to the thread class in the operator overloading function. Finally, a getCounter method returns the value of the counter variable.

Next comes the program's entry function main(). We create three threads, but only one instance of the Incrementer class, which we then pass to the three threads. Note the use of STD ::ref, which is passing the reference object of the instance, not a copy of the object.

Now let's take a look at the results of the program execution. If this stupid programmer is smart enough, he will compile using GCC 4.7 or later, or Clang 3.1.
 


g++ -std=c++11 -lpthread -o threading_example main.cpp

Operation results:

 


[lucas@lucas-desktop src]$ ./threading_example
218141
[lucas@lucas-desktop src]$ ./threading_example
208079
[lucas@lucas-desktop src]$ ./threading_example
100000
[lucas@lucas-desktop src]$ ./threading_example
202426
[lucas@lucas-desktop src]$ ./threading_example
172209

But wait, no, it didn't count to 300,000, it only counted to 100,000 once, why is that? Well, the plus 1 operation corresponds to the actual processor instructions, which actually include:
 


movl  counter(%rip), %eax
addl  $1, %eax
movl  %eax, counter(%rip)

The first instruction loads the value of counter into the %eax register, then increments the value of the register by 1, then moves the value of the register to the address where the counter is located in memory.

I hear you muttering: that's good, but why does it cause counting errors? Well, remember we talked about threads sharing processors, because there's only one core. So at some point, one thread will follow the instructions, but in many cases, the operating system will say to the thread, "time is up, queue up, come back, and then another thread will execute, and when the next thread starts, it will execute from the place where it was suspended." So guess what happens when the current thread is about to execute register 1 and the system hands off the processor to another thread?

I don't really know what's going to happen, but maybe we're going to add 1, and then another thread comes in and reloads the counter into the register, and so on. No one knows what happened.

The right way

The solution is to require that only one thread be allowed to access the Shared variables at a time. This can be solved with the STD ::mutex class. When a thread enters, it locks, performs an operation, and then releases the lock. Other threads that want to access the Shared resource must wait for the lock to be released.

Mutex is the operating system's assurance that the lock and unlock operations are indivisible. This means that threads cannot be interrupted while locking and unlocking the mutex. When a thread locks or unlocks a mutex, the operation is completed before the operating system switches threads.

The best thing is that when you try to lock a mutex, other threads have already locked the mutex, and you have to wait until it is released. The operating system tracks which thread is waiting for which mutex, and the blocked thread enters a "blocked onm" state, meaning that the operating system does not give the blocked thread any processor time until the mutex is unlocked, and therefore does not waste CPU cycles. If more than one thread is in a waiting state, which thread gets the resource first depends on the operating system itself. Generally, systems like Windows and Linux use a FIFO policy, but in real-time operating systems it is based on priority.

Now let's improve the above code:
 


#include <iostream>
#include <string>
#include <thread>
#include <vector>
#include <mutex>
 
using std::thread;
using std::vector;
using std::cout;
using std::endl;
using std::mutex;
 
class Incrementer
{
  private:
    int counter;
    mutex m;
 
  public:
    Incrementer() : counter{0} { };
 
    void operator()()
    {
      for(int i = 0; i < 100000; i++)
      {
        this->m.lock();
        this->counter++;
        this->m.unlock();
      }
    }
 
    int getCounter() const
    {
      return this->counter;
    } 
};
 
int main()
{
  // Create the threads which will each do some counting
  vector<thread> threads;
 
  Incrementer counter;
 
  threads.push_back(thread(std::ref(counter)));
  threads.push_back(thread(std::ref(counter)));
  threads.push_back(thread(std::ref(counter)));
 
  for(auto &t : threads)
  {
    t.join();
  }
 
  cout << counter.getCounter() << endl;
 
  return 0;
}

Note the change in code: we introduced the mutex header file and added a member of m of type mutex, in operator()() we locked the mutex m and then added 1 to counter, then released the mutex.


Execute the above procedure again and the result is as follows:
 


[lucas@lucas-desktop src]$ ./threading_example
300000
[lucas@lucas-desktop src]$ ./threading_example
300000

That's the right number. But in computer science, there's no free lunch, and using mutexes can degrade a program's performance, but it's better than having a bad program.

To prevent abnormal

Exceptions are possible when you add 1 to a variable, which is extremely unlikely in our case, but very likely in some complex systems. The code above is not exception-safe; when an exception occurs, the program is finished, but the mutex is still locked.

To ensure that the mutex can be unlocked even if an exception occurs, we need to use the following code:
 


for(int i = 0; i < 100000; i++)
{
 this->m.lock();
 try
  {
   this->counter++;
   this->m.unlock();
  }
  catch(...)
  {
   this->m.unlock();
   throw;
  }
}

However, this code is too much, just to lock and unlock the mutex. Never mind, I know you're lazy, so I recommend a simpler one-line solution that USES the STD ::lock_guard class. This class locks the mutex object when it is created and then releases it at the end.

Continue to modify the code:
 


void operator()()
{
  for(int i = 0; i < 100000; i++)
  {
  lock_guard<mutex> lock(this->m);
 
  // The lock has been created now, and immediatly locks the mutex
  this->counter++;
 
  // This is the end of the for-loop scope, and the lock will be
  // destroyed, and in the destructor of the lock, it will
  // unlock the mutex
  }
}

The above code is already exception-safe because when an exception occurs, the destructor of the lock object is called and the mutex is automatically unlocked.

Remember, use the code drop template to write:

 


void long_function()
{
  // some long code
 
  // Just a pair of curly braces
  {
  // Temp scope, create lock
  lock_guard<mutex> lock(this->m);
 
  // do some stuff
 
  // Close the scope, so the guard will unlock the mutex
  }
}


Related articles: