danidiaz
danidiaz

Reputation: 27776

Is my concurrency monad a valid instance of MonadThrow?

I have a concurrency helper which is a slight wrapper over IO. For it, >>= is sequential like with vanilla IO, but >> executes its arguments concurrently.

I want to make this type an instance of MonadThrow (from the exceptions package). However, this law that the documentation says MonadThrow must satisfy gives me pause:

throwM e >> x = throwM e

This is not exactly the case with my monad. Since throwM e and x will execute concurrently, x can have effects in the outside world or even throw an exception of its own before throwM e interrupts the computation.

Can the law be interpreted in a "lax" manner, or should I refrain from writing the MonadThrow instance?

Edit. Here's the simplified code for my Monad:

import Control.Concurrent.Async(concurrently)

newtype ConcIO a = ConcIO { runConcIO :: IO a }

instance Monad ConcIO where
   return = ConcIO . return
   f >>= k = ConcIO $ runConcIO f >>= runConcIO . k
   f >> k = ConcIO $ fmap snd $ concurrently (runConcIO f) (runConcIO k)

Upvotes: 3

Views: 194

Answers (4)

danidiaz
danidiaz

Reputation: 27776

As the other answers to my question have explained, the real problem is that >> shouldn't be concurrent for the monad.

An additional reason for that, found after a bit of tinkering: a concurrent >> behaves oddly with respect to monad transformers.

For example, in this code the messages are printed at the same time:

main :: IO ()
main = runConcIO $ do
    ConcIO $ sleep 5 >> putStrLn "aaa"
    ConcIO $ sleep 5 >> putStrLn "bbb"
    ConcIO $ sleep 5 >> putStrLn "ccc"

But if we add a monad transformer layer, suddenly the messages start printing in sequence:

 main :: IO ()
 main = void $ runConcIO $ runExceptT $ do
     lift $ ConcIO $ sleep 5 >> putStrLn "aaa"
     lift $ ConcIO $ sleep 5 >> putStrLn "bbb"
     lift $ ConcIO $ sleep 5 >> putStrLn "ccc"

Interestingly, this doesn't happen with Applicative composition. If we define a concurrent Applicative instance for ConcIO, and compose it with Either, the three messages still get printed at the same time:

import Data.Functor.Compose

main :: IO ()
main = void $ runConcIO $ getCompose $  
    (Compose . ConcIO $ sleep 5 >> putStrLn "aaa" >> return (Left ())) *> 
    (Compose . ConcIO $ sleep 5 >> putStrLn "bbb" >> return (Right ())) *> 
    (Compose . ConcIO $ sleep 5 >> putStrLn "ccc" >> return (Right ()))

The reason seems to be that Applicative composition applies the effects layer by layer. First all the "concurrency effects" take place, and only afterwards the "failure" effects. In this context the concurrency makes sense.

Upvotes: 1

CR Drost
CR Drost

Reputation: 9817

One mental model that really helps me in thinking about Haskell's IO x is to mentally phrase it as "a program which contains an x (i.e. some internal representation isomorphic to a Haskell x)". Haskell builds programs but does not execute them; you only execute them when you actually run the program. The monad definition that a >> b be equivalent to a >>= \_ -> b says therefore that >> is in-sequence, full-stop. That is why MonadThrow assumes that throwM e >> x is the same as throwM e -- they "are the same program" because they are sequenced together and the throwM terminates execution of the former every time. So you're going to do something counterintuitive for a lot of people.

It'll probably be simpler to just define your own operator as a parallelism primitive. We really want an operator with a different signature anyway:

(>|<) :: IO a -> IO b -> IO (a, b)

This does not clobber the a but instead waits for it to complete, so that you can, say, issue two database requests and then wait until they both come back.

This suggests perhaps that what you want is some Applicative instance for IO (or, when Applicative superclasses IO, you'll want newtype PIO x = PIO {runPIO :: IO x} with the needed Applicative instance).

The only reason to override >> is that you want to write something like:

do 
    a <- beforeEverything
    thread1 a
    thread2 a
    thread3 a
    -- no afterEverything possible

but perhaps with the right Applicative we can instead say:

do
    a <- beforeEverything
    runParallel $ afterEverything <$> thread1 a <*> thread2 a <*> thread3 a

with a little trickery akin to what the >>= operator does (turning f x into x (operator) f) we can maybe put the afterEverything after its arguments and get the logical order that will keep us sane. The only price we'll pay is more indentations.

Upvotes: 4

Michael Snoyman
Michael Snoyman

Reputation: 31355

I've thought about this a bit. I don't think the MonadThrow instance is any worse than any other typeclass instance you can define based on that Monad instance. For example, what should the following code do?

liftIO $ putStrLn "Hello" >> error "foo"
liftIO $ putStrLn "World" >> error "bar"

I think most people would assume that the result of this would be to print "Hello" and then throw a UserError "foo". However, with your implementation of >>, you have a 50/50 shot whether that will happen (well, probably not quite so evenly divided, since the first thread will still be forked first, but you get the idea).

So I'd say: if you've accepted that the Monad instance isn't terrible, you may as well throw in the MonadThrow instance as well. I'm just not convinced the Monad instance itself makes sense.

On a related note, this reminds me of Simon Marlow's talk on haxl. They do something similar there, but instead of giving the concurrent behavior to >>, they give it to the Applicative instance. It might be worth considering that for your case as well, as at least there's prior art.

Upvotes: 2

John L
John L

Reputation: 28097

I don't really like that law, it seems like a rather poor shorthand way of describing the actual behavior that's required.

If you require that all monadic actions lifted into ConcIO be idempotent and interruptible, it should be fine. However, that restriction may be overly onerous, which would mean that you couldn't use ConcIO for the intended purpose.

Why not just use normal IO and define a small operator that calls concurrently? That would give you more control, as well as let you avoid concurrent calls when necessary.

Upvotes: 1

Related Questions