Mutexes & Locks

What is a Mutex?

A mutex is a mutually-exclusive-object. It is used to synchronize access to shared memory resources across multiple threads. C++ mutex type is called std::mutex from the <mutex> header. Threads can own a std::mutex by locking it. Other threads will block when they try to lock a std::mutex owned by another thread. std::mutex also implement a try-lock that returns a Boolean indicating the result off the lock attempt. A thread cannot own a std::mutex before it tries to lock it. Mutexes are generally implemented as a OS primitive. Because std::mutex (and C++ other mutex types) use locking and unlocking methods to control access, these types are not considered to be RAII types. Instead there are locking types that will lock a mutex on construction and unlock it on destruction (more below).

#include <chrono>
#include <iostream>
#include <map>
#include <mutex>
#include <sstream>
#include <thread>
#include <vector>

using namespace std::literals;

auto mx  = std::mutex{};
auto map = std::map<int, long long>{};

auto job = [](auto job_id)
{ 
    std::this_thread::sleep_for(150ms);
    auto ss = std::stringstream{};
    ss << std::this_thread::get_id();
    auto thread_id = std::stoll(ss.str());

    while (!mx.try_lock())
        std::this_thread::sleep_for(150ms);

    map.insert({ job_id, thread_id });
    mx.unlock();
    std::this_thread::sleep_for(150ms);
};

auto main() -> int
{    
    auto thr_count { std::thread::hardware_concurrency() };
    auto pool = std::vector<std::thread>(thr_count);

    /// Queue jobs
    for (auto i { 0u }; i < thr_count; ++i)
        pool.emplace_back(job, i);

    std::this_thread::sleep_for(200ms);

    /// Join all job threads
    for (auto& th : pool)
        if (th.joinable())
            th.join();

    std::cout << "{ ";
    for (auto i { map.size() }; auto& [k, v] : map)
        std::cout << k << ": " << v << (i-- ? ", " : "");
    std::cout << " }" << std::endl;

    return 0;
}
bpt build -t build.yaml -o build

# ...

./build/mutex
{ 0: 140667719128640, 1: 140667710735936, 2: 140667702343232, 3: 140667693950528, 4: 140667685557824, 5: 140667677165120, 6: 140667668772416, 7: 140667660379712, 8: 140667651987008, 9: 140667643594304, 10: 140667635201600, 11: 140667626808896, 12: 140667618416192, 13: 140667610023488, 14: 140667601630784, 15: 140667593238080,  }

Example

std::mutex

Other Mutex Types

  • std::timed_mutex - Mutex that offers timeout based locking methods. Locking will be attempted for a certain duration.

  • std::recursive_mutex - Mutex that can be repeatedly locked by the same thread multiple times. Must be unlocked the same number of times to become fully unlocked.

  • std::recursive_timed_mutex - Recursive mutex with timeout locking.

  • std::shared_mutex - A mutex that offers to levels of access, shared or exclusive. Shared locking allows for multiple threads to share a mutex and read the shared memory resources while exclusive only allows one thread to access the shared resources with write privileges. If one thread has a shared lock an a mutex other threads can only gain a shared lock on it as well prohibiting the ability to gain exclusive access from another thread until all threads have unlocked the shared lock. Similarly, a thread with an exclusive lock on a thread disallows other threads from gaining any lock on the mutex until it has been unlocked.

  • std::shared_timed_mutex - Same as a std::shared_mutex but offers timeout based exclusive and shared locking.

  • std::timed_mutex

  • std::recursive_mutex

  • std::recursive_timed_mutex

  • std::shared_mutex

  • std::shared_timed_mutex

What is a lock?

A lock is another kind of synchronization primitive. Locks can be used to wrap other synchronization primitives like mutexes and bind the locking and unlocking if the mutex to the lifetime of the lock using RAII or can themselves be synchronization primitives that must be acquires and released. Most locks in C++ perform the former which allow for mutex locking to be scoped ensuring proper releasing of resources even if exceptions are thrown. Locks however, can also be used erroneously creating deadlocks for which two threads rely on the releasing of each others locks in order to release their respective locks. They also have a little more overhead as you have to create and destroy locks. Locks will often be created in an unnamed scope to ensure that it only lives as long as it needs.

Semaphores

The most simple type of lock is a semaphore. Semaphores allow multiple threads to access the same resource. The number of accessors is dictated by a count which decrements when the semaphore is acquires and blocks for any acquisitions for which the count is zero. C++ semaphore type which supports arbitrary size counts is called std::counting_semaphore. There is also a specialisation for which only a single accessor is allowed, called std::binary_semaphore. Both of these live in the semaphore header.

