Here is what I'm trying to accomplish: Suppose you are trying to control an embedded system with several external hardware resources/components/devices. Naturally, you are concerned with the issue of sharing these resources, locking them. Also, the control flow of the overall program must not be unnecessarily impeded by the waiting for the availability of resources.
So what I came up with was the following train of thoughts... I have roughly the following responsibilities:
- Executing a command sequence (lets call it a task) that requires none, one, or multiple device resources
- Keeping track of all resources/devices of concern
- Distributing resources between different tasks
- providing a subset of the resources upon request
As for the execution of command sequences: They can be plain old callables...
struct FooCmdSequence {
RetType operator() (Device1& dev1, Device2& dev2) {
// ...
}
}
Then, regarding the devices/resources themselves: They should be Lockable, such that a std::lock
may be used on them:
class
LockableDevice {
mutable std::mutex mutex;
std::shared_ptr<void> const device;
public:
template <typename Device>
LockableDevice(std::unique_ptr<Device> device)
: device(std::move(device))
{
}
void
lock() const {
mutex.lock();
}
void
unlock() const {
mutex.unlock();
}
bool
try_lock() const {
return mutex.try_lock();
}
private:
friend class ResourceRegistry;
/// nobody should access the device unprotected except for the ResourceRegistry
template <typename Device>
Device*
access () {
return static_cast<Device*>(device.get());
}
};
Also, we need a place where we can manage the devices and provide safe access to one or several of them together:
class
ResourceRegistry {
std::mutex
mutex;
std::map<std::type_index, std::unique_ptr<LockableDevice>>
deviceMap;
public:
/// @brief returns a pair consisting of a lock-scope object and a tuple containing pointers to the locked resources
template <typename...Ts>
auto
acquire(requires<Ts...>) {
std::lock_guard<std::mutex> selfProtect(mutex);
auto lockScope = lock(*deviceMap.at(std::type_index(typeid(Ts)))...);
return std::make_pair(
std::move(lockScope),
std::make_tuple(
deviceMap.at(std::type_index(typeid(Ts)))->access<Ts>()...
)
);
}
/// @brief Trivial specialization for the empty case where no resources are required
/// The lock-scope is a noop and the tuple of resources is empty.
auto
acquire(requires<>) {
return std::make_pair(std::shared_ptr<void>(),std::tuple<>());
}
/// @brief Hand over ownership for the given device to the registry
template <typename Device>
void
registerDevice(std::unique_ptr<Device> device) {
std::lock_guard<std::mutex> selfProtect(mutex);
auto lockDev = std::make_unique<LockableDevice>(std::move(device));
deviceMap.emplace(std::make_pair(std::type_index(typeid(T)), std::move(lockDev)));
}
};
I will get to the function lock
a little bit later. The requires<Ts...>
that is used as argument to ResourceRegistry::acquire
is just an empty utility typelist. This list will be automatically generated (from the command that is to be executed) as follows:
template <typename...Devices> struct
requires {
static constexpr size_t size = sizeof...(Devices);
};
template <typename T> struct
decay_args_of;
template <typename Ret,typename Class,typename...Ts> struct
decay_args_of<Ret(Class::*)(Ts...)> {
using type = requires<std::decay_t<Ts>...>;
};
template <typename Ret,typename Class,typename...Ts> struct
decay_args_of<Ret(Class::*)(Ts...) const> {
using type = requires<std::decay_t<Ts>...>;
};
template <typename Ret,typename Class,typename...Ts> struct
decay_args_of<Ret(Class::*)(Ts...) const volatile> {
using type = requires<std::decay_t<Ts>...>;
};
template <typename Ret,typename Class,typename...Ts> struct
decay_args_of<Ret(Class::*)(Ts...) &> {
using type = requires<std::decay_t<Ts>...>;
};
template <typename Ret,typename Class,typename...Ts> struct
decay_args_of<Ret(Class::*)(Ts...) const& > {
using type = requires<std::decay_t<Ts>...>;
};
template <typename Ret,typename Class,typename...Ts> struct
decay_args_of<Ret(Class::*)(Ts...) const volatile &> {
using type = requires<std::decay_t<Ts>...>;
};
template <typename Ret,typename Class,typename...Ts> struct
decay_args_of<Ret(Class::*)(Ts...) &&> {
using type = requires<std::decay_t<Ts>...>;
};
template <typename Ret,typename Class,typename...Ts> struct
decay_args_of<Ret(Class::*)(Ts...) const&&> {
using type = requires<std::decay_t<Ts>...>;
};
template <typename Ret,typename Class,typename...Ts>
struct decay_args_of<Ret(Class::*)(Ts...) const volatile &&> {
using type = requires<std::decay_t<Ts>...>;
};
template <typename Cmd> struct
requirements_of;
template <typename Cmd> struct
requirements_of {
using type = typename decay_args_of<decltype(&std::decay_t<Cmd>::operator())>::type;
};
template <typename Cmd> using
requirements_of_t = typename requirements_of<Cmd>::type;
Now to the locking (and in particular the lock()
used above in the ResourceRegistry
): For some reason I don't quite understand, the standard provides a variadic
template <typename Lockable1, typename Lockable2, typename...LockableN>
std::lock(Lockable1&, Lockable2&, LockableN&...);
notably with at least 2 arguments. This makes things unnecessarily complicated when using it in a context where the arguments are passed in from a parameter pack that can have one, or many elements... So here is my wrapper:
template <typename T1, typename T2, typename...Ts>
auto
lock(T1& t1, T2& t2, Ts&... ts) {
std::lock(t1, t2, ts...);
return std::shared_ptr<void>(nullptr,[&](void*){
t1.unlock(), t2.unlock();
int unlockRest[]{(ts.unlock(),1)...};
});
}
template <typename T1>
auto
lock(T1& t1) {
t1.lock();
return std::shared_ptr<void>(nullptr,[&](void*){ t1.unlock(); });
}
Now finally, the core of the whole thing, the ResourceOrchestrator
:
class
ResourceOrchestrator {
std::unique_ptr<ResourceRegistry>
resourceRegistry;
std::shared_ptr<Executor>
executor;
template <typename Cmd, typename...Ts>
static auto
result_type_helper(Cmd&& cmd,requires<Ts...>) -> decltype(cmd(std::declval<std::add_lvalue_reference_t<Ts>>()...));
template <typename Command> struct
result_of {
using
type = decltype(result_type_helper(std::declval<std::decay_t<Command>>(), requirements_of_t<Command>()));
};
template <typename T> using
result_of_t = typename result_of<T>::type;
public:
ResourceOrchestrator(std::unique_ptr<ResourceRegistry> resourceRegistry, std::shared_ptr<Executor> executor)
: resourceRegistry(std::move(resourceRegistry))
, executor(std::move(executor))
{
}
template<
typename Result,
typename Command,
typename Devices,
int ...S
> std::enable_if_t<std::is_same<Result,void>::value, void>
executeWithDevices(
std::promise<Result> promise,
Command&& cmd,
std::shared_ptr<void> lockScope,
Devices&& devs,
std::index_sequence<S...>)
{
auto unboundTask =
[](std::promise<Result>& prom, Command& cmd, type_at<S,Devices>&...devices) {
try {
cmd(*devices...);
prom.set_value();
} catch ( ... ) {
prom.set_exception(std::current_exception());
}
};
/// the callable returned by std::bind is not movable, so we wrap it in a std::shared_ptr in a std::function
/// in order to keep all lockScopes alive for the lifetime of the task.
std::function<void()> boundTask = shared_function(std::bind(unboundTask, std::move(promise), std::forward<Command>(cmd), std::get<S>(devs)...));
executor->execute(boundTask);
}
template<
typename Result,
typename Command,
typename Devices,
size_t ...S
> std::enable_if_t<!std::is_same<Result,void>::value, void>
executeWithDevices(
std::promise<Result> promise,
Command&& cmd,
std::shared_ptr<void> lockScope,
Devices&& devs,
std::index_sequence<S...>)
{
auto unboundTask =
/// accepts args by lvalue reference, because they will be bound into the task alongside this lambda below
[](std::promise<Result>& prom, Command& cmd, type_at<S,Devices>&...devices) {
try {
auto result = cmd(*devices...);
prom.set_value(std::move(result));
} catch ( ... ) {
prom.set_exception(std::current_exception());
}
};
std::function<void()> task = shared_function(std::bind(unboundTask, std::move(promise), std::forward<Command>(cmd), std::get<S>(devs)...));
executor->execute(task);
}
template <
typename Command,
typename Result = result_of_t<Command>,
typename requiredResources = requirements_of_t<Command>
> auto
execute(Command&& cmd) -> std::future<Result> {
auto devices = resourceRegistry->acquire(requiredResources());
std::promise<Result> promise;
auto future = promise.get_future();
executeWithDevices(std::move(promise), std::forward<Command>(cmd), std::move(devices.first), std::move(devices.second), std::make_index_sequence<requiredResources::size>{});
return future;
}
};
/// This came in handy above, which I found somwhere on stackoverflow:
template<class F>
auto shared_function( F&& f ) {
auto pf = std::make_shared<std::decay_t<F>>(std::forward<F>(f));
return [pf](auto&&... args){
return (*pf)(decltype(args)(args)...);
};
}
Basically, the ResourceOrchestrator
allows a client to request the execution of a resource-dependent functor via execute(Functor())
. Internally, the ResourceOrchestrator
deduces the arguments that the functor expects and requests exclusive access to these resources from the ResourceRegistry
. Once exclusive access is granted, the lockScopes
are packaged along the command into a task
which is forwarded to the executor
for execution.
So all in all, the aim here is to be able to just do the following in the client code. At application startup, we want to wire everything up like so:
auto registry = std::make_unique<ResourceRegistry>();
auto executor = std::make_shared<Executor>();
auto dev1 = std::make_unique<Device1>();
registry->registerDevice(std::move(dev1));
auto dev2 = std::make_unique<Device2>();
registry->registerDevice(std::move(dev2));
// ...
/// hand over ownership of the ResourceRegistry to the ResourceOrchestrator,
/// alongside an executor that determines if tasks get executed in diverse threads, only one thread etc...
auto orchestrator = std::make_shared<ResourceOrchestrator>(std::move(registry), executor);
Then, once the orchestrator is set-up, we can just do the following request:
struct FooCmdSequence {
RetType operator() (Device1& dev1, Device2& dev2) {
// will be called with guaranteed exclusive access to dev1 and dev2
}
}
/// just execute and provide locked resources automatically:
orchestrator->execute(FooCmdSequence());
I would highly appreciate it if someone other than me could have a look at it and address the following questions:
- Is the whole thing conceptually sound? Or are there important concerns that are not addressed here that will pop up in the distribution of resources to tasks?
- Are there alternative patterns that would be better suited?
- Would it make sense to extend the present approach such that the
ResourceOrchestrator
is equipped with different executors allowing it to choose one of them according to some preference associated with the device (e.g. some device-interfaces may only be used from the Main-thread, while others don't care)?