template<class T, class Strand = boost::asio::io_context::strand>
class assert_excl_strand_ref;
In coroutines the suspend point is clearly marked with
await
, which tells the reader that something unusual happens in this function and allows the reader, for example, to confirm whether the lifetimes of the objects of interest align with the lifetime of the coroutine or not, whether some locks need to be acquired to protect some concurrently accessed data, and whether some locks need to be released before the execution reaches the suspend point.
Our concurrency problems don’t vanish when we adopt coroutines. It is very easy — even seductive — to abuse cooperative multitasking in a way we spare many round-trips to synchronization primitives when we want to touch shared variables.
The lack of synchronization logic may even be seen as desired as we also dismiss the associated complexity that might hinder (maybe just a little of) performance.
What we also bring to our homes together with such supposedly performance gains
is a headache of unmaintainable code. Advocates of the await
keyword suggest
we should read code paying attention to all suspension points. It is a
suggestion that makes every coroutined suspension code equally hard to read. I
don’t know which await
directives are safe or not. I’m not the writer of these
codes. How can I know what is a safe access? The information isn’t there until I
fully understand the code and the surrounding context (and productivity is lost
in the process). This has been observed before:
Where now, within the scope of the process we’re in, we know that we are only allowing anything else to happen at the bottom, when we call
yield from server.update_balances()
. There is no chance that any other concurrent calls topayer.withdraw()
can occur while we’re in the function’s body and have not yet reached theserver.update_balances()
call.He then makes a clear point as to why even the implicit gevent-style async isn’t sufficient. Because with the above program, the fact that
payee.deposit()
andpayer.withdraw()
do not do ayield from
, we are assured that no IO might occur in future versions of these calls which would break into our scheduling and potentially run anothertransfer()
before ours is complete.(As an aside, I’m not actually sure, in the realm of "we had to type
yield from
and that’s how we stay aware of what’s going on", why theyield from
needs to be a real, structural part of the program and not just, for example, a magic comment consumed by a gevent/eventlet-integrated linter that tests callstacks for IO and verifies that the corresponding source code has been annotated with special comments, as that would have the identical effect without impacting any libraries outside of that system and without incurring all the Python performance overhead of explicit async. But that’s a different topic.)
I only found out about this opinion much later, but that’s basically what is
proposed here. Real world usage of this async style led me to the same
conclusion. Instead making await
special, we make access to shared variables
special. If a variable must not be modified/read by concurrent fibers in a
critical section, we document our expectation using
assert_excl_strand_ref<T>
. It works just like an assert()
. It won’t add
runtime penalty and we actually encode the knowledge of our expectation into the
code.
Important
|
The key idea here is that when we replace preemptive multitasking by cooperative multitasking, we move some scheduling decisions from runtime to compile-time. So it is only natural that we also move related sync primitives from runtime to compile-time. The new sync primitives should be zero-cost and only determine if code will fail to compile. They should be understood as constraints. |
The name means the object is a reference. And within its strand, it should have exclusive access. The assert part is a reminder that it’ll only do extra work in debug releases (I’d rather have static analysers to ensure this constraint… maybe in the future).
Objects of this type are not movable nor copy-able. They are intimately tied to
some lexical scope, so it makes sense to disable these operations on them. They
don’t manage lifetime and their state can be completely inferred from the
surrounding code, so we don’t provide some common helpers either (e.g. operator
bool
).
P0171R0 had the following example to illustrate the original argument:
auto foo(string & s) {
s.push_back('<');
do-stuff
s.push_back('>');
}
Using assert_excl_strand_ref<T>
, we can rewrite the code as follows:
auto foo(string & s) {
assert_excl_strand_ref<string> s{s};
s->push_back('<');
do-stuff
s->push_back('>');
}
But given the await
-less coroutine support is absent in the language-level, we
have to resort to the following code for this very library:
auto foo(string & s, fiber::this_fiber this_fiber) {
assert_excl_strand_ref<string> s{s, this_fiber};
s->push_back('<');
do-stuff
s->push_back('>');
}
The code presented is actually pretty simple as all we need is to delimit a forbid-suspend block. In fact, my previous attempt to solve the problem over a year ago relied on a very stupid static analyser implemented on top of the Python bindings for the LLVM project[1]. Using such script, the solution would actually be:
auto foo(string & s) {
[[forbid_suspend]] {
s.push_back('<');
do-stuff
s.push_back('>');
}
}
But from personal experience working daily on a project heavily based on Boost.Asio and fibers, the block approach showed itself unable to handle more complex cases as I hit cases where I’d need to enable suspension again in the middle of the block. One of the cases that best illustrate the limitation can be reduced as:
void MyApp::on_update(std::variant<A, B>& state, auto this_fiber)
{
std::visit([&](auto& e) {
using T = std::decay_t<decltype(e)>;
if constexpr (std::is_same_v<T, A>) {
auto msg = generate_msg(this->view, e);
foo.async_write(msg, this_fiber);
// from here we shouldn't touch `this->view` again
} else if constexpr (std::is_same_v<T, B>) {
auto msg = generate_msg(this->view, e);
bar.async_write(msg, this_fiber);
// from here we shouldn't touch `this->view` again
}
}, state);
}
The original code was 77 lines long. From this count, 24 lines were dense
comments. Before we reach any async_write
, we must not have any suspension
point or else this→view
will reflect state unrelated to state
. And when we
do the necessary preparations to call async_write
… well… we have to
suspend the fiber. Where [[forbid_suspend]]
blocks would fail, we can resort
to assert_excl_strand_ref<T>
:
void MyApp::on_update(std::variant<A, B>& state, auto this_fiber)
{
assert_excl_strand_ref<View> view{this->view, this_fiber};
std::visit([&](auto& e) {
using T = std::decay_t<decltype(e)>;
if constexpr (std::is_same_v<T, A>) {
auto msg = generate_msg(*view, e); //< NEW
view.release(); //< NEW
foo.async_write(msg, this_fiber);
// any attempts to dereference `view`
// here will fail *loudly*
} else if constexpr (std::is_same_v<T, B>) {
auto msg = generate_msg(*view, e); //< NEW
view.release(); //< NEW
bar.async_write(msg, this_fiber);
// any attempts to dereference `view`
// here will fail *loudly*
}
}, state);
}
I have been using assert_excl_strand_ref<T>
on othes places that requires
different patterns not similar to the previous one and so far it has been a good
fit.
A nice collateral effect is it encodes the reason why/when a fiber can’t suspend. It definitively made the code much easier to follow and more maintainable.
A void
specialization is provided to cover smaller blocks where we can go
without any documentation/reason.
Member-functions
Constructor
// Not available when T=void
assert_excl_strand_ref(
T& o,
typename basic_fiber<Strand>::this_fiber& this_fiber
);
// Only available when T=void
assert_excl_strand_ref(
typename basic_fiber<Strand>::this_fiber& this_fiber
);
Watches o
and stores a reference (not a copy) to this_fiber
.
Calls this_fiber.forbid_suspend()
.
Destructor
~assert_excl_strand_ref();
Calls this_fiber.allow_suspend()
if watching some object.
operator*()
// Not available when T=void
T& operator*() const;
T* operator->() const;
Dereferences pointer to the watched object.
release()
void release();
Releases the pointer of the watched object. If there was a watched object
previously, will also call this_fiber.allow_suspend()
.
reset()
// Not available when T=void
void reset(T& o);
// Only available when T=void
void reset();
Watches o
. If there was no watched object previously, will also call
this_fiber.forbid_suspend()
.
See also
-
this_fiber.forbid_suspend()
-
this_fiber.allow_suspend()