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.
Sounds interesting?
The idea of a separate IO
has permeated functional programming communities. Several of our Academy courses discuss this topic, including Haskell Fundamentals, Functional Programming with Arrow.
In addition to our courses, we routinely offer talks and webinars on IO
and related topics, many of them free! 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
- Exceptions
- Concurrent fibers
- Communicating threads
- Resource management
- Scheduling and retry policies
- Data streaming
MonadIO
and friends
Exceptions
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 <code>catch
\(e :: IOException) -> -- handle IO problemscatch
\(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 ScopedTypeVariables
extension).
Concurrent fibers
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 async |
---|---|
fork | async |
join | wait |
cancel / interrupt | cancel |
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 wait
:
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 Async
s 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 Applicative
and 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)
Linking
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.
Communicating 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
instead.
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.
Name | Library | Description |
---|---|---|
TVar | stm | (Usual) variable |
TSem | stm | Semaphore |
TMVar | stm | Variables which are either empty or full |
TChan and TQueue | stm | FIFO queues |
T{B,M}Chan , T{B,M}Queue | stm and stm-chans | B = bounded, M = closeable |
TArray | stm | Arrays |
Map | stm-containers | Hash maps |
Set | stm-containers | Set |
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.
Resource management
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.
The main pattern to handle resource correctly is called the bracket
pattern, after the name of the function that implements it in Haskell, Arrow Fx, Bow, or Cats Effect.
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 bracket
, though:
- 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 managed
and 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 bracket
, the 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
A common feature across IO
incarnations is an API for scheduling or retrying actions. The documentation for Arrow Fx, ZIO, and Bow discuss this, in fact, as a main feature.
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 recoverAll
.
Data streaming
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, conduit
and 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.
In the 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 Combinators
module:
-- 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"
opens the 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 conduit
:
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 MonadIO
, like lifted-base
, lifted-async
, and stm-lifted
.
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.
Summary
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 IO
.
And don’t forget to check our Xebia Functional 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.