awkward-squad / ki

A structured concurrency library

Home Page:https://hackage.haskell.org/package/ki

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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:

  1. (straw man) Make fork have type fork :: 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 with error "ki: scope closing" rather than return Nothing.
  2. Make Thread a two-variant sum type, with a DidntActuallyMakeTheThreadBecauseTheScopeWasClosing variant. We'll have to decide what to do if you await such a thing.
  3. 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).
  4. 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!