Reconsider the behavior of trying to fork a new thread in a closing scope
mitchellwrosen opened this issue · comments
Some background: conceptually, there are three states a scope can be in:
- "open", allowing new threads to be created within it
- "closing", because we reached the end of a
scoped
block naturally, or because we got hit by an exception, and are going to proceed to killing all living children and waiting for them to terminate - "closed", because we're outside the callback in which the scope was valid, like any regular resource acquired in bracket-style
Clearly, we do want to disallow this bogus program, either with the type system (meh) or via a runtime exception:
scope <- Ki.scoped pure
Ki.fork scope whatever -- using a scope outside its callback
On to the implementation. Each scope keeps an int count of the threads that are about to start, with the sentinel value -1 meaning closed/closing. When we go to fork a thread, if this counter is not -1, we bump the counter, then spawn the thread, then decrement the counter. If the counter is -1, we throw a runtime exception (error "ki: scope closed").
This design makes closing a scope pretty simple: wait until there are 0 children about to start, then prevent new children from starting by writing -1, then kill all of the living children.
The problem (potentially) is that there's not actually a "closing" state that's distinguishable from "closed". So while we do prevent bogus programs like the above from spawning a thread in a closed scope, it seems wrong to punish code that attempts to spawn a thread into a closing scope in the same way.
Some options:
- (straw man) Make
fork
have typefork :: Scope -> IO a -> IO (Maybe (Thread a))
, and return Nothing if we try to fork a thread in a closing scope. I don't think this API is good, but it's conceptually what we are after. The current behavior (to reiterate/summarize the above) is to throw a lazy runtime exception witherror "ki: scope closing"
rather than return Nothing. - Make
Thread
a two-variant sum type, with aDidntActuallyMakeTheThreadBecauseTheScopeWasClosing
variant. We'll have to decide what to do if youawait
such a thing. - Tweak the teardown dance to actually continue to allow threads to be created in a closing scope, if only to throw a
ScopeClosing
exception to them soon after (which is how we kill children). This doesn't seem meaningfully different to (2). - Something else, or nothing?
it seems wrong to punish code that attempts to spawn a thread into a closing scope in the same way.
Can you say why? I'm still not quite clear on the motivation for needing that distinction.
Sure. Conceptually, a worker trying to create a thread in a closing scope hasn't done anything wrong; a lazy synchronous exception seems fit only for wrong, invariant-violating code like the above example.
And why this might matter in practice: for starters, we'll try to propagate this exception to our parent, which seems unclean, since we should know it's closing and not bother. Additionally, the user may have instrumented the thread to log and rethrow all exceptions, or something similar, for diagnostic purposes. We probably ought not to communicate that something grave like fromJust Nothing
has occurred.
Another option would be for fork
to check some TVar
that holds the scope's status and immediately throw a ScopeClosing
exception if the status is observed to be Closing
.
Yes, great, that seems to be much better than 1, 2, and 3 above!
It might seem a little bit suspicious to readers that we synchronously throw an asynchronous exception, but it gets the behavior I think we want: just act like we got hit by a ScopeClosing
rather than actually proceed with the theater of forking a thread and putting its ThreadId
in a data structure just so its parent can throw it a ScopeClosing
.
@ocharles Does that sound good to you?
Yea, I think so!