putil: Add new primitives for async callbacks/futures

These are a lot lighter than AsyncFuture and safer to use in C++ due to the reliance on RAII.  An async method can now take a CompletionToken, which will implicitly accept an arbitrary callable or an AsyncFuture pointer.
This commit is contained in:
rdb 2025-01-24 22:19:42 +01:00
parent 67723ca06a
commit 2ea02ef0c8
11 changed files with 493 additions and 1 deletions

View File

@ -389,6 +389,17 @@ wake_task(AsyncTask *task) {
}
}
/**
* Internal callback called when a CompletionToken created from this future
* completes.
*/
void AsyncFuture::
token_callback(Completable::Data *data, bool success) {
AsyncFuture *future = (AsyncFuture *)data;
future->set_result(EventParameter(success));
unref_delete(future);
}
/**
* @see AsyncFuture::gather
*/

View File

@ -20,6 +20,7 @@
#include "eventParameter.h"
#include "patomic.h"
#include "small_vector.h"
#include "completionToken.h"
class AsyncTaskManager;
class AsyncTask;
@ -58,7 +59,7 @@ class AsyncTask;
*
* @since 1.10.0
*/
class EXPCL_PANDA_EVENT AsyncFuture : public TypedReferenceCount {
class EXPCL_PANDA_EVENT AsyncFuture : public TypedReferenceCount, protected Completable::Data {
PUBLISHED:
INLINE AsyncFuture();
virtual ~AsyncFuture();
@ -109,6 +110,8 @@ public:
private:
void wake_task(AsyncTask *task);
static void token_callback(Completable::Data *, bool success);
protected:
enum FutureState : patomic_unsigned_lock_free::value_type {
// Pending states
@ -136,6 +139,7 @@ protected:
friend class AsyncGatheringFuture;
friend class AsyncTaskChain;
friend class CompletionToken;
friend class PythonTask;
public:
@ -199,6 +203,33 @@ private:
static TypeHandle _type_handle;
};
#ifndef CPPPARSER
// Allow passing a future into a method accepting a CompletionToken.
template<>
INLINE CompletionToken::
CompletionToken(AsyncFuture *future) {
if (future != nullptr) {
future->ref();
_callback._data = future;
if (_callback._data->_function == nullptr) {
_callback._data->_function = &AsyncFuture::token_callback;
}
}
}
template<>
INLINE CompletionToken::
CompletionToken(PT(AsyncFuture) future) {
if (future != nullptr) {
_callback._data = future;
if (_callback._data->_function == nullptr) {
_callback._data->_function = &AsyncFuture::token_callback;
}
future.cheat() = nullptr;
}
}
#endif
#include "asyncFuture.I"
#endif // !ASYNCFUTURE_H

View File

@ -20,6 +20,9 @@ set(P3PUTIL_HEADERS
clockObject.h clockObject.I
collideMask.h
colorSpace.h
completable.I completable.h
completionCounter.I completionCounter.h
completionToken.I completionToken.h
copyOnWriteObject.h copyOnWriteObject.I
copyOnWritePointer.h copyOnWritePointer.I
compareTo.I compareTo.h
@ -86,6 +89,7 @@ set(P3PUTIL_SOURCES
callbackObject.cxx
clockObject.cxx
colorSpace.cxx
completionCounter.cxx
copyOnWriteObject.cxx
copyOnWritePointer.cxx
config_putil.cxx configurable.cxx

View File

@ -0,0 +1,61 @@
/**
* PANDA 3D SOFTWARE
* Copyright (c) Carnegie Mellon University. All rights reserved.
*
* All use of this software is subject to the terms of the revised BSD
* license. You should have received a copy of this license along
* with this source code in a file named "LICENSE."
*
* @file completable.I
* @author rdb
* @date 2025-01-22
*/
#ifndef CPPPARSER
/**
*
*/
template<class Callable>
INLINE Completable::
Completable(Callable callback) :
_data(new LambdaData<Callable>(std::move(callback), [](Data *data, bool do_run) {
LambdaData<Callable> *self = (LambdaData<Callable> *)data;
if (do_run) {
std::move(self->_lambda)();
}
delete self;
})) {
}
#endif
/**
*
*/
INLINE Completable::
Completable(Completable &&from) noexcept :
_data(from._data) {
from._data = nullptr;
}
/**
*
*/
INLINE Completable::
~Completable() {
Data *data = _data;
if (data != nullptr) {
data->_function.load(std::memory_order_relaxed)(data, false);
}
}
/**
*
*/
INLINE void Completable::
operator ()() {
Data *data = _data;
_data = nullptr;
if (data != nullptr) {
data->_function.load(std::memory_order_relaxed)(data, true);
}
}

View File

@ -0,0 +1,79 @@
/**
* PANDA 3D SOFTWARE
* Copyright (c) Carnegie Mellon University. All rights reserved.
*
* All use of this software is subject to the terms of the revised BSD
* license. You should have received a copy of this license along
* with this source code in a file named "LICENSE."
*
* @file completable.h
* @author rdb
* @date 2025-01-22
*/
#ifndef COMPLETABLE_H
#define COMPLETABLE_H
#include "pandabase.h"
#include "patomic.h"
/**
* Stores a type-erased callable that is move-only. May only be called once.
*/
class EXPCL_PANDA_PUTIL Completable {
public:
constexpr Completable() = default;
#ifndef CPPPARSER
template<class Callable>
INLINE Completable(Callable callback);
#endif
INLINE Completable(const Completable &copy) = delete;
INLINE Completable(Completable &&from) noexcept;
INLINE void operator ()();
INLINE ~Completable();
protected:
// There are several design approaches here:
// 1. Optimize for no data block: do not require dynamic allocation of a data
// block in the simple case where the callback data is only the size of a
// single pointer. Store two pointers, one function pointer and a data
// pointer(-sized storage), directly on the class here.
// 2. Optimize for a data block: store the function pointer on the data block,
// always requiring dynamic allocation.
//
// Right now I have opted for 2 because it allows the function pointer to be
// dynamically swapped (used in CompletionCounter), but this decision may
// change in the future.
struct Data;
typedef void CallbackFunction(Data *, bool);
struct Data {
patomic<CallbackFunction *> _function { nullptr };
};
template<typename Lambda>
struct LambdaData : public Data {
// Must unfortunately be defined inline, since this struct is protected.
LambdaData(Lambda lambda, CallbackFunction *function) :
_lambda(std::move(lambda)) {
_function = function;
}
Lambda _lambda;
};
Data *_data = nullptr;
friend class AsyncFuture;
friend class CompletionCounter;
friend class CompletionToken;
};
#include "completable.I"
#endif

View File

@ -0,0 +1,97 @@
/**
* PANDA 3D SOFTWARE
* Copyright (c) Carnegie Mellon University. All rights reserved.
*
* All use of this software is subject to the terms of the revised BSD
* license. You should have received a copy of this license along
* with this source code in a file named "LICENSE."
*
* @file completionCounter.I
* @author rdb
* @date 2025-01-22
*/
/**
*
*/
INLINE CompletionCounter::
~CompletionCounter() {
CounterData *data = _data;
if (data != nullptr) {
// then() is not called; we still need something that destructs the data
// when done.
auto prev_function = data->_function.exchange(&abandon_callback, std::memory_order_relaxed);
if (prev_function == nullptr) {
// Was already done.
delete data;
}
}
}
/**
* Returns a new token. May not be called after then().
*/
INLINE CompletionToken CompletionCounter::
make_token() {
CompletionToken token;
if (_data == nullptr) {
_data = new CounterData;
_data->_function = &initial_callback;
}
auto old_value = _data->_counter.fetch_add(1);
nassertr(old_value >= 0, token);
token._callback._data = _data;
return token;
}
/**
* Runs the given callback immediately upon completion. If the counter is
* already done, runs it immediately. This requires an rvalue because it
* consumes the counter, use std::move() if you don't have an rvalue.
*
* The callback will either be called immediately or directly when the last
* token calls complete(), however, it may also be called if a token is
* destroyed. This may happen at unexpected times, such as when the lambda
* holding the token is destroyed prematurely. In this case, however, the
* passed success argument will always be false.
*/
template<class Callable>
INLINE void CompletionCounter::
then(Callable callable) && {
// Replace the callback pointer with something that calls the given callable
// once the count reaches 0.
CounterData *data = _data;
nassertv(data != nullptr);
_data = nullptr;
if (data->_function.load(std::memory_order_acquire) == nullptr) {
// Already done.
callable((data->_counter.load(std::memory_order_relaxed) & ~0xffff) == 0);
delete data;
return;
}
static_assert(sizeof(Callable) <= sizeof(data->_storage),
"raise storage size in completionCounter.h or reduce lambda captures");
new (data->_storage) Callable(std::move(callable));
Completable::CallbackFunction *new_function =
[] (Completable::Data *data_ptr, bool success) {
CounterData *data = (CounterData *)data_ptr;
auto prev_count = data->_counter.fetch_add((success ? 0 : 0x10000) - 1, std::memory_order_release);
if ((short)(prev_count & 0xffff) > 1) {
return;
}
Callable *callable = (Callable *)data->_storage;
std::move(*callable)(success && (prev_count & ~0xffff) == 0);
callable->~Callable();
delete data;
};
auto prev_function = data->_function.exchange(new_function, std::memory_order_acq_rel);
if (UNLIKELY(prev_function == nullptr)) {
// Last token finished in the meantime.
new_function(data, (data->_counter.load(std::memory_order_relaxed) & ~0xffff) == 0);
}
}

View File

@ -0,0 +1,52 @@
/**
* PANDA 3D SOFTWARE
* Copyright (c) Carnegie Mellon University. All rights reserved.
*
* All use of this software is subject to the terms of the revised BSD
* license. You should have received a copy of this license along
* with this source code in a file named "LICENSE."
*
* @file completionCounter.cxx
* @author rdb
* @date 2025-01-24
*/
#include "completionCounter.h"
/**
* Called when a token is completed before then() is called.
*/
void CompletionCounter::
initial_callback(Completable::Data *data_ptr, bool success) {
CounterData &data = *(CounterData *)data_ptr;
auto prev_count = data._counter.fetch_add((success ? 0 : 0x10000) - 1, std::memory_order_release);
if ((prev_count & 0xffff) == 1) {
// We're done early.
auto prev_callback = data._function.exchange(nullptr, std::memory_order_acq_rel);
nassertv(prev_callback != nullptr);
// Someone called then() in the meantime. Call the new callback. The
// refcount will drop below 0 when that's called but they are designed to
// handle that.
if (prev_callback != &initial_callback) {
prev_callback(data_ptr, success && (prev_count & ~0xffff) == 0);
}
}
}
/**
* Called when a token is completed after this object is destroyed without
* then() being called.
*/
void CompletionCounter::
abandon_callback(Completable::Data *data_ptr, bool success) {
CounterData &data = *(CounterData *)data_ptr;
auto prev_count = data._counter.fetch_sub(1, std::memory_order_relaxed);
if ((prev_count & 0xffff) <= 1) {
// Done.
auto prev_callback = data._function.exchange(nullptr, std::memory_order_relaxed);
nassertv(prev_callback != nullptr);
nassertv(prev_callback == &abandon_callback);
delete &data;
}
}

View File

@ -0,0 +1,58 @@
/**
* PANDA 3D SOFTWARE
* Copyright (c) Carnegie Mellon University. All rights reserved.
*
* All use of this software is subject to the terms of the revised BSD
* license. You should have received a copy of this license along
* with this source code in a file named "LICENSE."
*
* @file completionCounter.h
* @author rdb
* @date 2025-01-22
*/
#ifndef COMPLETIONCOUNTER_H
#define COMPLETIONCOUNTER_H
#include "pandabase.h"
#include "completionToken.h"
#include <cstddef>
/**
* Shared counter that generates "completion tokens" incrementing a counter,
* which will decrement the counter once they are finished. After the tokens
* are handed out, a callback may be registered using then(), which will be
* called as soon as the last token is done.
*/
class EXPCL_PANDA_PUTIL CompletionCounter {
public:
constexpr CompletionCounter() = default;
CompletionCounter(const CompletionCounter &copy) = delete;
INLINE ~CompletionCounter();
INLINE CompletionToken make_token();
template<class Callable>
INLINE void then(Callable callable) &&;
private:
static void initial_callback(Completable::Data *data, bool success);
static void abandon_callback(Completable::Data *data, bool success);
protected:
struct CounterData : public Completable::Data {
// Least significant half is counter, most significant half is error count
patomic_signed_lock_free _counter { 0 };
// Just raise this if the static_assert fires (or limit the size of your
// lambda captures).
alignas(std::max_align_t) unsigned char _storage[64];
};
CounterData *_data = nullptr;
};
#include "completionCounter.I"
#endif

View File

@ -0,0 +1,42 @@
/**
* PANDA 3D SOFTWARE
* Copyright (c) Carnegie Mellon University. All rights reserved.
*
* All use of this software is subject to the terms of the revised BSD
* license. You should have received a copy of this license along
* with this source code in a file named "LICENSE."
*
* @file completionToken.I
* @author rdb
* @date 2025-01-22
*/
#ifndef CPPPARSER
/**
* Creates a token that calls the given callback when it's done, passing it
* true on success and false on failure or abandonment.
*/
template<class Callable>
INLINE CompletionToken::
CompletionToken(Callable callback) {
// Main difference over a Completable is that this will always call the
// callback, even on failure, so that cleanup can be done.
_callback._data = new Completable::LambdaData<Callable>(std::move(callback), [](Completable::Data *data, bool success) {
Completable::LambdaData<Callable> *self = (Completable::LambdaData<Callable> *)data;
std::move(self->_lambda)(success);
delete self;
});
}
#endif
/**
*
*/
INLINE void CompletionToken::
complete(bool success) {
Completable::Data *data = _callback._data;
if (data != nullptr) {
_callback._data = nullptr;
data->_function.load(std::memory_order_relaxed)(data, success);
}
}

View File

@ -0,0 +1,56 @@
/**
* PANDA 3D SOFTWARE
* Copyright (c) Carnegie Mellon University. All rights reserved.
*
* All use of this software is subject to the terms of the revised BSD
* license. You should have received a copy of this license along
* with this source code in a file named "LICENSE."
*
* @file completionToken.h
* @author rdb
* @date 2025-01-22
*/
#ifndef COMPLETIONTOKEN_H
#define COMPLETIONTOKEN_H
#include "pandabase.h"
#include "pnotify.h"
#include "completable.h"
/**
* A completion token can be created from a callback, future or
* CompletionCounter and can be passed into an asynchronous operation in order
* to receive a signal when it is done.
*
* The asynchronous operation should call complete() on it when it is done,
* with a boolean value indicating success or failure. If the token is
* destroyed prematurely, it is treated as if it called complete(false).
*
* This should be preferred over passing an AsyncFuture into a method since
* a CompletionToken provides both more flexibility in use (due to accepting
* an arbitrary callback) and more safety (since the RAII semantics guarantees
* that the callback is never silently dropped).
*
* The token may only be moved, not copied.
*/
class EXPCL_PANDA_PUTIL CompletionToken {
public:
constexpr CompletionToken() = default;
#ifndef CPPPARSER
template<class Callable>
INLINE CompletionToken(Callable callback);
#endif
void complete(bool success);
protected:
Completable _callback;
friend class CompletionCounter;
};
#include "completionToken.I"
#endif

View File

@ -17,6 +17,7 @@
#include "callbackObject.cxx"
#include "clockObject.cxx"
#include "colorSpace.cxx"
#include "completionCounter.cxx"
#include "config_putil.cxx"
#include "configurable.cxx"
#include "copyOnWriteObject.cxx"