Search code examples
c++multithreadingasynchronousstd-future

Async Task Execution and Future Object Requirement for C++ Void Functions: Understanding the Need for std::future<void> Objects in C++Async


In the first example code, all tasks are launched successfully without any issues. However, in the second example code, only the first task is launched, and the program waits there without executing the remaining lines of code. It seems even if when the the functors of class (A,B,C,D) don't return anything (void), we need to define objects of std::future type and I don't understand why!

// example #1
int main()
{
    A a("A");
    B b("B");
    C c("C");
    D d("D");
    Controller controller("Controller");

    // Resources shared between threads
    SharedResource sharedResource;
    ControllerResource controllerResource;

    std::future<void> taskA = std::async(std::launch::async, a, std::ref(sharedResource));
    std::future<void> taskB = std::async(std::launch::async, b, std::ref(sharedResource));
    std::future<void> taskC = std::async(std::launch::async, c, std::ref(sharedResource));
    std::future<void> taskD = std::async(std::launch::async, d, std::ref(sharedResource));
    std::thread thController(controller, std::ref(controllerResource), std::ref(sharedResource));
    thController.join();
}
// example #2
int main()
{
    A a("A");
    B b("B");
    C c("C");
    D d("D");
    Controller controller("Controller");

    // Resources shared between threads
    SharedResource sharedResource;
    ControllerResource controllerResource;

    std::async(std::launch::async, a, std::ref(sharedResource));
    std::async(std::launch::async, b, std::ref(sharedResource));
    std::async(std::launch::async, c, std::ref(sharedResource));
    std::async(std::launch::async, d, std::ref(sharedResource));

    std::thread thController(controller, std::ref(controllerResource), std::ref(sharedResource));
    thController.join();
}

Solution

  • std::async when passed a std::launch::async returns std::future objects that block on the thread finishing on destruction.

    This is because having loose threads in a C++ program makes avoiding undefined or unspecified behaviour nearly impossible. Threads running after the end of main (or during/after exit or similar) should be avoided at nearly all costs.

    To make std::async usable without spewing undefined behavior, the std::future that it returns behaves in a less than ideal manner. And when you don't store it, it is destroyed on the same line you call std::async.

    This destruction stops the main thread until the worker thread you just created finishes. Which is far less than ideal.

    [[nodiscard]] warnings for sufficiently advanced C++ compilers can help here.

    std::async(std::launch::async, a, std::ref(sharedResource));
    

    this line launches a thread that runs a(sharedResource), but then the std::future std::async returns is cleaned up. That cleanup blocks until the thread finishes. So this is far less async than you intended.

    The intended use of std::async is something like taking a problem, dividing it up into (# CPU) sub problems, making a vector of std::futures of size (# CPU-1) and populating them with calls to std::async each working on a subproblem, then working on the final sub problem in the main thread. Finally, you block on all of the std::futures in the vector, and collect the results.

    When used like this, std::async is a very useful tool.

    If you want more advanced threading usage, you may have to write your own framework. C++ provides threading primitives. Direct use of these primitives without extreme discipline will lead to unmaintainable buggy code in my experience.

    Part of this is because in general correct multithreaded code is Hard, as in not tractable. This is true in almost every language. In C++, they give you access to near bare-metal threading with a very expressive memory model of how inter-thread communication works. This makes it harder to write correct threading code, but only in a linear manner; most non-C++ imperative languages also have insanely broken use of threads and a memory model that basically throws its hands up and says "what the single language implementation does is what it does".

    But that is a rant for a different time.