How to write typed wrapper for a thread pool?

174 views Asked by At

I have a simple thread pool. It takes tasks and distributes them among threads using round-robin. The task looks like this

using TaskFn = void (*)(void*);

struct Task {
    TaskFn fn;
    void* args;
};

Just two pointers: to a function that takes void* and to the argument itself. The thread pool calls Task::fn and passes Task::args. Everything works well.

But I wanted to write a typed wrapper for this whole thing. So I could write like this:

Task some_task = MakeTask([](int a, int b){
        // Task body
}, 123, 456);

I don’t need the closures to work. I wrote code that does not compile:

template <typename Function, typename ... Args>
void DoCall(Function f, std::tuple<Args...>* args) {
    auto callable = [args, f](){
        std::apply(f, *args);
    };
    callable();
}

template <typename Function, typename ... Args>
Task MakeTask(Function f, Args ... args) {
    Task task;
             
    std::tuple<Args...>* args_on_heap = new std::tuple<Args...>(args...);
    task.args = (void*) args_on_heap;

    TaskFn fn = [](void* ptr){
        // The problem here is that I can’t pass `f` here without creating a closure.
        // But if I create a closure, the signature will be different. 
        // In theory, everything that is needed is known at the compilation stage. 
        // But how to curb the compiler?
        DoCall<Function, Args...>(f, (std::tuple<Args...>*) ptr);
    };

    task.fn = fn;
    return task;
    
    // P.S I know that args_on_heap leaks.
}

So, questions:

  1. Is it possible to implement what I have in mind?
  2. If yes, how to do it? In what direction should I dig? What features of the language (which I probably don’t know about yet) will help me implement what I have in mind.
  3. If I can’t implement this, then what are the alternatives?

Thank you in advance :)

3

There are 3 answers

0
Davis Herring On BEST ANSWER

As written, you’re accepting even callable objects with state, so the language will force you to account for that state. You could copy it into a control block along with your arguments, but if support for state isn’t the point you can require the function to be a template argument:

template <auto &F, typename ... Args>
Task MakeTask(Args ... args) {
    using T = std::tuple<Args...>;
    return {[](void* ptr){
        std::unique_ptr<T> p (static_cast<T*>(ptr));
        DoCall<F, Args...>(f, p.get());
    }, new T(args...)};
}

Note that passing a lambda directly as a template argument requires C++20. You can work around that with a constexpr variable:

constexpr auto *f = [](…) {…};
MakeTask<*f>(…);
0
Ahmed AEK On

any callable or lambda can be stored inside a std::function the concept of storing any type into one type is called Type Erasure, and to keep your arguments from leaking you should be using std::shared_ptr.

#include <functional>
#include <memory>

struct Task {
    std::function<void(void*)> fn;
    std::shared_ptr<void> args; // 100% legal C++
};

template <typename Function, typename ... Args>
Task MakeTask(Function f, Args ... args) {
    Task task;
             
    task.args = std::make_shared<std::tuple<Args...>>(std::move(args)...); // 100% legal C++

    task.fn = [f](void* ptr){
        std::apply(f, *reinterpret_cast<std::tuple<Args...>*>(ptr));
    };
    
    return task;
}

#include <iostream>
void foo() { std::cout << "called\n";}
int main()
{
    Task a = MakeTask(foo);
    a.fn(a.args.get());
    return 0;
}

C++23 introduces std::move_only_function which you should be using to be able to move your args into the lambda instead of using a void*, so you can accept move-only arguments.

the above example uses std::shared_ptr instead of just capturing args by value [f, args...](){f(args...);} because now copying Task only increments an atomic instead of copying args into a newly allocated space on the heap.

0
VadimP22 On

Thanks for your answers! I found a solution that suits me. Since C++20, lambdas without closures are default-costructible, so I wrote code like this:

// Without allocations
template<typename Function, typename Arg> Task MakeTask(Function function, Arg* arg) {
    Task task;

    task.args = (void*) arg;
    task.fn = [](void* ptr){
        Function f;
        f((Arg*) ptr);
    };

    return task;
}

// With allocation
template<typename ... Args> Task MakeTask(auto function, Args ... args) {
    Task task;

    auto args_on_heap = (void*) new std::tuple<Args...>(args...);
    task.args = args_on_heap;
    
    task.fn = [](void* ptr){
        decltype(function) f;
        auto largs = (std::tuple<Args...>*) ptr;
        std::apply(f, *largs);
        delete largs;
    };

    return task;
}