2
\$\begingroup\$

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:

  1. 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?
  2. Are there alternative patterns that would be better suited?
  3. 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)?
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.