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

Unify `fork` and `async`

mitchellwrosen opened this issue · comments

Current API

data Thread a
fork :: Scope -> IO a -> IO (Thread a)
async :: Scope -> IO a -> IO (Thread (Either SomeException a))
await :: Thread a -> IO a

Idea 1: allow user to pick e in async

data Thread a
fork :: Scope -> IO a -> IO (Thread a)
async :: Exception e => Scope -> IO a -> IO (Thread (Either e a))
await :: Thread a -> IO a

This would allow users to pick some root exception, not necessarily SomeException, that is "checked" - and all other higher exceptions (including all async exceptions, always) are propagated to the parent, like fork.

One downside here is it moves async in the direction of fork, and in fact async @Void would be basically equivalent to fork, so it might be a bit difficult to separate the two functions in one's mind.

Idea 2: move the e into Thread

data Thread e a
fork :: Scope -> IO a -> IO (Thread Void a)
async :: Exception e => Scope -> IO a -> IO (Thread e a)
await :: Thread e a -> IO (Either e a)

This would move the concept of what exception a thread might be expected to throw into its type, which is kind of nice. For example, a Thread IOException Int is a thread that may succeed with an Int, or fail with an IOException, but any other exception is truly unexpected, not evident in its type, and would be propagated to its parent.

One downside here is callers of await on a Thread Void have to deal with an absurd Left Void case explicitly. This can be worked around with a type family, which adds complexity to the API, and requires users to apply a function in their heads when reading documentation:

type family Result e a where
  Result Void a = a
  Result e a = Either e a

await :: Thread e a -> IO (Result e a)

Idea 3: fully unify fork and async

data Thread e a

fork :: SyncException e => Scope -> IO a -> IO (Thread e a)
await :: Thread e a -> IO (Result e a)

class Exception e => SyncException e where
  type Result e a :: Type
  notExported :: Either e a -> Result e a

instance {-# OVERLAPS #-} SyncException Void where
  type Result Void a = a
  notExported = either absurd id

instance Exception e => SyncException e where
  type Result e a = Either e a
  notExported = id 

This is like idea 2, but gets rid of the fork/async distinction entirely. Unfortunately, this means we need a type class to allow us to return just an a, not a Either Void a, in the Thread Void case, which was previously accomplished by having two separate functions.

I've named the type class SyncException here, just as a reminder that in ki, asynchronous exceptions are always propagated, but it could really be called anything, including (confusingly) Exception.

Feedback from @seagreen: Idea 1 is the winner so far.

We think the type family + type class is a little much just to unify fork and async.

Plus, we had the nice realization that async, when generalized such, looks a lot like try. Maybe it should be renamed to forktry?

forktry :: Exception e => Scope -> IO a -> IO (Thread (Either e a))