The rollback
Execution is wrapped in an RAII guard:
- A thread transitions the policy from not-started to running.
- An
execution_guardis constructed, holding a reference to the policy. - The callable runs.
- On success,
guard.commit()transitions to done and stores the result. - 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 ofoperator().
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 andnotify_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, sooperator()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
catchand calledstd::terminate.check_and_call()nowloops: re-check
is_done(), retrytry_enter(), elsewait()again.
test/ExceptionRollbackConcurrent.cpphammers this path 200 times.
What is and is not thread-safe
| Operation | single_threaded | lock_free |
|---|---|---|
Concurrent operator()() / operator()(same args) (first-run race) | not safe | safe |
Concurrent operator()(different args) | not safe | not safe |
Concurrent reset() / reset(args...) | not safe | not safe |
is_done() / value() after a completed run, no concurrent writer | safe | safe |
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.)

