guarded objects - make the relationship between objects and their locking mechanism explicitly expressable and hard to use incorrect
11 November 2024
In multithreaded programming locking mechanisms are used to prevent concurrent access to data. Common practice is to create a locking mechansim, lets say an std::mutex along side the data it is protecting. However, the relationship between the mutex and the data is only implied and expressed in code only by naming variables and/or 'doing it right' in all places. This proposal improves this by providing a way to clearly express the relationship and make it impossible to access the data without locking its associated guarding mechanism.
Initial version
The guarded
type encapsulates the locking mechanism and its data, ensuring that lock is acquired and released properly when accessing the protected data.
By requiring access to the data through the guarded type, you make it harder (or impossible) to accidentally access the data without properly locking it first.
By combining the the data and its locking mechanism in one type they have the same lifetime and the API for interacting with the protected data becomes clearer. Users don’t have to worry about manually locking and unlocking. Instead, the locking and unlocking can be handled internally within the type. This simplifies the API and reduces the cognitive load.
The only wat to access data is to call one of the locking functions, these functions either return an object (a unique_ptr-like object) that owns the lock, or allows you to pass in an operation that is exectured while holding the lock. It can be used in a familiar way, just like you would use a smart pointer, or when passing in the operation, not dealing with the lock directly at all.
The locking mechanism is based on RAII (Resource Acquisition Is Initialization), the type handles acquiring and releasing the lock in a scoped manner. This makes sure the lock is always released no matter how you leave the scope, returning of by an exception for example.
The guarded type eliminates the need to embed thread-safety directly into the design of classes. Such implementations are pessimizing single-threaded use and have locking overhead on every API call. An example of a 'synchronized' queue:
#include <queue>
#include <mutex>
#include <cs_plain_guarded.h>
template <typename T>
class SynchronizedQueue
{
public:
bool Empty() const
{
std::unique_lock<std::mutex> lock(m_mtx);
return m_q.empty();
}
void Push(T t)
{
std::unique_lock<std::mutex> lock(m_mtx);
m_q.push(std::move(t));
}
T Pop()
{
std::unique_lock<std::mutex> lock(m_mtx);
T t(m_q.front());
m_q.pop();
return t;
}
mutable std::mutex m_mtx;
// here there is the implied agreement, that 'm_mtx' should be locked
// before accessing m_q, however nothing enforces this.
std::queue<T> m_q;
};
Notice that the code above is fragile in the sense that every public method must acquire the lock and forgetting to do so is only checkable by reviewing.
Alternative with a guarded lock from the cs_libguarded
library:
template <typename T>
using GuardedQueue = libguarded::plain_guarded<std::queue<T>>;
bool example()
{
GuardedQueue<int> guarded_queue;
// it is not possible to access the std::queue directly, without calling lock()
return guarded_queue.lock()->empty();
}
void example2()
{
GuardedQueue<int> guarded_queue;
// it is not possible to access the std::queue directly, without calling lock()
auto locked_q = guarded_queue.lock();
// execute code with the lock held.
if (locked_q->empty())
{
// do things
}
// lock leaves scope.
}
For demonstration purposes, here is a naive implementation of a 'guarding' class that allows you to pass in a function:
// Naive example how an operation could be passed in, to perform a set of operations while holding the lock
#include <mutex>
#include <string>
#include <iostream>
template<typename T>
class naive_guarded {
private:
T data;
mutable std::mutex mtx;
public:
template<typename Func>
auto with_lock(Func&& func) {
std::lock_guard<std::mutex> lock(mtx);
return func(data); // Pass the data to the provided function.
}
};
int main() {
naive_guarded<std::string> naive_guarded_string;
// Modify the string safely.
naive_guarded_string.with_lock([](std::string& str) { // locking overhead
str = "Hello, World!";
});
// Read the string safely.
naive_guarded_string.with_lock([](const std::string& str) { // locking overhead
std::cout << str << '\n';
});
return 0;
}
The example above is a demostration only
When locking is tightly coupled with the data it protects, it becomes easier to reason about the behavior of the code. A guarded
The goal of this proposal is to provide a type:
1) better readability and maintainability: by explicitly expressing the relationship between data and its guarding mechanism 2) better thread safely / hard to use incorrectly: by making it impossible to access the data without locking its guarding mechanism 3) make unlocking automatic and exception safe: by returning a RAII object that has ownership of the locked state
An example of what the result could look like:
#include <string>
#include <iostream>
#include "https://raw.githubusercontent.com/copperspice/cs_libguarded/master/src/cs_plain_guarded.h"
int main() {
libguarded::plain_guarded<std::string> guarded_string;
auto accessor = guarded_string.lock(); // as long as 'accessor' remains in scope, the mechanism remains locked.
// Modify the string safely.
*accessor = "Hello, World!";
// Read the string safely.
std::cout << *accessor << '\n';
return 0;
} // accessor leaves scope, automatically unlocking
Demonstration of clear separation of the class implementation and the sychronisation of the class.
#include <string>
#include <vector>
#include <iostream>
#include "https://raw.githubusercontent.com/copperspice/cs_libguarded/master/src/cs_plain_guarded.h"
struct person
{
std::string name;
};
using contacts_t = std::vector<person>;
void print(const contacts_t& contacts)
{
for (const auto& person: contacts)
{
std::cout << person.name << '\n';
}
}
int main()
{
libguarded::plain_guarded<contacts_t> synchronized_contacts; // specifiy only synchronized access to contacts_t is possible
print(*synchronized_contacts.lock()); // use of the lock
return 0;
}
Discuss customizable [thread.req.lockable] interface with Cpp17BasicLockable requirements
std::guared<T>;
std::guared<T, L>; // The type of second argument satisfies the [thread.req.lockable] interface
This section is for naming, conventions and pinning down details to make it suitable for the standard.
As a suggestion: std::guarded<T>
would express the intent.