LouisCharlesC / safe

Header only read/write wrapper for mutexes and locks.
MIT License
147 stars 11 forks source link
guard lock lock-guard multi-threading mutexes raii thread-safety

Every variable protected by a mutex should be wrapped with safe.

build

Contents

safe is a header-only library that makes code with mutexes safer and easier to understand.
This readme will walk you through the important features of the library using several code examples. Read on, and enjoy safe mutexes!

Here is why you want to use safe:

Mutex code without safe

std::string foo; // do I need to lock a mutex to safely access this variable ?
std::string bar;
std::string baz; // what about this one ?
std::mutex fooMutex; // don't forget to change the name of this variable if foo's name changes!
std::mutex barMutex;

{
    std::lock_guard<std::mutex> lock(fooMutex); // is this the right mutex for what I am about to do ?
    foo = "Hello, World!"; // I access foo here, but I could very well access bar, yet barMutex is not locked!
}

std::cout << bar << std::endl; // unprotected access, is this intended ?
std::cout << baz << std::endl; // what about this access ?

Mutex code with safe

using SafeString = safe::Safe<std::string>; // type aliases will save you a lot of typing
SafeString safeFoo; // std::string and mutex packaged together!
SafeString safeBar;
std::string baz; // now you can see that this variable has no mutex

{
    safe::WriteAccess<SafeString> foo(safeFoo); // this locks the mutex and gives you access to foo
    *foo = "Hello, World!"; // access the value using pointer dereference: * and ->
}

std::cout << safeBar.unsafe() << std::endl; // unprotected access: clearly expressed!
std::cout << baz << std::endl; // all good (remember, baz has no mutex!)

Motivation

Since C++11, the standard library provides mutexes, like std::mutex, along with tools to facilitate their usage, like std::lock_guard and std::unique_lock. These are sufficient to write safe multithreaded code, but it is all too easy to write code you think is safe but actually is not. Typical mistakes are: locking the wrong mutex and accessing the value object before locking (or after unlocking) the mutex. Other minor mistakes like unnecessary locking or keeping a mutex locked for too long can also be avoided.

safe prevents common mutex usage mistakes by providing tools that complement the C++ standard library. Using safe, you will find it much easier to protect a variable using a mutex, and your code will be easier to understand.

Installation

Method 1: Copy the source files in your project

safe is a header-only library. Using the library can simply mean copy the contents of the include/ folder to some place of your convenience. This is the most straightforward installation method.

Method 2: Via CMake FetchContent (CMake > 3.14)

cmake_minimum_required(VERSION 3.14)
project(my_project)

FetchContent_Declare(
  safe
  GIT_REPOSITORY https://github.com/LouisCharlesC/safe.git
  GIT_TAG        v1.1.0
)
FetchContent_MakeAvailable(safe)

add_executable(my_project my_project.cc)
target_link_library(my_project safe::safe)

NOTE: find_package(safe CONFIG REQUIRED) is not needed with this method.

Method 3: Install locally via CMake

git clone https://github.com/louischarlescaron/safe

cd safe
cmake -B safe-build -DCMAKE_INSTALL_PREFIX="$(pwd)/safe-install"
cmake --build safe-build --config Release --target install

Then you can use find_package in your project:

cmake_minimum_required(VERSION 3.11)
project(my_project)

find_package(safe CONFIG REQUIRED)

add_executable(my_project my_project.cc)
target_link_library(my_project safe::safe)

And build with:

cd my_project
cmake -B build -DCMAKE_PREFIX_PATH="path/to/safe-install"
cmake --build build --config Release

NOTE: CMAKE_PREFIX_PATH is used to tell find_package() where to look for libraries. path/to/safe-install is not a standard path but it's easier to remove when needed.

Method 4: Install system-wide via CMake (not recommended)

git clone https://github.com/LouisCharlesC/safe

cd safe
cmake -B build
sudo cmake --build build --config Release --target install

safe will be installed into your OS's standard intallation path. Be aware that system-wide installation make it hard to deal with multilpe library versions, and can cause collisions if you happen to install another library called safe!

When you build your own project, you won't need to append -DCMAKE_PREFIX_PATH="path/to/safe-install".

Basic usage

