C++11 concurrency: locks

In a previous post I introduced the C++11 support for threads. In this article I will discuss the locking features provided by the standard that one can use to synchronize access to shared resources.

The core syncing primitive is the mutex, which comes in four flavors, in the <mutex> header:

  • mutex: provides the core lock() and unlock() and the non-blocking try_lock() method that returns if the mutex is not available.
  • recursive_mutex: allows multiple acquisitions of the mutex from the same thread.
  • timed_mutex: similar to mutex, but it comes with two more methods try_lock_for() and try_lock_until() that try to acquire the mutex for a period of time or until a moment in time is reached.
  • recursive_timed_mutex: is a combination of timed_mutex and recusive_mutex.

Here is a simple example of using a mutex to sync the access to the std::cout shared object.

#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>

std::mutex g_lock;

void func()
{
    g_lock.lock();

    std::cout << "entered thread " << std::this_thread::get_id() << std::endl;
    std::this_thread::sleep_for(std::chrono::seconds(rand() % 10));
    std::cout << "leaving thread " << std::this_thread::get_id() << std::endl;

    g_lock.unlock();
}

int main()
{
    srand((unsigned int)time(0));

    std::thread t1(func);
    std::thread t2(func);
    std::thread t3(func);

    t1.join();
    t2.join();
    t3.join();

    return 0;
}

In the next example we’re creating a simple thread-safe container (that just uses std::vector internally) that has methods like add() and addrange(), with the later implemented by calling the first.

template <typename T>
class container 
{
    std::mutex _lock;
    std::vector<T> _elements;
public:
    void add(T element) 
    {
        _lock.lock();
        _elements.push_back(element);
        _lock.unlock();
    }

    void addrange(int num, ...)
    {
        va_list arguments;

        va_start(arguments, num);

        for (int i = 0; i < num; i++)
        {
            _lock.lock();
            add(va_arg(arguments, T));
            _lock.unlock();
        }

        va_end(arguments); 
    }

    void dump()
    {
        _lock.lock();
        for(auto e : _elements)
            std::cout << e << std::endl;
        _lock.unlock();
    }
};

void func(container<int>& cont)
{
    cont.addrange(3, rand(), rand(), rand());
}

int main()
{
    srand((unsigned int)time(0));

    container<int> cont;

    std::thread t1(func, std::ref(cont));
    std::thread t2(func, std::ref(cont));
    std::thread t3(func, std::ref(cont));

    t1.join();
    t2.join();
    t3.join();

    cont.dump();

    return 0;
}

Running this program results in a deadlock.

The reason for the deadlock is that the tread that own the mutex cannot re-acquire the mutex, and such an attempt results in a deadlock. That’s were recursive_mutex come into picture. It allows a thread to acquire the same mutext multiple times. The maximum number of times is not specified, but if that number is reached, calling lock would throw a std::system_error. Therefore to fix this implementation (apart from changing the implementation of addrange not to call lock and unlock) is to replace the mutex with a recursive_mutex.

template <typename T>
class container 
{
    std::recursive_mutex _lock;
    // ...
};

Then the output looks something like this:

6334
18467
41
6334
18467
41
6334
18467
41

Notice the same numbers are generated in each call to func(). That is because the seed is thread local, and the call to srand() only initializes the seed from the main thread. In the other worker threads it doesn’t get initialized, and therefore you get the same numbers every time.

Explicit locking and unlocking can lead to problems, such as forgetting to unlock or incorrect order of locks acquiring that can generate deadlocks. The standard provides several classes and functions to help with this problems.

The wrapper classes allow consistent use of the mutexes in a RAII-style with auto locking and unlocking within the scope of a block. These wrappers are:

  • lock_guard: when the object is constructed it attempts to acquire ownership of the mutex (by calling lock()) and when the object is destructed it automatically releases the mutex (by calling unlock()). This is a non-copyable class.
  • unique_lock: is a general purpose mutex wrapper that unlike lock_quard also provides support for deferred locking, time locking, recursive locking, transfer of lock ownership and use of condition variables. This is also a non-copyable class, but it is moveable.

