The first template parameter of memoized_invoke is an execution policy: a small class that owns the "has this run yet?" state and decides whether concurrent callers are synchronised. callonce ships two.
single_threaded
class single_threaded; // the CTAD default
A plain enum member — not_started / running / done — with non-atomic loads and stores. Every operation is a single instruction. There is no cost over hand-writing a bool ran_ = false; flag.
It is not thread-safe. Two threads calling operator() on the same object can both see not_started, both enter, and both run the callable. Use it when:
- the
memoized_invokelives on one thread, or - an external lock already serialises every access to it, or
- it is a function-local
staticguarded by the compiler's own thread-safe-static-initialisation (in which case callonce is only giving you the return-value cache and thereset()).
lock_free
class lock_free; // via make_memoized<lock_free>(...)
A std::atomic<state>. The first-run race is decided by one compare_exchange_strong from not_started to running:
- Exactly one thread wins and runs the callable.
- Every other thread calls
wait(), which parks onstd::atomic::wait(C++20) — no spinning, no mutex — until the winner publishes a result. - The winner's
mark_done()storesdonewithmemory_order_releaseandnotify_all()s. Any thread that then observesdonesees the cached value through the matching acquire.
This is the std::call_once guarantee, plus the return value.
What lock_free does not protect
reset() and operator()(new_args...) are still not thread-safe under lock_free. They mutate the stored argument tuple and the cached-value optional, which are ordinary members — only the execution state is atomic. If you need to reset a shared lock_free object, stop every other thread from touching it first.
Copy and move reset the state
std::atomic cannot be copied or moved, and a copy owns a different synchronisation context. So lock_free's copy and move operations re-initialise the state to not_started. The cached value is still copied, but a copied object re-runs on its first call:
auto a = make_memoized<lock_free>( &init );
a(); // init runs, state = done
auto b = a; // b.state = not_started (value copied, state not)
b(); // init runs AGAIN for b
single_threaded has no such restriction — it copies its enum like any other member, so a copy of a done single_threaded object serves the cache without re-running.
Side-by-side
single_threaded | lock_free | |
|---|---|---|
| First-run race across threads | not safe | safe — one runs, rest block |
reset() / operator()(args...) concurrency | not safe | not safe |
| Overhead when uncontended | none (one non-atomic load) | one CAS + a few atomic loads |
| Blocking mechanism | — | std::atomic::wait (futex-backed) |
| Copy/move | copies state | resets state to not_started |
| Selected by | CTAD, make_memoized<single_threaded> | make_memoized<lock_free> |
Writing a custom policy
Any type with this interface works as the policy:
struct my_policy
{
bool try_enter(); // move not_started -> running; true if we won
void mark_done(); // -> done; wake waiters
void mark_free(); // -> not_started (reset / rollback); wake waiters
bool is_done() const; // true while in the done state
void wait() const; // block until state is no longer running
};
It must also be default-, copy- and move-constructible and copy- and move-assignable; copy and move may reset the state. A std::mutex + std::condition_variable policy, or one that logs every transition, is a few dozen lines. See the Reference for the exact semantics memoized_invoke relies on.

