Explores the problem of protecting Shared data when C++ programs are concurrent

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

  Let's start with a simple code to understand the problem.
Synchronization issues

We use a simple Counter struct that contains a value and a method to change the value:
 


struct Counter {
  int value;
 
  void increment(){
    ++value;
  }
};

Then start multiple threads to modify the value of the structure:

 


int main(){
  Counter counter;
 
  std::vector<std::thread> threads;
  for(int i = 0; i < 5; ++i){
    threads.push_back(std::thread([&counter](){
      for(int i = 0; i < 100; ++i){
        counter.increment();
      }
    }));
  }
 
  for(auto& thread : threads){
    thread.join();
  }
 
  std::cout << counter.value << std::endl;
 
  return 0;
}

We started five threads to increment the counter, incremented each thread 100 times, and then printed the counter at the end of the thread.


But when we ran the program, we hoped it would say 500, but it didn't. No one knows for sure what the program will print. Here's what it printed after it ran on my machine, and it changed every time:
 


442
500
477
400
422
487

The reason for the problem is that changing the counter value is not an atomic operation. It takes three operations to increase the counter:

      First read the value of value       Then increment the value by 1       Assign the new value to value

But of course there's nothing wrong with using a single thread to run this program, so the program executes sequentially, but in a multi-threaded environment, there's trouble. Imagine this sequence:

      Thread 1: reads value, gets 0, plus 1, so value = 1       Thread 2: reads value, gets 0, plus 1, so value = 1       Thread 1: assigns 1 to value and returns 1       Thread 2: assigns 1 to value and returns 1

This situation is called multithreaded interleaving, which means that multiple threads may execute the same statement at the same time, even though there are only two threads, interleaving is obvious. If you have more threads and more operations to perform, this interleaving is inevitable.

There are many ways to solve the thread interleaving problem:

      Semaphore Semaphores       Atomic references       Monitors       Condition codes       Compare and swap

In this article we will learn how to use semaphores to solve this problem. Semaphores are also known by many as Mutex. Only one thread is allowed to acquire the lock of a Mutex at a time, and the simple properties of Mutex can be used to solve interleaving problems.

Use Mutex to make the counter thread-safe

In the C++11 thread library, the mutex is contained in the mutex header file. The corresponding class is STD ::mutex. There are two important methods :mutex :lock() and unlock(). Once a mutex is locked, a second call to lock() returns a block worthy of the object being released.

To make our counter structure thread-safe, we added a set:mutext member and protected each method with the lock()/unlock() method:
 


struct Counter {
  std::mutex mutex;
  int value;
 
  Counter() : value(0) {}
 
  void increment(){
    mutex.lock();
    ++value;
    mutex.unlock();
  }
};

Then we tested the program again, and the result was 500, and it was the same every time.

Abnormal and lock

Now let's look at the other case, imagine our counter has a minus operation and throws an exception when the value is zero:
 


struct Counter {
  int value;
 
  Counter() : value(0) {}
 
  void increment(){
    ++value;
  }
 
  void decrement(){
    if(value == 0){
      throw "Value cannot be less than 0";
    }
 
    --value;
  }
};

Then we do not need to modify the class to access the structure, we create a wrapper:
 


struct ConcurrentCounter {
  std::mutex mutex;
  Counter counter;
 
  void increment(){
    mutex.lock();
    counter.increment();
    mutex.unlock();
  }
 
  void decrement(){
    mutex.lock();
    counter.decrement();    
    mutex.unlock();
  }
};

Most of the time the wrapper works fine, but exceptions occur when using the decrement method. This is a big problem. Once an exception occurs, the unlock method is not called, causing the mutex to be held and the entire program to be blocked (deadlock). To solve this problem, we need to use the try/catch structure to handle the exception:
 


void decrement(){
  mutex.lock();
  try {
    counter.decrement();
  } catch (std::string e){
    mutex.unlock();
    throw e;
  }
  mutex.unlock();
}

This code is not difficult, but it looks ugly. If you have 10 exit points for a function, you must call the unlock method once for each exit point.

Now let's see how to solve this problem.

Automatic lock management

When you need to include an entire block of code (in our case, a method, maybe a loop, or some other control structure), there is a good way to avoid forgetting to release the lock: STD ::lock_guard.

This class is a simple smart lock manager, but when STD ::lock_guard is created, the mutex object's lock() method is automatically called, and the lock is automatically released when lock_guard is destructed. See the following code:

 


struct ConcurrentSafeCounter {
  std::mutex mutex;
  Counter counter;
 
  void increment(){
    std::lock_guard<std::mutex> guard(mutex);
    counter.increment();
  }
 
  void decrement(){
    std::lock_guard<std::mutex> guar(mutex);
    mutex.unlock();
  }
};

Does it look better?

With lock_guard, you no longer need to think about when to release the lock, which is done for you by the STD ::lock_guard instance.

conclusion

In this article we learned how to protect Shared data through semaphores/mutexes. Keep in mind that using locks can degrade program performance. There are other, better solutions in some highly concurrent applications, but that is outside the scope of this article.

You can get the source of this article on Github.


Related articles: