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_invoke lives on one thread, or
  • an external lock already serialises every access to it, or
  • it is a function-local static guarded by the compiler's own thread-safe-static-initialisation (in which case callonce is only giving you the return-value cache and the reset()).

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 on std::atomic::wait (C++20) — no spinning, no mutex — until the winner publishes a result.
  • The winner's mark_done() stores done with memory_order_release and notify_all()s. Any thread that then observes done sees 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_threadedlock_free
First-run race across threadsnot safesafe — one runs, rest block
reset() / operator()(args...) concurrencynot safenot safe
Overhead when uncontendednone (one non-atomic load)one CAS + a few atomic loads
Blocking mechanismstd::atomic::wait (futex-backed)
Copy/movecopies stateresets state to not_started
Selected byCTAD, 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.