The safe library defines the Safe and Access class templates. They are meant to replace the mutexes and locks in your code. safe does not offer much more functionality than mutexes and locks do, they simply make their usage safer.
Here is the simplest way to replace mutexes and locks by Safe objects.

Vocabulary

The Access class template has a template parameter for the lock object:

safe::Safe<int, std::mutex> bothDefault; // mutex and value are default constructed safe::Safe<int, std::mutex&> noDefault(aMutex, 42); // mutex and value are initialized safe::Safe<int, std::mutex&> valueDefault(aMutex); // mutex is initialized, and value is default constructed safe::Safe<int, std::mutex> mutexDefaultTag(safe::default_construct_mutex, 42); // mutex is default constructed, and value is initialized safe::Safe<int, std::mutex> mutexDefaultBraces({}, 42);

#### Flexibly construct the Lock objects
The Access constructors have a variadic parameter pack that is forwarded to the Lock object's constructor. This can be used to pass in standard lock tags such as std::adopt_lock, but also to construct your custom locks that may require additionnal arguments than just the mutex.
```c++
safe::Safe<int> safeValue; // given a Safe object
safeValue.mutex().lock(); // with the mutex already locked...
// Because the mutex is already locked, you need to pass the std::adopt_lock tag to std::lock_guard when you construct your Access object.

// No matter how you get your Access objects, you can pass arguments to the lock's constructor.
safe::WriteAccess<safe::Safe<int>> value(safeValue, std::adopt_lock);
safe::Safe<int>::WriteAccess<> value(safeValue, std::adopt_lock);
auto value = safeValue.writeAccess(std::adopt_lock); // again, only in C++17
auto value = safeValue.writeAccess<std::unique_lock>(std::adopt_lock);

Even more safety!

Choose the access mode that suits each access

You will instatiate one Safe object for every value object you want to protect. But, you will create an Access object every time you want to operate on the value object. For each of these accesses, you can choose whether the access is read-write or read-only.

Force read-only access with shared mutexes and shared_locks

Shared mutexes and shared locks allow multiple reading threads to access the value object simultaneously. Unfortunately, using only mutexes and locks, the read-only restriction is not guaranteed to be applied. That is, it is possible to lock a mutex in shared mode and write to the shared value. With safe, you can enforce read-only access when using shared locking by using ReadAccess objects. See this section for details.

Compatibility

With legacy code

You can use safe with old-style unsafe code that uses the soon-to-be out-of-fashion separate-mutex-and-value-idiom. Imagine you are provided with the typical mutex and int. safe allows you to wrap these variables, without having to modify the existing code. Enjoy the safety and avoid the headaches:

std::mutex lousyMutex;
int unsafeValue;

// Wrap the existing variables
safe::Safe<int&, std::mutex&> safeValue(lousyMutex, unsafeValue);
// do not use lousyMutex and unsafeValue directly from here on!

With code from the future

safe is written in C++11, but it is fully compatible with mutexes and locks from different sources like C++14's std::shared_lock and C++17's std::shared_mutex, thanks to template parameters. Of course, you can also use boost::shared_lock_guard and your own custom mutexes and locks.

With standard uses of mutexes and locks

The mutex is accessible from the Safe object through an accessor functions, and the lock object is a public member of the Access class. Anything you can do with your typical mutexes and locks you can do with safe.

For example, safe can seamlessly be used with std::condition_variable:

std::condition_variable cv;
safe::Safe<int> safeValue;
safe::Safe<int>::WriteAccess<std::unique_lock> value(safeValue);
cv.wait(value.lock, [](){return true;});

Advanced usage

Enforcing read-only access

You can inform the safe library that some locks that you use are read-only (e.g. std::shared_lock, boost::shared_lock_guard). If you do so, trying to instantiate a WriteAccess object with these locks will trigger a compilation error. Use the trait class safe::AccessTraits to customize this behavior.

Here is how the trait works:

As an example, here is how to specialize the trait for std::shared_lock (you will find this exact code snippet in safe/accessmode.h):

template<typename MutexType>
struct safe::AccessTraits<std::shared_lock<MutexType>>
{
    static constexpr bool IsReadOnly = true;
};

Acknowledgment

Thanks to all contributors, issue raisers and stargazers! Most cmake code comes from this repo: https://github.com/bsamseth/cpp-project and Craig Scott's CppCon 2019 talk: Deep CMake for Library Authors. Many thanks to the authors!