C++ Logo

std-discussion

Advanced search

Re: Some feedback on scope guards

From: Edward Catmur <ecatmur_at_[hidden]>
Date: Thu, 13 Apr 2023 15:23:51 -0300
On Thu, 13 Apr 2023, 13:42 Lee Howes, <xrikcus_at_[hidden]> wrote:

> I don't think co_unhandled_exceptions adds anything does it? Either we
> automate storing the thread_local on yield and loading on resume or we
> don't. If we do, then the current thread_local query works fine.
>

Good point, that's no more expensive than updating a linked list, and is
more direct.

If we had a way to *write* to unhandled exceptions we could do this in
> coroutine library code and make it QOI for a given coroutine
> implementation, and that doesn't have to be coroutine specific:
> std::set_uncaught_exceptions(cnt). We can already inject arbitrary state
> into the coroutine frame that's hidden from the coroutine author: in folly
> we maintain a stack trace for the debugger to walk, as one example.
>

That would allow omitting the overhead in the case of coroutines that
aren't expected to be long lived. Fairly ugly, though, to expect coroutine
libraries to modify state like this.

All that said, uncaught_exceptions is a nasty piece of global state in the
> way of the optimizer, and this is a good example of more overhead that
> depending on it could create. It might be better to warn about using
> scope_fail across a yield point in the same way we need to be better at
> warning against use of locks or thread_locals in general. One big advantage
> that co_await/co_yield have over fibers is that we know where the yield
> points are and can make this logic work.
>

Yes, but the sort of actions in a coroutine that you're worried about
throwing are usually suspend points.

This all feels unnecessary when there's an obvious way to make scope guards
work across yield points, at the expense of composability, sure, but with
no problem for how they'd be intended to be used.

