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.
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.MVarrunInBackground :: IO Int -> IO (MVar Int)runInBackground action = dobox <- newEmptyMVar_tid <- forkIO (action >>= putMVar box)pure boxmain :: IO ()main = dobox <- runInBackground (pure (expensiveComputation 100))result <- takeMVar boxprint 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 = dobox <- runInBackground (error "boom" >> pure 0)result <- takeMVar box -- blocks forever; the exception died silentlyprint 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 = dobox <- newEmptyMVar_tid <- forkIO (try action >>= putMVar box)pure box
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.Asyncmain :: IO ()main = doa <- async (pure (expensiveComputation 100))putStrLn "doing other work while a runs"result <- wait aprint 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 = doa <- async (error "boom")result <- wait aprint result
What that actually prints - an ordinary uncaught-exception exit, not a hang.
main: boomCallStack (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 acase result ofLeft 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 acase status ofNothing -> 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
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 = doa <- async longRunningTaskbody a -- if this throws, `a` is never cancelledcancel 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 -> dobody 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 $ doputStrLn "still alive"threadDelay 1_000_000doWork :: IO StringdoWork = dothreadDelay 3_000_000pure "done"main :: IO ()main = withAsync sendHeartbeats $ \_heartbeat -> doresult <- doWorkputStrLn result
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 fetchOrdersprint (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 = doresult <- 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 = dobodies <- mapConcurrently fetchUrl urlsmapM_ 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 boundedbodies <- mapConcurrently fetchUrl tenThousandUrls
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 = dooutcome <- race (fetchFromPrimary) (fetchFromReplica)case outcome ofLeft 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 StringreadThrough cacheLookup networkFetch = docached <- cacheLookupcase cached ofJust v -> pure vNothing -> networkFetch
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 = doresult <- timeout 2_000_000 slowNetworkCallcase result ofNothing -> 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 ResponsehandleRequest = doouter <- timeout 5_000_000 $ dodbResult <- timeout 1_000_000 runQuerycase dbResult ofNothing -> 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 *)
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 = doa <- async (threadDelay 1_000_000 >> error "late failure")putStrLn "this prints immediately"threadDelay 2_000_000putStrLn "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 = doa <- async criticalBackgroundTasklink aputStrLn "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 = doproducer <- async runProducerconsumer <- async runConsumerlink2 producer consumer_ <- wait producer_ <- wait consumerpure ()
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 ofJust (_ :: AsyncCancelled) -> throwIO e -- let cancellation throughNothing -> putStrLn ("recovered from: " <> show (e :: SomeException))
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.MVarimport Control.Exception (bracket_)newSemaphore :: Int -> IO (MVar ())newSemaphore n = dosem <- newMVar ()-- replicateM_ below fills it with n permits, one MVar () per slotpure 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' = newMVarwithPermit :: MVar Int -> IO a -> IO awithPermit sem = bracket_ acquire releasewhereacquire = modifyMVar_ sem $ \n ->if n > 0 then pure (n - 1) else retryUntilAvailable semrelease = modifyMVar_ sem (pure . (+ 1))retryUntilAvailable :: MVar Int -> IO IntretryUntilAvailable sem = dothreadDelay 1000n <- readMVar semif 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.QSemboundedMapConcurrently :: Int -> (a -> IO b) -> [a] -> IO [b]boundedMapConcurrently n f xs = dosem <- newQSem nmapConcurrently (\x -> withSem sem (f x)) xswherewithSem 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 = dobodies <- boundedMapConcurrently 4 fetchUrl thousandUrlsmapM_ (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 = dosem <- newQSem nlet 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.