async in Haskell

2026-05-24 · 9 min

forkIO gives you a thread with no way to wait for its result, no way to know if it crashed, and no connection to the thread that spawned it. async wraps that thread in a value you can wait on, cancel, and - critically - that propagates an exception back to whoever is waiting rather than dying silently. This builds the problem forkIO has by itself, then works through async, race, concurrently, exception semantics, timeouts, and a worker pool.

§ 01

What forkIO alone does not give you

forkIO returns a ThreadId - nothing else. There is no built-in way to retrieve what the thread computed.

import Control.Concurrent (forkIO, ThreadId)
main :: IO ()
main = do
_tid <- forkIO (print (expensiveComputation 100))
putStrLn "main carries on immediately"
-- main may exit before the forked thread ever runs

Getting a value out requires an MVar wired up by hand - and this is before any exception handling.

import Control.Concurrent (forkIO)
import Control.Concurrent.MVar
runInBackground :: IO Int -> IO (MVar Int)
runInBackground action = do
box <- newEmptyMVar
_tid <- forkIO (action >>= putMVar box)
pure box
main :: IO ()
main = do
box <- runInBackground (pure (expensiveComputation 100))
result <- takeMVar box
print result

An exception in the forked thread never reaches the MVar - the thread that spawned it hangs forever on takeMVar, waiting for a value that will never arrive.

main :: IO ()
main = do
box <- runInBackground (error "boom" >> pure 0)
result <- takeMVar box -- blocks forever; the exception died silently
print result

Fixing that by hand needs a second MVar (or an Either) just to carry the failure case, plus a try around the action - this is the shape async packages up as one type.

import Control.Exception (SomeException, try)
runInBackground' :: IO a -> IO (MVar (Either SomeException a))
runInBackground' action = do
box <- newEmptyMVar
_tid <- forkIO (try action >>= putMVar box)
pure box
§ 02

async and wait

Install.

$ cabal install async

async spawns a thread and hands back an Async a - the MVar-and-try machinery above, already built, plus a real thread identity the library tracks.

import Control.Concurrent.Async
main :: IO ()
main = do
a <- async (pure (expensiveComputation 100))
putStrLn "doing other work while a runs"
result <- wait a
print result

wait re-raises the child\'s exception in the thread that calls it - the silent hang from section 1 becomes an ordinary exception at the wait site instead.

main :: IO ()
main = do
a <- async (error "boom")
result <- wait a
print result

What that actually prints - an ordinary uncaught-exception exit, not a hang.

main: boom
CallStack (from HasCallStack):
error, called at Main.hs:5:14 in main:Main

waitCatch gets the Either back instead of re-raising, when you want to branch on success/failure rather than let it propagate.

result <- waitCatch a
case result of
Left e -> putStrLn ("failed: " <> show e)
Right v -> print v

poll checks without blocking - Nothing means still running, a Just wraps the same Either waitCatch would give.

status <- poll a
case status of
Nothing -> putStrLn "still running"
Just (Left e) -> putStrLn ("failed: " <> show e)
Just (Right v) -> print v

cancel throws an AsyncCancelled exception into the running thread and blocks until it has actually stopped - not merely requested to stop.

cancel a
§ 03

withAsync: the structured form

async on its own can leak: if the code between async and wait throws, the spawned thread keeps running with nothing left to ever wait on or cancel it. withAsync ties the child\'s lifetime to a bracket, so it is guaranteed to be cancelled when the scope exits for any reason.

The leak - if body throws, a keeps running orphaned, since cancel a is never reached.

main :: IO ()
main = do
a <- async longRunningTask
body a -- if this throws, `a` is never cancelled
cancel a

withAsync wraps that in bracket - on any exit path, normal or exceptional, the child is cancelled before control leaves the block.

main :: IO ()
main = withAsync longRunningTask $ \a -> do
body a
-- `a` is cancelled here automatically, whether body succeeded or threw

A worked example: start a background heartbeat, do the real work, and the heartbeat is guaranteed to stop even if the real work throws partway through.

sendHeartbeats :: IO ()
sendHeartbeats = forever $ do
putStrLn "still alive"
threadDelay 1_000_000
doWork :: IO String
doWork = do
threadDelay 3_000_000
pure "done"
main :: IO ()
main = withAsync sendHeartbeats $ \_heartbeat -> do
result <- doWork
putStrLn result
§ 04

