5
\$\begingroup\$

The following — I claim — is a implementation of when_any from the C++ Concurrency TS, except without all the baggage around when_any_result. (This makes when_any and when_all have exactly the same function signature, which is elegant IMHO.)

My futures are just like std::future, except that they support one additional member function:

    auto then(F f) -> future<decltype(f(std::move(*this)))>;

(My futures also support next(F), recover(F), and fallback_to(V) as specified in N3865 "More improvements to std::future<T>", and also bool ready() const, but those aren't used in this code.)

The Concurrency TS's when_any philosophically-incorrectly returns a ready future when called with zero arguments. My version doesn't treat that case specially, and so the natural behavior falls out: the internal promise is destroyed before any 1 of the 0 provided futures has become ready, and so when_any(/*zero args*/) returns a ready future whose get() will throw broken_promise.

I'm a little concerned by the apparently necessary use of lock-free programming. Am I missing an "easier" (higher-level) way to do it?

#include "scratch/bits/concurrency/future-and-promise.h"
#include "scratch/bits/smart-ptrs/shared-ptr.h"
#include "scratch/bits/tuple/tuple.h"
#include "scratch/bits/type-traits/decay.h"

#include <atomic>
#include <utility>

namespace scratch::detail {

template<class... Futures>
struct when_any_shared_state {
    promise<tuple<Futures...>> m_promise;
    tuple<Futures...> m_tuple;
    std::atomic<bool> m_done;
    std::atomic<bool> m_count_to_two;

    when_any_shared_state(promise<tuple<Futures...>> p) :
        m_promise(std::move(p)), m_done(false), m_count_to_two(false) {}
};

} // namespace scratch::detail

namespace scratch {

template<class... Futures>
auto when_any(Futures... futures) -> future<tuple<Futures...>>
{
    using shared_state = detail::when_any_shared_state<Futures...>;
    using R = tuple<Futures...>;
    promise<R> p;
    future<R> result = p.get_future();

    auto sptr = make_shared<shared_state>(std::move(p));
    auto satisfy_combined_promise =
        [sptr](auto f) {
            if (sptr->m_done.exchange(true) == false) {
                if (sptr->m_count_to_two.exchange(true)) {
                    sptr->m_promise.set_value(std::move(sptr->m_tuple));
                }
            }
            return f.get();
        };
    sptr->m_tuple = tuple<Futures...>(futures.then(satisfy_combined_promise)...);
    if (sptr->m_count_to_two.exchange(true)) {
        sptr->m_promise.set_value(std::move(sptr->m_tuple));
    }
    return result;
}

} // namespace scratch

(GitHub link)


I've linked this post from over here. I wrote some external documentation there as well: :)

You attach a continuation to each incoming future (using then). This continuation holds a shared_ptr to a shared state. The shared state holds a count-to-one (m_done) and a count-to-two (m_count_to_two). Each continuation that executes will increment the count-to-one; if it's the winner, it will also increment the count-to-two. The main thread will also increment the count-to-two after it finishes setting up all this stuff. As soon as the count-to-two has reached 2 (indicating that the main thread finished setting up and at least one continuation has executed), we'll call set_value on the promise corresponding to when_any's return future. Ta-da!


To explicitly answer Harald's question in the comments

what is the purpose of the double check using m_done and m_count_to_two, what necessitates that ? Isn't checking on m_done enough?

  • We need at least m_done, "intuitively".
  • But if all we had was m_done, we wouldn't be able to bump it in the main thread.
  • So when the first continuation finished and bumped m_done, it would have no way of knowing whether the main thread had finished writing to sptr->m_tuple.
  • If the continuation tried to move-out-of sptr->m_tuple while the main thread was still writing into it, we'd have a race condition.
  • So we need another counter to indicate that (exactly) one of the continuation threads is ready and that the main thread is ready.

...Now it does occur to me that we could use a mutex lock instead, like this, and then we could get rid of the count-to-two and make m_done non-atomic. But I currently think I prefer the lockfree version as easier to reason about.(!)

\$\endgroup\$
4
  • \$\begingroup\$ Curious i can't find the reference that documents how the part tuple<Futures...>(futures.then(satisfy_combined_promise)...) works, you're creating a tuple with the template types from the function call, but then calling a function on each of the parameters (at least that's my interpretation). I didn't know this works, the parameter pack documentation on cppreference doesn't seem to cover this case. I am assuming the results of the function call get expanded into a comma separated list ? \$\endgroup\$ Commented Sep 20, 2017 at 22:53
  • \$\begingroup\$ @HaraldScheirich: You're correct! (I think.) It's plain vanilla pack-expansion, but with all the parens it may look worse than it is. Basically if xs is a pack, then f(g(xs)...) expands into f(g(x1), g(x2), g(x3)). In this case I've got f(xs.g(h)...) expanding into f(x1.g(h), x2.g(h), x3.g(h)). Does that help and/or jibe with your existing understanding? :) \$\endgroup\$ Commented Sep 21, 2017 at 2:13
  • \$\begingroup\$ yes that was what I would have expected for that syntax. I'm still trying to figure out the specs on then and when_any. Looking at your code, if you don't mind what is the purpose of the double check using m_done and m_count_to_two, what necessitates that ? Isn't checking on m_done` enough ? btw thanks for posting this, it's giving me a chance to check this part of c++ out a little bit more. \$\endgroup\$ Commented Sep 21, 2017 at 13:46
  • \$\begingroup\$ @HaraldScheirich: Thanks! :) I've just updated this post with an explanation (quoted) of my logic. \$\endgroup\$ Commented Sep 21, 2017 at 18:00

2 Answers 2

1
\$\begingroup\$

After Harald's questions, it occurred to me that I could have done it with a plain old mutex instead of all the lock-free stuff. But if I do it this way, then it starts to look like "blocking". I think it's fine — this use of a mutex isn't any more harmful than the use of a mutex in the innards of promise/future in the first place — but in a strange way I'm finding it harder to reason about this new version than about the version in the question.

#include "scratch/bits/concurrency/future-and-promise.h"
#include "scratch/bits/concurrency/lock-guard.h"
#include "scratch/bits/concurrency/mutex.h"
#include "scratch/bits/concurrency/unique-lock.h"
#include "scratch/bits/smart-ptrs/shared-ptr.h"
#include "scratch/bits/tuple/tuple.h"
#include "scratch/bits/type-traits/decay.h"

#include <utility>

namespace scratch::detail {

template<class... Futures>
struct when_any_shared_state {
    promise<tuple<Futures...>> m_promise;
    tuple<Futures...> m_tuple;
    mutex m_mtx;  // protects m_done and m_tuple
    bool m_done;

    when_any_shared_state(promise<tuple<Futures...>> p) :
        m_promise(std::move(p)), m_done(false) {}
};

} // namespace scratch::detail

namespace scratch {

template<class... Futures>
auto when_any(Futures... futures) -> future<tuple<Futures...>>
{
    using shared_state = detail::when_any_shared_state<Futures...>;
    using R = tuple<Futures...>;
    promise<R> p;
    future<R> result = p.get_future();

    auto sptr = make_shared<shared_state>(std::move(p));
    auto satisfy_combined_promise =
        [sptr](auto f) {
            unique_lock lk(sptr->m_mtx);
            if (!sptr->m_done) {
                sptr->m_done = true;
                lk.unlock();
                sptr->m_promise.set_value(std::move(sptr->m_tuple));
            }
            return f.get();
        };
    {
        lock_guard lk(sptr->m_mtx);
        sptr->m_tuple = tuple<Futures...>(futures.then(satisfy_combined_promise)...);
    }
    return result;
}

} // namespace scratch
\$\endgroup\$
2
  • \$\begingroup\$ I have a rather strange question, but are you by some chance Arthur O'Dwyer? I've seen almost all of his talks, and they were awesome! \$\endgroup\$ Commented Dec 9, 2017 at 10:23
  • \$\begingroup\$ @Incomputable: Indeed I am. Thanks for the compliment! :) \$\endgroup\$ Commented Dec 10, 2017 at 5:43
1
\$\begingroup\$

It took me a while to figure this one out but thanks again for posting it was a good way to read up on these new concepts. I find the locking version easier to understand, as it expresses the key concept of having to protect the construction of the m_tuple much better. That was the part that I was struggling with on the atomic counter version. In that case a continuation function might have triggered while the constructor or the assignment operator are still running in the main thread.

I am torn about your change to the return value type, yes I agree that this makes the signatures the same but it now seems to require an iteration over the returned tuple to find the future that is ready. I can see where the standards committee is coming from.

I wonder if using the unique lock and unlocking early is worth the extra effort that might be in Instatiating a unique lock vs a lock guard.

As both versions seem to work the faster one should probably win ;)

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.