– Tom Ellis, June 2026
The Haskell ecosystem contains several examples of
“reader-like”
operations that run in IO rather than in a specific “reader-like”
monad. Such operations necessarily have fragile behaviour when
performed in a forked thread, because Haskell does not yet have
suitable primitives with which to implement such operations robustly.
(For more information see Haskell’s missing mutable reference
type). This article catalogues
some examples.
IOThe reader-like operations in question are, firstly, an operation to
read an ambient state (analogous to ReaderT’s
ask,
and with a type like IO a) and, secondly, an operation to locally
modify the ambient state (analogous to ReaderT’s
local,
and with a higher order type like IO a -> IO a). There are two
approaches one can take to implement such operations.
This is an example of implementation strategy 1:
ambientState :: IORef StateType
ambientState = unsafePerformIO (newIORef initialValue)
ask :: IO StateType
ask = readIORef ambientState
local :: (StateType -> StateType) -> IO StateType -> IO StateType
local f body = do
-- Read the original value of the state
orig <- readIORef ambientState
bracket_
-- Modify the state to its new value
(modifyIORef f ambientState)
-- Restore the original value of the state
(writeIORef ambientState orig)
bodyImplementation strategy 1 leads to a particular type of fork-fragility, which I’ll call “Symptom 1”.
For example:
concurrently_
( do
...
local f $ do
...
...
)
( do
...
-- ask may see modification due
-- to `local f` in sibling thread
s <- ask
...
)(Haskell’s missing mutable reference type gives another example that exhibits Symptom 1.)
The second approach to implement reader-like operations in IO is:
This is an example of implementation strategy 2:
-- An ambient state *for each thread*
ambientState :: IORef (Map ThreadId StateType)
ambientState = unsafePerformIO (newIORef initialValue)
ask :: IO StateType
ask = do
m <- readIORef ambientState
t <- myThreadId
-- Look up the value of *this thread's* ambient state
pure (Map.lookup t m)
local :: (StateType -> StateType) -> IO StateType -> IO StateType
local f body = do
-- Read the original value of the state
orig <- readIORef ambientState
t <- myThreadId
bracket_
-- Modify the state to its new value, for *this thread* only
(modifyIORef (Map.adjust f t) ambientState)
-- Restore the original value of the state
(writeIORef ambientState orig)
bodyImplementation strategy 2 leads to a different type of fork-fragility, which I’ll call “Symptom 2”.
For example:
local g $ do
forkIO $ do
-- ask does not see the value
-- of the state set by g
s <- ask
...
...To implement reader-like operations in IO that are subject to
neither symptom, Haskell needs a new feature. A hypothetical such
feature is described in these articles:
In the absence of such a feature, reader-like operations in IO will
continue to exhibit fork-fragile behaviour. This article concludes
with a catalogue of some ecosystem examples of reader-like operations
which, inevitably, exhibit such behaviour.
withArgs and withProgName allow the programs arguments and
program name to be locally overridden.
baseInternal functions in testing libraries allow Handle buffering
setting to be locally overridden
QuickCheck, hspec-coreHandle
state for stdout and stderrwith-utf8 functions allow Handle default encoding setting to be
locally overridden
with-utf8Handle
state for stdin, stdout and stderr, and GHC’s
program-global locale encodingcontext is a library that allows arbitrary state to be locally overridden
contextctxStore is implemented with an IORef containing a
thread-indexed MapwithThreadContext
in
monad-logger-aeson
and Blammo,
to attach context to log messagesmodifyResponsesWithContext/modifyRequestsWithContext
in
context-http-client,
to apply a modification to all incoming requests or outgoing responsesStore ctx is identical to what IOScopedRef ctx would be, with
adjust corresponding to modifyIOScopedRef, except
IOScopedRef has the desired behaviour around access within a
new thread whilst Store does not (as it has no way to achieve
it with current GHC primitives).OpenTelemetry allows defining enclosing spans for blocks of code for improved observability
hs-opentelemetry-apiIORefInstana SDK allows defining enclosing spans for blocks of code for improved observability
instana-haskell-trace-sdkwithRootEntry :: MonadIO m => InstanaContext -> SpanType -> m a -> m awithEntry, withExit, …TVarThread-indexed logging settings allow logging settings to be locally overridden
heavy-loggerIORefGlobal IO logging settings allow logging settings to be locally overridden
logging, simple-logger, hslogger, nvim-hsString-indexed storage allows arbitrary data to be stored under a string key, and locally overriden
io-storageIORefhledger-web