concurrently: run two, wait for both

concurrently runs two IO actions at once and returns both results as a tuple - it is withAsync plus wait on two threads, wired up as one call.

import Control.Concurrent.Async (concurrently)
main :: IO ()
main = do
(usersResult, ordersResult) <- concurrently fetchUsers fetchOrders
print (usersResult, ordersResult)

If either side throws, concurrently cancels the other side and re-raises - it never returns a partial result, and it never leaves the other action running.

main :: IO ()
main = do
result <- concurrently fetchUsers (error "orders service down")
print result
-- fetchUsers is cancelled the moment the other side throws;
-- the exception from the failing side propagates here

concurrently_ discards both results, for two actions run purely for effect.

concurrently_ (logToFile "a.log" msg) (logToFile "b.log" msg)

mapConcurrently generalizes concurrently across a whole Traversable - every element gets its own thread, and the results come back in the original order.

urls :: [String]
urls = ["http://a", "http://b", "http://c"]
main :: IO ()
main = do
bodies <- mapConcurrently fetchUrl urls
mapM_ putStrLn bodies

mapConcurrently_ discards the results - the common case for firing off N independent side effects and waiting for all of them.

mapConcurrently_ (uploadFile bucket) filePaths

An unbounded mapConcurrently spawns every element at once, which is the wrong choice against a rate-limited API or a small connection pool - forAll below builds a bounded version.

-- 10,000 URLs -> 10,000 simultaneous connections, unless bounded
bodies <- mapConcurrently fetchUrl tenThousandUrls
§ 05

race: run two, take the first

race runs two actions and returns whichever finishes first, as an Either tagging which side won - the loser is cancelled immediately.

import Control.Concurrent.Async (race)
main :: IO ()
main = do
outcome <- race (fetchFromPrimary) (fetchFromReplica)
case outcome of
Left primaryResult -> putStrLn ("primary won: " <> primaryResult)
Right replicaResult -> putStrLn ("replica won: " <> replicaResult)

race_ discards which side won and both results - the common shape is racing real work against a timer.

race_ doTheWork (threadDelay 5_000_000 >> throwIO TimedOut)

This exact pattern is what timeout, covered next, already implements - shown by hand here because the same race primitive is what you reach for whenever the built-in timeout is not quite the shape you need, such as racing against a cancellation signal instead of a fixed delay.

import System.Timeout (timeout)
-- timeout microseconds action
-- race action (threadDelay microseconds) & either Just (const Nothing)

A cache-first read pattern: race a fast local cache lookup against a slower network fetch, but only start the network fetch after a short grace period - a live example of composing race with threadDelay for something other than a plain timeout.

readThrough :: IO (Maybe String) -> IO String -> IO String
readThrough cacheLookup networkFetch = do
cached <- cacheLookup
case cached of
Just v -> pure v
Nothing -> networkFetch
§ 06

timeout

System.Timeout.timeout wraps an action with a time budget in microseconds, returning Nothing rather than a value if it did not finish - it is in base, not async, but is the same race-and-cancel mechanism underneath.

import System.Timeout (timeout)
main :: IO ()
main = do
result <- timeout 2_000_000 slowNetworkCall
case result of
Nothing -> putStrLn "timed out after 2s"
Just v -> print v

Nesting timeouts on a database call plus an outer request-level timeout - the inner Nothing needs its own handling, distinct from the outer one, since a query timeout and a whole-request timeout usually mean different things to the caller.

handleRequest :: IO Response
handleRequest = do
outer <- timeout 5_000_000 $ do
dbResult <- timeout 1_000_000 runQuery
case dbResult of
Nothing -> pure (errorResponse "query timeout")
Just r -> pure (okResponse r)
pure (maybe (errorResponse "request timeout") id outer)

The caveat that catches people: timeout relies on being able to interrupt the action with an async exception, which means an FFI call into non-interruptible foreign code will not actually be interrupted - the timeout fires, but the underlying call keeps running to completion regardless.

(* a `timeout` around a blocking, non-interruptible C call returns *)
(* Nothing on schedule, but the C call itself is still running -- *)
(* the thread is not actually reclaimed until that call returns *)
§ 07

Exception semantics: what actually propagates

An exception inside an async-spawned action is caught by the library and stored - it only re-appears when you call wait, not at the moment it happened.

