Abstracting Async.Concurrently

Subscribe via RSS


Applicatives have an interesting relationship to concurrency. fraxl is my attempt to abuse this relationship with free monads to get something more abstract than haxl. By using a special Applicative instance, fraxl allows computations to be implicitly concurrent.

The async package has something similar in Control.Concurrent.Async.Concurrently. The Concurrently applicative is a newtype around IO. But its Applicative instance parallelizes the IO actions.

{-# LANGUAGE DeriveFunctor #-}

newtype Concurrently a = Concurrently { runConcurrently :: IO a }
  deriving Functor

instance Applicative Concurrently where
  pure = Concurrently . return
  Concurrently fs <*> Concurrently as =
    Concurrently $ (\(f, a) -> f a) <$> concurrently fs as

Because of the monad law (<*>) = ap, Concurrently cannot be a monad. Any monadic binding will not be executed concurrently, forcing ap to behave differently than (<*>). Despite this, you can convert between Concurrently and IO painlessly by packing and unpacking the Concurrently newtype.

But both fraxl and haxl fall prey to the same issue. They have to knowingly break this monad law to parallelize computations. However, being free monads, they hide this behind interpreters that make the law hard to observe. So morally, the law is not being broken. The free monad accumulates the computation, and the interpreter runs it while promising not to break any laws.

This solution leaves me uneasy. Cherry picking when the law doesn’t matter feels half-baked. This is why I like Concurrently so much. It allows seamless conversion between concurrent and sequential code, without being lenient on the laws. Abstracting Concurrently’s design to other monads would be great. In essence, Concurrently is an applicative tied to the monad IO, with conversions between the two to allow you to use either. As a class, this would look something like this.

{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE FunctionalDependencies #-}

import qualified Control.Concurrent.Async as Async

class (Applicative f, Monad m) => Concurrently f m | f -> m, m -> f where
  runConcurrently :: f a -> m a
  runSequentially :: m a -> f a

instance Concurrently Async.Concurrently IO where
  runConcurrently = Async.runConcurrently
  runSequentially = Async.Concurrently

One of the key features of Async.Concurrently is that switching between it and IO preserves concurrency. That is, runSequentially (runConcurrently x) <*> runSequentially (runConcurrently y) is just as concurrent as x <*> y. This suggests the laws:

  • runSequentially . runConcurrently = id
  • runConcurrently . runSequentially = id

Async.Concurrently abides by this quite trivially, since it’s just a newtype around IO with a different Applicative instance. Luckily, something like fraxl can use a similar trick. fraxl uses a special free monad that abuses an underlying Applicative, and consequently “breaks” the monad law. However, using Async.Concurrently’s trick, fraxl could have a newtype for concurrency, and then use an ordinary free monad for the sequential monad, instead of its special one.

Recall what fraxl looks like with the pseudo-law-breakage.

-- Old

data Ap f a = ...
-- Concurrent, law-breaking `Applicative` instance
data Free f a = ...
type Fraxl f = Free (Ap f)

getSite :: String -> Fraxl MyDataSource Site
getSite = ...
printSite :: Site -> Fraxl MyDataSource ()
printSite = ...

example :: Fraxl MyDataSource IO ()
example = do
  --                               Concurrent
  (a,b) <- (,) <$> getSite "site1.com" <*> getSite "site2.com"
  printSite a
  printSite b

In this version, we have a free monad with a weird Applicative instance that “breaks” the monad laws. But we get concurrency in do blocks for no extra charge. Using the newtype trick, we can move the weird Applicative instance to a type with no Monad instance, at the cost of slightly more verbose do code.

-- New

data Ap f a = ...
-- Sequential `Applicative` instance
data Free f a = ...
type Fraxl f = Free (Ap f)

-- Concurrent, law-abiding `Applicative` instance
-- No `Monad` instance
newtype Free' f a = Free' (Free f a)
type Fraxl' f = Concurrently (Ap f)

instance Concurrently (Free' f) (Free f) where
  runConcurrently (Free' a) = a
  runSequentially = Free'

getSite :: String -> Fraxl MyDataSource Site
getSite = ...
printSite :: Site -> Fraxl MyDataSource ()
printSite = ...

getSite' :: String -> Fraxl' MyDataSource Site
getSite' = runSequentially . getSite

example :: Fraxl MyDataSource ()
example = do
  (a,b) <- runConcurrently $ --       Concurrent
             (,) <$> getSite' "site1.com" <*> getSite' "site2.com"
  printSite a
  printSite b

Works wonderfully! We’ve encoded the same structure into example, without having to subvert the monad laws.

There are probably some other interesting instances of Concurrently. I don’t know what they are, but the pattern of having special applicatives isn’t new. Whatever the instances are, there are some cool consequences. Traversal, for instance, can become implicitly concurrent.

traverse' :: (Concurrently f m, Traversable t) => (a -> m b) -> t a -> m (t b)
traverse' f = runConcurrently . traverse (runSequentially . f)

for' :: (Concurrently f m, Traversable t) => t a -> (a -> m b) -> m (t b)
for' = flip traverse'

example = do
  ys <- for' xs $ \x -> do
    z <- f x
    y <- g z
    return y
  ...

You can write monadic code in for', and have each computation implicitly done in parallel. This largely accomplishes one of haxl’s motivating examples.


There are some problems though. The need for runConcurrently would likely be controversial. On one hand, it does make it slightly more verbose, and will mean you often duplicate combinators as I did with traverse'. On the other hand, it forces the concurrency of a computation to be documented at the type level. We know by looking at any expression (or its type) if it’s going to do things concurrently. This could save some headaches in debugging.

The biggest downside is losing implicit concurrency with ApplicativeDo. With ApplicativeDo enabled on the law-breaking version of fraxl, writing example like this yields some nice desugared code.

{-# LANGUAGE ApplicativeDo #-}

example :: Fraxl MyDataSource ()
example = do
  a <- getSite "site1.com"
  b <- getSite "site2.com"
  printSite a
  printSite b

-- Desugars to:

example :: Fraxl MyDataSource ()
example = join $ (\a b -> printSite a *> printSite b)
              <$> getSite "site1.com"
              <*> getSite "site2.com"

With the Concurrently version, the sugared do code might look a little worse, again depending on whether you prefer the concurrency to be explicit.

{-# LANGUAGE ApplicativeDo #-}

example :: Fraxl MyDataSource ()
example = do
  (a, b) <- runConcurrently $ do
    a <- getSite' "site1.com"
    b <- getSite' "site2.com"
    return (a, b)
  printSite a
  printSite b

-- Desugars to:

example :: Fraxl MyDataSource ()
example = runConcurrently
  ((,) <$> getSite' "site1.com" <*> getSite' "site2.com")
  >>= \(a, b) -> printSite a *> printSite b

Functionally, it’s the same result. The do block just had to be much more explicit about its concurrency. It’s unclear to me if this tradeoff is a good or bad one.

Pros:

  • Law-abiding with no tricks.
  • Explicit concurrency can make code easier to reason about.
  • Lets fraxl use an existing free monad instead of a custom one.

Cons

  • Explicit concurrency is more verbose.
  • Do notation gets in the way instead of being clearer.

I’m leaning towards calling it good, because I’m often a fan of being explicit about dirty tricks. But I can definitely see why someone wouldn’t like this approach.