With these wrappers we can rewrite the container class like this:

template <typename T>
class container 
{
    std::recursive_mutex _lock;
    std::vector<T> _elements;
public:
    void add(T element) 
    {
        std::lock_guard<std::recursive_mutex> locker(_lock);
        _elements.push_back(element);
    }

    void addrange(int num, ...)
    {
        va_list arguments;

        va_start(arguments, num);

        for (int i = 0; i < num; i++)
        {
            std::lock_guard<std::recursive_mutex> locker(_lock);
            add(va_arg(arguments, T));
        }

        va_end(arguments); 
    }

    void dump()
    {
        std::lock_guard<std::recursive_mutex> locker(_lock);
        for(auto e : _elements)
            std::cout << e << std::endl;
    }
};

Notice that attempting to call try_lock_for() or try_lock_until() on a unique_lock that wraps a non-timed mutex results in a compiling error.

The constructors of these wrapper guards have overloads that take an argument indicating the locking strategy. The available strategies are:

  • defer_lock of type defer_lock_t: do not acquire ownership of the mutex
  • try_to_lock of type try_to_lock_t: try to acquire ownership of the mutex without blocking
  • adopt_lock of type adopt_lock_t: assume the calling thread already has ownership of the mutex

These strategies are declared like this:

struct defer_lock_t { };
struct try_to_lock_t { };
struct adopt_lock_t { };

constexpr std::defer_lock_t defer_lock = std::defer_lock_t();
constexpr std::try_to_lock_t try_to_lock = std::try_to_lock_t();
constexpr std::adopt_lock_t adopt_lock = std::adopt_lock_t();

Apart from these wrappers for mutexes, the standard also provides a couple of methods for locking one or more mutexes.

  • lock: locks the mutexes using a deadlock avoiding algorithm (by using calls to lock(), try_lock() and unlock()).
  • try_lock: tries to call the mutexes by calling try_lock() in the order of which mutexes were specified.

Here is an example of a deadlock case: we have a container of elements and we have a function exchange() that swaps one element from a container into the other container. To be thread-safe, this function synchronizes the access to the two containers, by acquiring a mutex associated with each container.

template <typename T>
class container 
{
public:
    std::mutex _lock;
    std::set<T> _elements;

    void add(T element) 
    {
        _elements.insert(element);
    }

    void remove(T element) 
    {
        _elements.erase(element);
    }
};

void exchange(container<int>& cont1, container<int>& cont2, int value)
{
    cont1._lock.lock();
    std::this_thread::sleep_for(std::chrono::seconds(1)); // <-- simulates the deadlock
    cont2._lock.lock();    

    cont1.remove(value);
    cont2.add(value);

    cont1._lock.unlock();
    cont2._lock.unlock();
}

Suppose this function is called from two different threads, from the first, an element is removed from container 1 and added to container 2, and in the second it is removed from container 2 and added to container 1. This can lead to a deadblock (if the thread context switches from one thread to another just after acquiring the first lock).

int main()
{
    srand((unsigned int)time(NULL));

    container<int> cont1; 
    cont1.add(1);
    cont1.add(2);
    cont1.add(3);

    container<int> cont2; 
    cont2.add(4);
    cont2.add(5);
    cont2.add(6);

    std::thread t1(exchange, std::ref(cont1), std::ref(cont2), 3);
    std::thread t2(exchange, std::ref(cont2), std::ref(cont1), 6);

    t1.join();
    t2.join();

    return 0;
}

To fix the problem, you can use std::lock that guaranties the locks are acquired in a deadlock-free way:

void exchange(container<int>& cont1, container<int>& cont2, int value)
{
    std::lock(cont1._lock, cont2._lock); 

    cont1.remove(value);
    cont2.add(value);

    cont1._lock.unlock();
    cont2._lock.unlock();
}

Hopefully this walktrough will help you understand the basics of the synchronization functionality supported in C++11.

Leave a Comment