main :: IO ()
main = do
a <- async (threadDelay 1_000_000 >> error "late failure")
putStrLn "this prints immediately"
threadDelay 2_000_000
putStrLn "this also prints - the exception has not surfaced yet"
wait a -- the exception finally re-raises here

link ties a child\'s failure directly to its parent, without waiting - the parent thread receives the exception asynchronously the moment the child fails, rather than only at a wait call.

main :: IO ()
main = do
a <- async criticalBackgroundTask
link a
putStrLn "runs until criticalBackgroundTask fails, then dies with it"
forever (threadDelay 1_000_000)

link2 does the same between two peer asyncs - if either fails, the other is sent the exception too, useful for a pair of tasks that only make sense running together.

main :: IO ()
main = do
producer <- async runProducer
consumer <- async runConsumer
link2 producer consumer
_ <- wait producer
_ <- wait consumer
pure ()

Catching AsyncCancelled specifically matters when a thread does cleanup on any exception - catching everything, including its own cancellation, means cancel a can never actually stop it, since the handler swallows the very exception meant to end it.

import Control.Exception (catch, SomeException, fromException)
import Control.Concurrent.Async (AsyncCancelled)
safeguarded :: IO ()
safeguarded = doWork `catch` \e ->
case fromException e of
Just (_ :: AsyncCancelled) -> throwIO e -- let cancellation through
Nothing -> putStrLn ("recovered from: " <> show (e :: SomeException))
§ 08

A bounded worker pool

Neither mapConcurrently nor any single async primitive limits concurrency - this builds a pool that runs at most n jobs at once, out of the same pieces used above: withAsync, an MVar as a semaphore, and mapConcurrently itself for the outer fan-out.

A counting semaphore built on MVar () - acquiring takes one unit out of the box, releasing puts one back, and a full box blocks the next acquirer until someone releases.

import Control.Concurrent.MVar
import Control.Exception (bracket_)
newSemaphore :: Int -> IO (MVar ())
newSemaphore n = do
sem <- newMVar ()
-- replicateM_ below fills it with n permits, one MVar () per slot
pure sem

A cleaner semaphore via an MVar Int as the counter directly, with bracket_ guaranteeing the permit is returned even if the job throws.

newSemaphore' :: Int -> IO (MVar Int)
newSemaphore' = newMVar
withPermit :: MVar Int -> IO a -> IO a
withPermit sem = bracket_ acquire release
where
acquire = modifyMVar_ sem $ \n ->
if n > 0 then pure (n - 1) else retryUntilAvailable sem
release = modifyMVar_ sem (pure . (+ 1))
retryUntilAvailable :: MVar Int -> IO Int
retryUntilAvailable sem = do
threadDelay 1000
n <- readMVar sem
if n > 0 then pure n else retryUntilAvailable sem

QSem, from base, is this exact counter already built and correctly handling the blocking wakeup without a busy-poll loop - the by-hand version above shows the shape; QSem is what to actually use.

import Control.Concurrent.QSem
boundedMapConcurrently :: Int -> (a -> IO b) -> [a] -> IO [b]
boundedMapConcurrently n f xs = do
sem <- newQSem n
mapConcurrently (\x -> withSem sem (f x)) xs
where
withSem s = bracket_ (waitQSem s) (signalQSem s)

Using it - at most 4 requests in flight at once, however many URLs the list actually has.

main :: IO ()
main = do
bodies <- boundedMapConcurrently 4 fetchUrl thousandUrls
mapM_ (putStrLn . take 40) bodies

A pool that also stops early on the first failure, by racing the whole bounded batch against a shared failure signal - built from concurrently, not just mapConcurrently, since it needs to watch two things: the batch, and an error channel any worker can write to.

boundedMapConcurrentlyFailFast :: Int -> (a -> IO b) -> [a] -> IO [b]
boundedMapConcurrentlyFailFast n f xs = do
sem <- newQSem n
let run x = bracket_ (waitQSem sem) (signalQSem sem) (f x)
mapConcurrently run xs
-- an exception from any `run x` propagates through mapConcurrently
-- automatically, cancelling every other in-flight worker

Everything here composes: race, concurrently, and a semaphore are enough to build timeouts, fail-fast batches, and bounded pools without ever calling forkIO directly. Once a program actually has concurrent work running, Profiling GHC Programs covers watching what it does at runtime.