#include <chrono>
#include <iostream>
#include <semaphore>
#include <thread>

using namespace std::literals;

auto toMain     = std::binary_semaphore{ 0 };
auto fromMain   = std::binary_semaphore{ 0 };

auto work = []()
{ 
    fromMain.acquire();

    std::cout << "[thread]: Got signal" << std::endl;
    std::this_thread::sleep_for(3s);
    std::cout << "[thread]: Sent signal" << std::endl;

    toMain.release();
};

auto main() -> int
{    
    auto th = std::thread{ work };

    std::cout << "[Main]: Sent signal" << std::endl;
    fromMain.release();
    toMain.acquire();
    std::cout << "[Main]: Got signal" << std::endl;

    th.join();

    return 0;
}
$ bpt build -t build.yaml -o build

# ...

$ ./build/semaphores 
[Main]: Sent signal
[thread]: Got signal
[thread]: Sent signal
[Main]: Got signal

Example

std::counting_semaphore & std::binary_semaphore

Lock Types

  • std::lock_guard - The most basic kind of mutex locking wrapper. It binds the locking lifetime of a mutex to the lifetime of the lock. It takes a template type parameter of the mutex type and a mutex as a constructor argument. It can also adopt the ownership of a mutex by passing a second constructor argument std::adopt_lock which does not lock the mutex but ensuring the calling thread will unlock it. std::lock_guard is non-copyable.
  • std::scoped_lock - A lock for acquiring ownership of zero or more mutexes for the duration of a scope block. When constructed and given ownership of multiple mutexes, the locking and unlocking of mutexes uses a deadlock avoidance algorithm.
  • std::unique_lock - Used to acquire an exclusive lock on a mutex with deferred, time-constrained, recursive and transfer semantics for locking. It is non-copyable but is moveable.
  • std::shared_lock - Used to gain shared access to a mutex with similar semantics to std::unique_lock. Used for locking a std::shared_mutex in a shared ownership model.
#include <chrono>
#include <iostream>
#include <map>
#include <mutex>
#include <sstream>
#include <thread>
#include <vector>

using namespace std::literals;

auto mx  = std::mutex{};
auto map = std::map<int, long long>{};

auto job = [](auto job_id)
{ 
    std::this_thread::sleep_for(150ms);
    auto ss = std::stringstream{};
    ss << std::this_thread::get_id();
    auto thread_id = std::stoll(ss.str());

    /// Acquire a lock on mx that lasts for this scope
    {
        auto lk = std::lock_guard{ mx };
        map.insert({ job_id, thread_id });
    }
    
    std::this_thread::sleep_for(150ms);
};

auto main() -> int
{    
    auto thr_count { std::thread::hardware_concurrency() };
    auto pool = std::vector<std::thread>(thr_count);

    /// Queue jobs
    for (auto i { 0u }; i < thr_count; ++i)
        pool.emplace_back(job, i);

    std::this_thread::sleep_for(200ms);

    /// Join all job threads
    for (auto& th : pool)
        if (th.joinable())
            th.join();

    std::cout << "{ ";
    for (auto i { map.size() }; auto& [k, v] : map)
        std::cout << k << ": " << v << (i-- ? ", " : "");
    std::cout << " }" << std::endl;

    return 0;
}
$ bpt build -t build.yaml -o build

# ...

$ ./build/locks
{ 0: 139998766057024, 1: 139998757664320, 2: 139998749271616, 3: 139998740878912, 4: 139998732486208, 5: 139998724093504, 6: 139998715700800, 7: 139998707308096, 8: 139998698915392, 9: 139998690522688, 10: 139998682129984, 11: 139998673737280, 12: 139998665344576, 13: 139998656951872, 14: 139998648559168, 15: 139998640166464,  }

Example

Latches

A std::latch is count-down synchronization primitive with the count is initialized on construction. Threads can wait at a std::latch until the count reaches zero. Once this happens, all the threads waiting on the latch are released. std::latch cannot increment or reset its counter after construction making it a single use barrier. std::latch is non-copyable and lives in the <latch> header.

#include <chrono>
#include <iostream>
#include <latch>
#include <syncstream>
#include <thread>
#include <vector>

using namespace std::literals;

auto thr_count  = std::thread::hardware_concurrency();
auto done       = std::latch{ thr_count };
auto cleanup    = std::latch{ 1 };

