The power of IO in Haskell
- by Alejandro Serrano
- June 15, 2020
- haskell• functional• functional programming
- 27 minutes to read.
IO is a hot topic these days in many FP communities. The
IO here refers to the idea of wrapping and tagging your effectful actions within a special type, with the goal of offering better control over its execution. This contrasts sharply with the usual leeway languages give to the programmer, in which you can perform side effects anywhere, at any point. In Haskell, this mode of operation is the default; there is no way to escape
IO if you need to perform side effects such as console, file, and network input/output, or random number generation, among others.
However, if one compares the documentation of
System.IO from Haskell’s
base library with more modern incarnations, such as Arrow Fx, Bow Effects, ZIO, and Cats Effect, Haskell seems quite underpowered in the
IO arena. Whereas the latter libraries provide streams, resource management, and queues out of the box, those are nowhere to be found in Haskell’s library. This is just a wrong impression, but an easy one to get: the Haskell ecosystem provides all of those, but spread across many different libraries. In this post, we look at several of them, grouped by their goal.
The idea of a separate
IO has permeated functional programming communities. Several of our courses at 47 Degrees Academy discuss this topic, including Haskell Fundamentals, Functional Programming with Arrow and Advanced Concurrency with Arrow Fx (for Kotlin), and Advanced Functional Programming with Cats and Functional Stream with fs2 (for Scala).
In addition to our courses, we routinely offer talks and webinars on
IO and related topics, many of them free! You can check our current schedule. These talks and webinars focus on more concrete topics around Functional Programming and its applications, and span from 1 to 4 hours.
Table of contents
- Concurrent fibers
- Communicating threads
- Resource management
- Scheduling and retry policies
- Data streaming
One fair question faced by many people the first time they see Haskell’s function to read the entire contents of a file,
readFile :: FilePath -> IO String, is, “What happens when the file does not exist?” Well, an exception is thrown. This answer is not that different from other languages, but it may come as a surprise due to Haskell’s usual explicitness on error handling. However, in the case of
IO, adding one additional layer of possible erroring to every function was deemed impractical. If it was the case, doing
IO would turn into a deeply nested matching:
do config <- readFile "config" case config of Left e -> -- error case Right config' -> do let db = database config conn <- openDbConnection db case conn of Left e -> -- error case Right conn' -> do ...
Exception handling is implemented in the
Control.Exception module. Its basic functions are to throw and catch exceptions:
throw :: Exception e => e -> a catch :: Exception e => IO a -> (e -> IO a) -> IO a
On the surface, these functions are not that surprising:
throw requires the exception to be thrown,
catch requires the happy path and a function to recover if an exception is found. If you dig further, there’s something slightly odd in that an
Exception type class is being used. This design allows catching different types of exceptions in the same code block:
do somethingWithFilesAndNumbers `catch` \(e :: IOException) -> -- handle IO problems `catch` \(e :: ArithException) -> -- handle division by zero
The code above shows the usual pattern when dealing with exceptions in Haskell: declare the type of exceptions you want to catch by using a type signature (to be able to write those, you need to enable the
Fibers, as they are known in Arrow (Kotlin), project Loom (Java), ZIO, or Cats Effect (both Scala), are
IO actions that are run concurrently by the runtime (fibers are also known as green threads. Most operating systems support multithreading, but OS-level threads tend to be quite heavy, hence the need for something a bit more lightweight.
Fortunately, GHC (the main Haskell compiler) uses fibers / green threads by default in the
Control.Concurrent module, unless one requests otherwise via
forkOS. The aforementioned module is the basis for concurrency in GHC, but provides a very low-level interface. On top of it, we find the
async package, an interface built upon the simple function
async :: IO a -> IO (Async a).
This function receives an
IO action, and spawns a new green thread to execute it. Not only that: the action as a whole can return a value, using the usual monadic interface, and we can query it later using the
wait function, or
cancel it altogether, stoping execution if it was not finished yet.
do compute_n <- async lengthyComputation r <- checkDb if r then do n <- wait compute_n updateDb n else cancel compute_n
Arrow Fx, ZIO, and Cats Effect provide the same interface, but with different names. Scala’s
Future is also quite close, although it does not provide the ability to interrupt the running computation.
|Arrow Fx / ZIO / Cats Effect||Haskell’s
Parallelization and races
One major reason for using lightweight green threads / fibers is to create many of them. Once created, you have two basic policies to gather their results: a) if you need every fiber’s result, you can wait for all of them to finish; or b) if you only need one fiber’s result to continue, you may just wait for the first one to finish, and then cancel all the others and dismiss their results. Both Arrow Fx and ZIO refer to those modes as parallelization and racing, respectively. In Haskell, we use different versions of
waitBoth :: Async a -> Async b -> IO (a, b) -- parallel waitEither :: Async a -> Async b -> IO (Either a b) -- race
Those functions are rarely used, though. The most common scenario is to create the different
Asyncs and, right after,
wait for them in one fashion or another. This pattern is embodied by the pair of functions:
concurrently :: IO a -> IO b -> IO (a, b) race :: IO a -> IO b -> IO (Either a b)
Alas, those two functions are specific to two threads. What if you want to race between three threads? Or even more, you want to execute one process concurrently with a race of two others? The
async library has a very ingenious solution: provide a data type to describe those scenarios, using the common
Alternative abstractions! The latter case we described is expressed as follows (thanks Harold for catching a bug in a previous version!):
runConcurrently $ (,) <$> Concurrently getInfoFromDb <*> (Concurrently computeLocally <|> Concurrently downloadFromCache)
One problem we may face when working with fibers is the premature termination of one of them, usually because of an exception. Other threads providing information to it, though, do not cease to execute. This means that we might have useless work being done. This problem is sometimes referred to as leaking fibers.
Inspired by Erlang’s awesome support for process management,
async introduces linking as a way to avoid this problem. You can either link another thread to the current thread, or two of them:
link :: Async a -> IO () link2 :: Async a -> Async b -> IO ()
The runtime ensures that, from now on, any exception thrown in one of them propagates to the other. Any unhandled exception in an
Async block, in turn, makes that thread stop. So in the simplest case in which we do not handle any exception, linking two threads means that one is cancelled whenever the other is. No more remaining useless threads.
Waiting for a thread to finish is a simple way to get information from it, but not the only one. GHC standard library contains a “one element box” called
MVar as a primitive for communication. However, I strongly suggest refraining from using it. The documentation itself points us towards the right tool:
[…] however they are very simple and susceptible to race conditions, deadlocks, or uncaught exceptions. Do not use them if you need perform larger atomic operations such as reading from multiple variables: use
STM stands for Software Transactional Memory. STM brings ideas from databases into usual variable handling. Instead of performing small simple operations over shared variables, you write a transaction script with one or more of those operations. The runtime then ensures that the entire script is executed atomically over a consistent world view. GHC was in fact one of the first compilers to provide runtime support for STM; others like Clojure make STM an integral part of their concurrency API. Cats STM provide similar functionality for the Scala ecosystem.
Take the following example in which we create a new transactional variable, or
TVar, and concurrently increase its value.
do v <- newTVar 0 concurrently (add1 v) (add1 v) where add1 v = atomically $ do n <- readTVar writeTVar (n+1)
The key part here is the function
atomically, which executes the script given as argument. The script itself does not have type
IO a, but
STM a. By using a different type, the language ensures that only a restricted set of operations can be performed in this block. Reading and writing to transactional variables are two examples of what is allowed. That block also shows a very common pattern: create a transactional variable in a “parent” thread and then pass its reference to each “child” thread as an argument.
Had we not had the consistency guarantees, this code could end up with the wrong value for variable
v: thread 1 reads value 0, thread 2 reads value 0, then both write value 1. STM prevents such a scenario, and we always obtain value 2 here, regardless of the order in which transactions are executed. How this is achieved is outside the scope of this article, but you should know that it relies on the ability to re-do computations. Haskell’s purity excels here; in other languages, users of STM must “promise” that they’ll never perform side effects here.
Variables are the simplest data structure that can be handled in a transactional fashion. Several libraries vastly extend this amount; here is a non exhaustive list of those.
||Variables which are either empty or full|
One may ask, “Why have a specific transactional map when one could go with
TVar (Map k v), that is, a variable that holds a map?” The reason is access to maps can be optimized in many cases, for example, if two scripts modify different keys. If we put everything in a
TVar, this optimization is not possible, since the runtime tracks the map as a whole.
Going further from what we understand as communication, Haskell’s STM implementation contains other functionality closer to database. In particular, we can declare that our transaction has found inconsistent information, or in general cannot proceed, by calling
retry. The runtime halts execution of that script, trying again once it detects some of the affected variables have changed.
Once exceptions and threads enter the game, resource managements becomes hard. If you open a file or a network connection, you want them to be closed regardless of whether using it was successful or not. However, the naïve code below:
do r <- acquireResource useResource r releaseResouce r
does not guarantee that: if
useResource throws an exception, then
releaseResource is never called, for example.
bracket :: IO a -- acquire -> (a -> IO b) -- release -> (a -> IO c) -- use -> IO c
Most libraries for handling resources often provide a variant of
bracket for those specific resources, usually prefixed by
with. For example,
withFile ensures that a file is correctly opened and closed; and
withPool does so for a pool.
There are two main caveats for using
- Each time you use
bracket, the rest of the code has to be indented one more level.
- Resources are always released in the reverse order in which they were acquired. In particular, this means that you cannot release a resource sooner if you are done with it; you have to wait until the end.
If you are only concerned with the first problem, the
managed package provides a nicer API on top of
bracket. In particular, what reads:
do bracket acquireResource1 releaseResource1 $ \r1 -> bracket acquireResource2 releaseResource2 $ \r2 -> use r1 r2
becomes the much nicer-looking, and devoid of nesting:
runManaged $ do r1 <- managed (bracket acquireResource1 releaseResource1) r2 <- managed (bracket acquireResource2 releaseResource2) liftIO $ use r1 r2
The trick here is the use of a new
Managed type that adds the
managed construct. The only downside is that you need to wrap the code that uses the resources in a call to
liftIO. As we shall see later,
liftIO is used when we want to run
IO actions in a monad which extends
IO, but is not
IO. Cats Effect and ZIO manage resources in a very similar fashion.
The solution to the second problem comes with the
resourcet package. The main function is
allocate, whose main difference with the combination of
bracket is that it returns both the resource and a so-called release key:
allocate :: IO a -- acquire -> (a -> IO ()) -- release -> m (ReleaseKey, a)
If you do nothing more, then the behavior is exactly as
bracket. But, with the release key, you may also call
release to deallocate a resource before the block ends. Note that, from that moment on, using the resource turns into a runtime error, since the compiler does not track the liveness of the resource.
Pools of resources
You may be wondering how to handle not a single resource, but a pool of resources. A pool gives access to a given amount of resources, which can be asked or returned to the pool. The typical use case are database connections: you don’t want to have one single connection for a whole application, but you don’t want each single thread to create its own connection either. Following the steps of
pool package provides a function that handles a maximum number of resources:
createPool :: IO a -- acquire -> (a -> IO ()) -- release -> Int -- max amount -> (Pool a -> IO b) -- use -> IO b
From that moment on, you can ask for a resource using
withPool. As you can see,
bracket is the bread and butter of resource management in functional programming.
Scheduling and retry policies
And once again you might be asking: where’s the implementation for Haskell? Fear not, the
retry package (which has been ported to Cats Effect) has your back! As in most implementations in other languages, the retry policy, that is, the way in which actions should be repeated if they fail, is separate from the actions themselves. The simplest policies are constant and exponential:
p1 = constantDelay 10000000 -- 1 second p2 = exponentialBackoff 10000000
Furthermore, you can combine policies, and make your own easily: the
retryPolicy function takes a history of the previous attempts, and returns whether the action should be attempted again, and in affirmative case after how many microseconds, by means of a
Maybe Int value.
You can apply one such policy in two different ways, depending on whether you want to deal with exceptions or not. When using
retrying, the question of whether to retry after one attempt is delegated to a function returning a Boolean.
retrying (constantDelay 1000000 <> limitRetries 3) (\_ n -> return $ n >= 0) -- retry if the number is negative (\_ -> computeNumber)
In this case, we have obviated the first argument to the retry check and the attempt; that represents the history of retries before the current one. In that way, the decision can be better informed.
The counterpart for exception-raising functions is
recovering. In this case, the function itself takes care of catching the exceptions in which you are interested, as you would go with
catch. But those handlers also need to decide whether the action should be retried. Let us write a version of the function above that also retries if an
ArithException is raised:
recovering (constantDelay 1000000 <> limitRetries 3) [\_ -> Handler $ \(e :: ArithException) -> return True] (\_ -> computeNumber)
If you are just interested in retrying regardless of the exception, the library provides a
Manually handling of reading or writing information at the right moment, while consuming a minimal amount of resources, is a challenge in itself. For awhile now, attention has shifted to (reactive) streams as a solution for handling incoming data in a functional fashion. These solutions are not only circunscribed to functional languages, ReactiveX or Reactor target several languages with a similar API for all of them. Arrow Fx, in fact, has decided to wrap those libraries instead of providing their own, a path followed by ZIO. FS2 is another implementation of functional streams in the Scala world, built on top of Cats Effect.
The Haskell community has produced a diversity of libraries in this area, although at this point,
pipes seem to be the most used. At the surface level, they are quite similar; I tend to use
conduit because at this moment there are more integrations with other libraries in the ecosystem.
conduit library, the basic building blocks are called, unsurprisingly, conduits. More precisely, a
ConduitM i o m r represents a stream that consumes values of type
i and produces values of type
o, using effects coming from
m. Conduits may also return a final value
r when the stream has finished; think for example of producing statistics when computation has finished. Let’s see some examples from the
-- reads ByteString from a file sourceFile :: FilePath -> ConduitT i ByteString IO () -- saves the incoming data in a file sinkFile :: FilePath -> ConduitT ByteString o IO ()
In addition to that module, the
conduit-extra library defines sources and sinks for other sorts of resources.
Many of the transformations share the same names as their list counterparts. For example,
map transforms incoming values and sends them as output, as hinted by the type:
map :: Monad m => (a -> b) -> ConduitT a b m ()
You can connect several conduits into a larger pipeline by using the
(.|) operator. Of course, input and outputs have to match for the compiler to accept it. For example, imagine that we had a
toUppercase operation that turns the letters
ByteString into its uppercase counterpart. Then the following block:
runConduitRes $ sourceFile "input" .| map toUppercase .| sinkFile "output"
input file and writes an uppercase version in
output. Note the use of
runConduitRes to execute the pipeline, until then the conduit is just a description of what ought to be done.
As mentioned above, the power of these libraries comes from the wide range of integrations readily available. In this case, you can open a file, transform it into JSON, put the value into a transactional queue, which is then read and sent to a RabbitMQ queue or a Kafka topic.
MonadIO and friends
The section about
Managed has already hinted about one important pattern for
IO actions in the Haskell community: monads, which are not
IO, but extend their abilities instead. Sometimes you also need to use transformers to add those new capabilities, such as logging. But of course, you want those operations that can be performed regardless of these extensions to be used as far as possible without changes. The trick is to use
MonadIO instead of the concrete
IO, as hinted by the following function in
print :: (Show a, MonadIO m) => ConduitT a o m ()
MonadIO defines a single operation called
liftIO, which executed an
IO action in the realm of the more capable monad. Since ending up with a monadic stack in which
IO is just a component is not that strange in big applications, the community has produced versions of many of the libraries discussed in this article generalized (“lifted”) to
Note that the story around generalizing
IO operations gets complicated pretty fast. Lifting something like printing to console is simple, but when errors and exceptions are involved, we need to use something more powerful than
MonadIO. The documentation for
unliftio, one of the simplest solutions, describes the problem and the design space quite well. This problem is in any case not so important to users of
IO actions, which can just use the lifted operations without having to know the technicalities around how they are lifted.
A fair question is: why is this not a problem in other libraries? Learning from Haskell’s mistakes, more modern libraries define most of the operations mentioned in this article using type classes from the start (check the documentation for Cats Effect and Arrow Fx). ZIO goes even further, using a completely different method of combining different effects.
The Haskell community has produced many interesting libraries for working with
IO actions. In many cases, though, the main features are scattered in different libraries, and sometimes it’s not clear where to look or how to choose between competing approaches. In contrast, more modern communities have produced all-in-one solutions in this space. My hope is that this article brings this fact to light, and shows that Haskell is well equiped for dealing with
And don’t forget to check our 47 Degrees Academy page for information about upcoming talks around Functional Programming and its applications, and courses to dive into
IO and many other facets of functional programming.
The next Haskell Fundamentals course is running July 6th through the 7th. We’re currently offering this course at a 50% discount due to the current global pandemic. There is limited space left available, so make sure to register asap to reserve your spot in Haskell Fundamentals.