1

It's known how to make a pure concurrency monad based on ContT, based on a functional pearl of Koen Claessen:

data Action m where
  Atom :: m (Action m) -> Action m
  Fork :: [Action m] -> Action m
  Stop :: Action m

fork :: Applicative m => [ContT (Action m) m a] -> ContT (Action m) m ()
fork processes = ContT $ \next -> Fork <$> sequenceA (next () : [ process $ const $ pure $ Const | ContT process <- processes ])

How would I implement shared variables like IORefs or MVars? Or at least an async/await mechanism? Bonus points if it's polymorphic in the type of data passed.

Turion
  • 5,684
  • 4
  • 26
  • 42
  • 1
    Depending on what kind of pure you need, would it suffice to use [`ST`](https://hackage.haskell.org/package/base-4.14.1.0/docs/Control-Monad-ST.html) as the base monad? Then you could at least eliminate into a pure value without relying on the dark arts. – luqui Feb 04 '21 at 02:43
  • @luqui I want to keep the base monad parametric, but maybe there are some basic constraints on the base monad that one could make. – Turion Feb 04 '21 at 08:20

1 Answers1

4

I assume by “implement shared variables like IORefs or MVars” you mean in a way other than just having the underlying monad m include IO and using IORef/MVar. That’s straightforward, something like this:

newVar :: a -> ContT (Action IO) IO (IORef a)
newVar x = ContT $ \ k -> Atom $ do
  v <- newIORef x
  pure $ k v

A conventional way to add mutable variables to the “poor man’s concurrency monad” purely is by adding additional actions to the Action type for creating, reading, and writing mutable variables. Suppose we have some type Var m a that identifies mutable variables of type a that can be created & accessed in m.

data Action m where
  Atom :: m (Action m) -> Action m
  Fork :: [Action m] -> Action m
  Stop :: Action m

  New   :: (Var m a -> Action m) -> Action m
  Read  :: Var m a -> (a -> Action m) -> Action m
  Write :: Var m a -> a -> Action m -> Action m

Notice that the type parameter a does not appear in the result type of these new constructors, so it’s existentially quantified, and variables may thus contain values of any type. New is an action that continues to another action with a fresh variable as an argument; Read, given a variable, continues to the next action with that variable’s value; and Write, given a variable and a new value, writes the value into the variable before continuing.

Like fork, these would be constructed with helper functions that produce actions in ContT (Action m) m:

newVar
  :: (Applicative m)
  => ContT (Action m) m (Var m a)
newVar = ContT $ \ k -> pure (New (Atom . k))

readVar
  :: (Applicative m)
  => Var m a -> ContT (Action m) m a
readVar v = ContT $ \ k -> pure (Read v (Atom . k))

writeVar
  :: (Applicative m)
  => Var m a -> a -> ContT (Action m) m ()
writeVar v x = ContT $ \ k -> pure (Write v x (Atom (k ())))

After that, you just have to decide on a suitable representation of Var. One method is a data family, which makes it relatively easy to use IORef/MVar when IO is available, and something else like an Int index into an IntMap otherwise.

data family Var (m :: Type -> Type) (a :: Type) :: Type

data instance Var IO a = IOVar { unIOVar :: !(MVar a) }

Of course this is just a sketch; a much more fleshed out implementation can be found in the monad-par package, whose design is described in A Monad for Deterministic Parallelism (Marlow, Newton, & Peyton Jones 2011); its Par monad is basically a continuation monad around an action type like this, and its IVar abstraction is implemented similarly to this, with some additional constraints like extra strictness to enforce determinism and allow pure execution of internally impure code (an IVar secretly wraps an IORef).

Jon Purdy
  • 53,300
  • 8
  • 96
  • 166
  • "something else like an `Int` index into an `IntMap` otherwise." That map must then reside in a state in `m`, I'm assuming? – Turion Feb 04 '21 at 08:15
  • Some of your constructions are missing `fmap` or `return` in order to lift the inner computations in `ContT` into `m`, I believe. – Turion Feb 04 '21 at 09:04
  • I would have expected an async/await pattern giving rise to `async :: ContT (Action m) m a -> ContT (Action m) m (Var a)` and `await :: Var a -> ContT (Action m) m a` to be easier to implement directly in the `Action m` type than variables, but I can't figure out. In case you have a quick idea, I'd be very interested :) otherwise I might open a follow-up question. – Turion Feb 04 '21 at 09:58
  • 1
    @Turion: Thanks, I think I’ve fixed those mistakes. That’s what happens when you don’t test the code! Yes, the hypothetical `IntMap` (or [`DMap`](https://hackage.haskell.org/package/dependent-map-0.4.0.0/docs/Data-Dependent-Map.html#t:DMap)) solution would need to go in the state of `m` somewhere. Effectively, an `IORef` *is* stored in the state of `IO`, but of course that’s just the Haskell runtime itself. I’m not sure about `async`/`await`, will think on it. `monad-par` uses `IVar` for that: `spawn` forks a task and returns an `IVar`, and using `get` on that `IVar` awaits the task’s result. – Jon Purdy Feb 04 '21 at 19:29