auto job = [](auto job_id)
{ 
    std::this_thread::sleep_for(2s);
    std::osyncstream(std::cout) << "Job " << job_id << " done.\n";
    done.count_down();
    cleanup.wait();
    std::osyncstream(std::cout) << "Job " << job_id << " cleaned up.\n";
};

auto main() -> int
{    
    auto pool = std::vector<std::thread>(thr_count);

    std::cout << "Starting jobs...\n";
    for (auto i { 0u }; i < thr_count; ++i)
        pool.emplace_back(job, i);

    done.wait();
    std::cout << "All jobs done.\n";
    std::this_thread::sleep_for(200ms);
    std::cout << "\nStarting cleanup...\n";
    cleanup.count_down();
    std::this_thread::sleep_for(200ms);

    for (auto& th : pool)
        if (th.joinable())
            th.join();
    std::cout << "All jobs cleaned up.\n";

    return 0;
}
$ bpt build -t build.yaml -o build

# ...

$ ./build/latch
Starting jobs...
Job 1 done.
Job 0 done.
Job 3 done.
Job 5 done.
Job 4 done.
Job 7 done.
Job 6 done.
Job 8 done.
Job 2 done.
Job 10 done.
Job 9 done.
Job 13 done.
Job 12 done.
Job 11 done.
Job 14 done.
Job 15 done.
All jobs done.

Starting cleanup...
Job 4 cleaned up.
Job 6 cleaned up.
Job 7 cleaned up.
Job 12 cleaned up.
Job 13 cleaned up.
Job 0 cleaned up.
Job 14 cleaned up.
Job 10 cleaned up.
Job 11 cleaned up.
Job 2 cleaned up.
Job 15 cleaned up.
Job 5 cleaned up.
Job 3 cleaned up.
Job 9 cleaned up.
Job 8 cleaned up.
Job 1 cleaned up.
All jobs cleaned up.

Example

std::latch

Barriers

std::barrier is a more general version of std::latch. The lifetime of a std::barrier consists of one or more phases. The first is a synchronization phase for which threads will block where once the counter has reach zero for the std::barrier the threads will unblock. Right before unblocking, a completion function will run which is optionally supplied at the std::barrier construction. After this the std::barrier will reset its counter and can be reused. The overall count can be reduced on arrival by a thread. std::barrier is non-copyable and lives in the <barrier> header.

#include <chrono>
#include <iostream>
#include <barrier>
#include <syncstream>
#include <string>
#include <thread>
#include <vector>

using namespace std::literals;

auto thr_count  = std::thread::hardware_concurrency();

auto on_completion = []() noexcept
{ 
    static auto message = "All jobs done.\nWorkers are at lunch before cleaning up...\n"s;
    std::osyncstream(std::cout) << message;
    std::this_thread::sleep_for(3s);
    message = "All cleaned up.\n"s;
};

auto barrier = std::barrier{ thr_count, on_completion };

auto job = [](auto job_id)
{ 
    std::this_thread::sleep_for(2s);
    std::osyncstream(std::cout) << "Job " << job_id << " done.\n";
    barrier.arrive_and_wait();
    std::osyncstream(std::cout) << "Job " << job_id << " cleaned up.\n";
    barrier.arrive_and_wait();
};

auto main() -> int
{    
    auto pool = std::vector<std::thread>(thr_count);

    std::cout << "Starting jobs...\n";
    for (auto i { 0u }; i < thr_count; ++i)
        pool.emplace_back(job, i);

    std::this_thread::sleep_for(200ms);

    for (auto& th : pool)
        if (th.joinable())
            th.join();
    
    return 0;
}
$ bpt build -t build.yaml -o build

# ...

$ ./build/barrier
Starting jobs...
Job 2 done.
Job 1 done.
Job 3 done.
Job 5 done.
Job 0 done.
Job 4 done.
Job 8 done.
Job 7 done.
Job 6 done.
Job 12 done.
Job 11 done.
Job 10 done.
Job 9 done.
Job 15 done.
Job 13 done.
Job 14 done.
All jobs done.
Workers are at lunch before cleaning up...
Job 2 cleaned up.
Job 8 cleaned up.
Job 1 cleaned up.
Job 0 cleaned up.
Job 6 cleaned up.
Job 3 cleaned up.
Job 14 cleaned up.
Job 4 cleaned up.
Job 11 cleaned up.
Job 15 cleaned up.
Job 12 cleaned up.
Job 9 cleaned up.
Job 13 cleaned up.
Job 7 cleaned up.
Job 5 cleaned up.
Job 10 cleaned up.
All cleaned up.

Example

std::barrier