Lee
>
> On Thu, 13 Apr 2023 at 06:04, Edward Catmur via Std-Discussion <
> std-discussion_at_[hidden]> wrote:
>
>>
>>
>> On Thu, 13 Apr 2023, 06:35 Andrey Semashev via Std-Discussion, <
>> std-discussion_at_[hidden]> wrote:
>>
>>> On 4/13/23 05:16, Edward Catmur wrote:
>>> >
>>> >
>>> > On Wed, 12 Apr 2023 at 15:27, Andrey Semashev via Std-Discussion
>>> > <std-discussion_at_[hidden]
>>> > <mailto:std-discussion_at_[hidden]>> wrote:
>>> >
>>> > On 4/12/23 18:53, Edward Catmur wrote:
>>> > > On Mon, 10 Apr 2023, 08:57 Andrey Semashev via Std-Discussion,
>>> > > <std-discussion_at_[hidden]
>>> > <mailto:std-discussion_at_[hidden]>
>>> > > <mailto:std-discussion_at_[hidden]
>>> > <mailto:std-discussion_at_[hidden]>>> wrote:
>>> > >
>>> > > I don't think that updating a pointer during a context switch
>>> > would be a
>>> > > deal breaker. The co_unhandled_exceptions() mechanism only
>>> > needs to know
>>> > > the current coroutine state of the current thread, it doesn't
>>> > need a
>>> > > list. If a linked list is needed to be able to return from
>>> one
>>> > coroutine
>>> > > to another then such a list must be already in place.
>>> > >
>>> > > Now that I think of it, co_unhandled_exceptions() might not
>>> > require any
>>> > > new pointers. The coroutine already has to keep a pointer to
>>> > its state
>>> > > somewhere to be able to reference its local variables. I
>>> don't
>>> > see why
>>> > > co_unhandled_exceptions() couldn't use that pointer.
>>> > >
>>> > > But how can co_unhandled_exceptions() *find* that pointer? The
>>> current
>>> > > coroutine state is not stored in per thread memory; it's solely
>>> on the
>>> > > stack. (The linked list that's "already in place" is the stack
>>> > itself.)
>>> >
>>> > The compiler could pass this pointer as a hidden parameter to
>>> > co_unhandled_exceptions() based on implementation-specific
>>> attribute, or
>>> > co_unhandled_exceptions() could be a compiler intrinsic to begin
>>> with.
>>> > Or, if the pointer is stored in a known fixed location of the
>>> parent
>>> > stack frame, co_unhandled_exceptions() could extract it from
>>> there. All
>>> > this is to say this task doesn't seem impossible.
>>> >
>>> > > That said, you might be able to find it when unwinding through a
>>> > > coroutine stack frame. I'm still thinking about it, but it seems
>>> > like it
>>> > > should work. In that case you've only got overhead added to
>>> coroutine
>>> > > creation, coroutine footprint, unwinding through a coroutine, and
>>> > you've
>>> > > doubled the stack footprint and memory access of the
>>> success/failure
>>> > > scope guards, compared to a C++17 implementation using
>>> > > unhandled_exceptions only.
>>> >
>>> > Not sure what you mean by doubling the stack footprint and memory
>>> access
>>> > of the scope guards. The size of the scope guards didn't change.
>>> >
>>> > But *how* do scope_success and scope_failure access the current
>>> > coroutine's co_uncaught_exceptions() counter? Obviously it's not a
>>> > problem if they're complete automatic objects of the coroutine frame,
>>> > but otherwise? e.g. if they're subobjects or dynamically allocated? I'd
>>> > think this would require stack walking (non-destructive unwinding),
>>> > which is hugely expensive, or constructing a thread-local linked list
>>> of
>>> > coroutines, which has continuous overhead.
>>>
>>> I think you are confusing the storage used for the scope guard object
>>> with the stack frame. co_uncaught_exceptions() doesn't need or use the
>>> scope guard object, it doesn't care where it is allocated or whether it
>>> exists at all. What co_uncaught_exceptions() *may* need is its caller's
>>> stack frame, if it is implemented in such a way that it uses the stack
>>> frame to obtain the pointer to the coroutine state (or to discover
>>> whether it is a coroutine at all).
>>>
>>> Now that I think of it more, perhaps using the caller's stack frame is
>>> not a viable idea after all, since the immediate caller of
>>> co_uncaught_exceptions() may not be a coroutine, but a normal function
>>> called within a coroutine. However, as I noted earlier, there are other
>>> possible implementations, including not involving TLS. But using TLS to
>>> store a pointer to the current coroutine state would be the simplest
>>> solution, of course.
>>>
>>
>> Right. But that would require a linked list, since otherwise there's no
>> way to restore the previous value when a coroutine suspends or returns.
>>
>> > And what for scope_success and scope_failure that aren't constructed
>>> > from within coroutines at all? How far do they look to determine to use
>>> > uncaught_exceptions() instead?
>>>
>>> The scope guard would always use co_uncaught_exceptions(). When called
>>> not within a coroutine (meaning, there are no coroutines higher up the
>>> stack), co_uncaught_exceptions() would be equivalent to the
>>> uncaught_exceptions() we currently have.
>>>
>>
>> And how does co_uncaught_exceptions() know that there are no coroutines
>> higher up the stack? It sounds like a thread local linked list is a
>> necessity.
>>
>> > And what if a dynamically allocated scope_success/scope_failure is
>>> > constructed in one coroutine, but then has ownership transferred to
>>> > another stack (which may or may not be a coroutine)?
>>>
>>> In that case, the cached number of uncaught exceptions may become not
>>> actual, depending on what kind of transfer you make. Yes, this may break
>>> scope_success/scope_fail, unfortunately. I'd be willing to mark such use
>>> of scope_success/scope_fail UB, as this is arguably a case of the user
>>> explicitly doing something incompatible with those scope guards' design,
>>> as opposed to using scope guards in the conventional way within
>>> coroutines.
>>>
>>
>> Right, so this design is still fairly fragile. Which behaviors would you
>> define, beyond the absolute minimum of scope guards as complete automatic
>> objects?
>>
>> > > When I say "the destruction of the scope guard is caused by a
>>> > stack
>>> > > unwinding procedure" I mean literally that the stack
>>> unwinding
>>> > procedure
>>> > > is present higher up in the call stack from the scope guard
>>> > destructor.
>>> > > I'm choosing this criteria because that's what makes most
>>> > sense to me in
>>> > > relation to the expected behavior of the scope guards.
>>> > >
>>> > > That doesn't work. Having an unwind caused destructor above in
>>> the
>>> > stack
>>> > > from a scope guard destructor does not always mean that the scope
>>> > guard
>>> > > is in a failure state. It feels like we're going round in circles
>>> > here.
>>> >
>>> > A scope guard destructor would conceal this information by setting
>>> the
>>> > uncaught exceptions counter to zero, as described below.
>>> >
>>> > Is this a revision to your design, an extension, or an alternate
>>> design?
>>> > I'm having trouble keeping track.
>>>
>>> No, it's the same design. Or you could say it's an evolution of the same
>>> design during the discussion. It's all about co_uncaught_exceptions()
>>> and co_set_uncaught_exceptions(), the latter being used for concealing
>>> the pending exceptions from the scope guard action, as I have shown
>>> earlier.
>>>
>>
>> Is this a necessary part of the design or can it be omitted? Which
>> scenarios does it help with?
>>
>>> --
>> Std-Discussion mailing list
>> Std-Discussion_at_[hidden]
>> https://lists.isocpp.org/mailman/listinfo.cgi/std-discussion
>>
>

Received on 2023-04-13 18:24:06