One Pointer to Wait for Them All
C++ gives you three separate things and expects you to wire them together yourself.
shared_ptr<T> handles ownership. future<T> handles "this value is not ready yet". And if you have a task scheduler, it has its own idea of what a dependency is, usually some node type you have to register by hand. Every time you want an object that is produced asynchronously, shared by several consumers, and known to the scheduler, you end up carrying three things around and keeping them in sync.
I got tired of that, so I wrote ptr<T>. It is one handle that is all three. You use it like a pointer. If the value is not ready, dereferencing it blocks until it is. And the scheduler can look at it and figure out what it is waiting on without anyone doing anything special.
This is a small library (two headers, ptr.hpp and cache.hpp) but it packs a surprising amount of behaviour into one template, so let me walk through it.
Four states, one type
A ptr<T> is a struct with four member fields, and at most one of them is meaningful at a time:
std::shared_ptr<sharedstate> sharedState; // pending: wraps a future
std::shared_ptr<T> singleShared; // resolved: owns the value
std::weak_ptr<T> singleView; // non-owning weak view
T* rawvalue = 0; // non-owning raw view
So the same type covers "I own this", "I am waiting for this", "I can see this but I do not own it", and "here is a raw borrow". A shared_ptr plus a future plus a weak_ptr plus a T*, collapsed into one thing you can pass around.
isNull() is true only when none of singleShared, sharedState or rawvalue are set (the weak view deliberately does not count, since it can expire on its own).
Construction covers all the ways a value can arrive:
ptr(T* value); // adopt a raw pointer
ptr(T&& value); // move into a fresh shared_ptr
ptr(const std::shared_ptr<T>& value); // wrap an existing one
ptr(std::weak_ptr<T>&& value); // non-owning view
ptr(std::future<pointer>&& fut); // pending
ptr(std::future<ptr<T>>&& fut); // pending, shallow (see below)
ptr(weak_reference reference); // rehydrate from a weak handle
There is no default-constructed "live" object here on purpose. Anything non-trivial gets produced by an init() that may kick off work on a worker thread, and whatever that returns gets wrapped in a ptr<T>. The constructor never does the real work.
Dereferencing blocks, and that is the whole trick
This is the part that makes the type worth having:
T* operator->() {
if (rawvalue) return rawvalue;
return get().get();
}
std::shared_ptr<T> get() {
wait();
if (singleShared) return singleShared;
if (!singleView.expired()) return singleView.lock();
return {};
}
void wait() {
if (sharedState) {
sharedState->wait();
singleShared = sharedState->value;
sharedState.reset();
}
}
a->foo() reads like a normal pointer dereference. Under the hood, if a is still pending, the calling thread blocks on the future, the resolved value gets cached into singleShared, and the pending state is dropped. Every dereference after that is just a shared_ptr access with no synchronisation cost.
That last bit matters. The collapse is one-way. Once a ptr<T> resolves, it stops being a future forever and becomes a plain owning handle. You do not pay for the async machinery on the hot path.
The "shallow" future variant (future<ptr<T>> instead of future<shared_ptr<T>>) is for producers that themselves return a handle rather than a finished object. Resolving one of those calls .get() on the inner ptr<T> too, so a chain of pending handles flattens down to a value in one go.
There is no timeout
Worth being upfront about this: ptr<T>::wait() always blocks. There is no cancellation, no deadline, no wait_for on the public path. If you dereference something whose producer never finishes, you sit there.
The non-blocking path exists, but one level down:
int wait(bool block = true); // on sharedstate: 1 = ready, 0 = not ready, -1 = invalid
So a thread that must not stall polls getFutureState()->wait(false) and does something else on 0, instead of touching operator->. It is not as ergonomic as the blocking path, which is sort of the point: the nice syntax is the one that waits, and opting out of waiting means opting into being explicit about it.
Abandoned futures do not block the destructor
Here is a nasty detail that std::async users know well. If you destroy a future that came from std::async and never got its value, the destructor blocks until the task finishes. Drop a pending ptr<T> in the wrong place and you have stalled a thread on cleanup for no reason.
The sharedstate destructor sidesteps it:
~sharedstate() {
if (fut.has_value() && !value) {
std::lock_guard<std::mutex> lck(fut->mtx);
if (fut) {
if (fut->fut.valid())
ptr<T>::yielder.write()->push_back(std::move(fut->fut));
if (fut->shallowFut.valid())
ptr<T>::yielderShallow.write()->push_back(std::move(fut->shallowFut));
fut.reset();
}
}
}
Unfinished futures get moved into a static per-type yielder list instead of being destroyed in place. The destructor returns immediately, and the abandoned work gets reaped later, somewhere that can afford to wait. It is a graveyard for futures nobody wanted anymore.
You do have to drain that list at some point, or it grows. But draining it at a chosen moment is much better than blocking at an arbitrary one.
The scheduler part
The other half of the type is scheduling metadata. sharedstate implements a tiny interface:
struct DependencyInterface {
// 1 = ready, 0 = not ready, -1 = invalid
virtual int wait(bool block = true) = 0;
virtual ~DependencyInterface() {}
};
struct Task {
std::shared_ptr<DependencyInterface> taskInterface;
std::weak_ptr<std::vector<Task>> childTasks;
int requiredCores;
};
Since the pending state is a dependency node, a Task can point straight at it. No adapter, no registration, no separate handle.
Declaring a dependency is one call:
template <typename Q>
void addDependency(ptr<Q> t, int requiredCores = 1) {
if (dependencies == nullptr) dependencies = std::make_shared<std::vector<Task>>();
if (t.sharedState) {
Task task;
task.taskInterface = t.getFutureState();
task.childTasks = t.getTaskDependencies();
task.requiredCores = requiredCores;
dependencies->push_back(task);
}
}
Note the guard: if t is already resolved (no sharedState), there is nothing to depend on and the call does nothing. Dependencies on finished work are free.
The scheduler on the other side is a custom thread pool that walks this graph. Conceptually it is close to OpenMP's dynamic tasking, but it derives readiness by polling DependencyInterface::wait(false) across a task's declared dependencies rather than by chaining continuations. Each node also carries requiredCores, so the pool knows how much of the machine a producer wants before it schedules it.
There is a lifetime consequence that is easy to miss. While a task is in flight, the scheduler holds Task::taskInterface strongly, which is a strong reference to the sharedstate. So the pending value stays alive because it is being computed, completely independently of whether any client code still holds a ptr<T> to it. Ownership by the scheduler is real ownership.
I looked around for prior art on this exact fusion and did not find any. The closest things are TBB's flow::graph and folly-style futures, but neither one collapses "the pending value" and "the reference to it" into a single dereferenceable type that also carries its own scheduling metadata. They keep those layers separate, probably for good reasons that I chose to ignore.
Views and weak references
Passing a handle downstream without touching the refcount:
ptr<T> createView(); // resolve, then borrow raw
static ptr<T> create_view(T* rawpointer);
A view is a ptr<T> with only rawvalue set. It dereferences with zero indirection and zero atomic traffic, and it keeps nothing alive. Standard borrow semantics, standard borrow risks: if the owner dies first, you have a dangling view and nothing will tell you.
weak_reference is the more interesting one, because it can round-trip either backing state:
struct weak_reference {
std::weak_ptr<sharedstate> shared;
std::weak_ptr<T> single;
};
Rebuild a ptr<T> from one and you get back whatever it was: the resolved value if it resolved, or the still-pending future state if it has not. That property is what makes the cache below work.
cache<Key, Value>: dedup, not memoisation
The whole cache is 30 lines:
ptr<Value> get(const Key& key) {
std::lock_guard<std::mutex> lck(mtx);
auto it = cachedValues.find(key);
if (it != cachedValues.end()) {
ptr<Value> result(it->second);
if (result.isNull()) {
cachedValues.erase(it);
} else
return result;
}
ptr<Value> result = creator(key);
cachedValues[key] = result.weakReference();
return result;
}
Look at what the map stores: weak_reference, never a strong one. The cache never keeps anything alive. It only remembers that something might still be alive, and hands you a fresh handle to it if it is.
Which means this is not a cache in the usual sense. It is a coalescing table. Ten threads asking for the same key while the value is being computed all get the same in-flight ptr<Value>, so creator runs once and everyone waits on the same future. That is the useful part, and it is genuinely useful: it is exactly what you want for something like "load this texture" being requested from five places at once.
But the moment nothing holds the value anymore, the entry goes stale and the next get() recomputes. No TTL, no LRU, no retention. That is by design as written, though "by design" is doing some work in that sentence.
The two things that can keep an entry alive are worth spelling out, because they are not obvious:
- an external caller still holding its
ptr<Value>, or - the scheduler, holding
Task::taskInterfacewhile the task is in flight.
Number two is the one that surprises people. A value can stay cached purely because it is still being computed, and then evaporate shortly after it finishes, if nobody grabbed the result.
The lock
One mtx guards the entire get(), including the creator(key) call. Every key serialises through the same lock.
That is fine if creator only builds the async handle, which is the intended usage: kick off a future or submit a task, return immediately, let the actual work happen elsewhere. It is not fine if creator does real work synchronously, because then a slow load of key A blocks a trivial lookup of key B for no reason at all.
Sharding the map, or dropping the lock around the creator call with a "someone else got here first" recheck, would both fix it. Neither is done today, so the constraint is: keep creator cheap.
Sharp edges
Some things I know are wrong or at least uncomfortable, in the spirit of not pretending the code is nicer than it is:
The const overloads are a lie. const T* operator->() const calls get(), which is non-const (it mutates state when it resolves the future). Templates only instantiate member functions you actually use, so this compiles right up until someone dereferences a const ptr<T>, at which point it does not. The honest fix is either making the resolution machinery mutable or admitting that a lazily resolving handle cannot really be const-dereferenced.
addref() calls singleShared.use_count() and throws away the result, and release() is empty. Those are placeholders for a COM-style interop path that was never finished.
operator< is written to give a stable ordering across mixed states, but it returns false for two views or two nulls, which is fine for ptrRemoveDuplicates (which sorts by resolved address anyway) and not fine as a general strict weak ordering. Do not put these in a std::set and expect good things.
And the big one: dereferencing can block, so any function taking a ptr<T> is potentially a blocking function, and you cannot tell from the signature. That is the price of the ergonomics. The whole appeal is that a->foo() works whether or not a is ready, and the cost is that you lose the ability to see, at the call site, that a wait might happen. It is a trade I would make again for this codebase, and would not make for a library other people had to use.
Was it worth it
Yeah, for the thing it was built for. The scheduler integration is the payoff: being able to write code that produces and consumes objects normally, and have the dependency graph fall out of the types instead of being maintained by hand, removes a whole category of "I forgot to declare that dependency" bugs.
The cost is that ptr<T> is a type with a lot of behaviour hiding inside a very innocent-looking ->. Four backing states, a blocking dereference, a future graveyard, and a scheduler node, all behind an arrow operator. That is either elegant or terrifying depending on how much of the codebase you wrote yourself.
I wrote all of it, so I am going with elegant.
