The rollback

Execution is wrapped in an RAII guard:

  1. A thread transitions the policy from not-started to running.
  2. An execution_guard is constructed, holding a reference to the policy.
  3. The callable runs.
  4. On success, guard.commit() transitions to done and stores the result.
  5. On exception, the guard's destructor calls policy.mark_free(), putting the state back to not-started. The cache stays empty. The exception propagates out of operator().

So a throwing callable leaves the memoized_invoke exactly as if it had never been called — is_done() is false, and the next call retries. This is the same contract as std::call_once, which also retries after an exception.

int attempt = 0;
memoized_invoke co( [&attempt](int x) -> int {
    if (++attempt == 1) throw std::runtime_error("transient failure");
    return x;
}, 42 );

try { co(); } catch (std::runtime_error const&) { /* first attempt failed */ }
int r = co();     // second attempt succeeds → 42

value() after a throw still throws std::bad_optional_access — there is no value to hand back.

The concurrent case

Under lock_free, when the winning thread's callable throws:

  • mark_free() stores not-started and notify_all()s the waiters.
  • Each woken waiter re-checks the state. It is not done, so the waiter itself tries to enter and run the callable.
  • One waiter wins the retry; if its callable also throws, the cycle repeats; if it succeeds, it publishes the value and wakes everyone else.

This is a fix in 1.0.0. Earlier, a waiter woken by the rollback fell

straight through check_and_call() without re-entering, so operator()

then read the still-empty cache and threw std::bad_optional_access

which, being unrelated to the exception the callable threw, escaped the

worker's catch and called std::terminate. check_and_call() now

loops: re-check is_done(), retry try_enter(), else wait() again.

test/ExceptionRollbackConcurrent.cpp hammers this path 200 times.

What is and is not thread-safe

Operationsingle_threadedlock_free
Concurrent operator()() / operator()(same args) (first-run race)not safesafe
Concurrent operator()(different args)not safenot safe
Concurrent reset() / reset(args...)not safenot safe
is_done() / value() after a completed run, no concurrent writersafesafe

The rule of thumb: lock_free makes the first call safe to race. Anything that changes the stored arguments or clears the cache — reset, the argument-changing operator() — must be single-threaded or externally locked, because those touch non-atomic members.

For a shared one-time initialiser that is never reset, lock_free is all you need:

inline auto& config()
{
    static auto loader = make_memoized<lock_free>(&Config::load_from_disk);
    return loader.value();   // first caller loads, everyone gets the same ref
}

(Here the function-local static initialisation is itself thread-safe, so even single_threaded would be correct — lock_free matters when the memoized_invoke is a data member reachable by several threads before any of them has called it.)