Fork-fragile reader-like operations in Haskell
This article is part of a collection
- Haskell’s missing mutable reference type
- A reference implementation of
IOScopedRef - Fork-fragile reader-like operations in Haskell
Introduction
The Haskell ecosystem contains several examples of
“reader-like”
operations that run in IO rather than in a specific “reader-like”
monad. They 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.
Reader-like operations in IO
The 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.
- Store the ambient state in a mutable reference type, shared between all operations that interact with it. “Asking” for the current value of the ambient state amounts to reading the current value of the reference; “locally modifying” the ambient state amounts to updating the value of the reference when entering the local block and restoring the original value when leaving the local block.
This is an example of implementation strategy 1:
ambientState :: IORef StateType
ambientState = unsafePerformIO (newIORef initialValue)
{-# NOINLINE ambientState #-}
ask :: IO StateType
ask = readIORef ambientState
local :: (StateType -> StateType) -> IO r -> IO r
local f body = do
-- Read the original value of the state
orig <- readIORef ambientState
bracket_
-- Modify the state to its new value
(modifyIORef ambientState f)
-- 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”.
- Symptom 1: concurrent threads can observe the (supposedly) “local” modifications performed in other threads.
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:
- Store the ambient state in a mutable reference type, shared between all operations that interact with it within a given thread. “Asking” for the current value of the ambient state amounts to reading the current value of that thread’s reference; “locally modifying” the ambient state amounts to updating the value of the that thread’s reference.
This is an example of implementation strategy 2:
-- An ambient state *for each thread*
ambientState :: IORef (Map ThreadId StateType)
ambientState = unsafePerformIO (newIORef Map.empty)
{-# NOINLINE ambientState #-}
ask :: IO StateType
ask = do
m <- readIORef ambientState
t <- myThreadId
-- Look up the value of *this thread's* ambient state
pure (fromJust (Map.lookup t m))
local :: (StateType -> StateType) -> IO r -> IO r
local f body = do
-- Read the original value of the state
m <- readIORef ambientState
t <- myThreadId
let orig = fromJust (Map.lookup t m)
bracket_
-- Modify the state to its new value,
-- for *this thread* only
( atomicModifyIORef' ambientState $ \m' ->
(Map.insert t (f orig) m', ())
)
-- Restore the original value of the state,
-- for *this thread* only
( atomicModifyIORef' ambientState $ \m' ->
(Map.insert t orig m', ())
)
bodyImplementation strategy 2 leads to a different type of fork-fragility, which I’ll call “Symptom 2”.
- Symptom 2: Child threads created through standard thread-creation primitives do not automatically inherit the ambient state of the parent but rather must reinitialise it somehow (either manually or through using library-specific thread-creation primitives).
For example:
local g $ do
forkIO $ do
-- ask does not see the value
-- of the state set by g
s <- ask
...
...A potential solution
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, except in the special case of masking operations, inevitably
exhibit such behaviour.
Masking: fork-resilient reader-like operations
Control.Exception
has
mask_,
uninterruptibleMask_
and
interruptible
(each of type IO a -> IO a), reader-like operations which, in
effect, locally modify a value of type
MaskingState
within their body. Local modifications to the masking state in one
thread are not observed within other threads but the masking state
is inherited by child threads, so these operations are not
fork-fragile. How? They have a special implementation in GHC’s RTS.
Unfortunately, that implementation cannot currently be used as a way
to obtain user-defined local states.
Catalogue of fork-fragile reader-like operations
withArgsandwithProgNameallow the program’s arguments and program name to be locally overridden.- Package:
base - Local-like operations:
- Local state: Program arguments and program name
- Storage: RTS-wide argv table
- Fork-fragile symptom: Symptom 1
- Package:
withCurrentDirectoryallows the process’s current working directory to be locally overridden.- Package:
directory - Local-like operation:
- Local state: Current working directory
- Storage: Operating-system process-wide current working directory
- Fork-fragile symptom: Symptom 1
- Notes: The fork fragility of
withCurrentDirectoryis exemplified byghcid’s test suite.freshDirandcopyDirwrap tests inwithCurrentDirectory. This requiresghcid’s test suite to run single threaded, because with multiple threads, concurrent tests would overwrite each other’s current working directory.
- Package:
Internal functions in testing libraries allow
Handlebuffering setting to be locally overridden- Packages:
QuickCheck,hspec-core - Local-like operations:
- Local state: Current buffering mode for standard handles
- Storage: Mutable GHC
Handlestate forstdoutandstderr - Fork-fragile symptom: Symptom 1
- Packages:
with-utf8functions allowHandledefault encoding setting to be locally overridden- Package:
with-utf8 - Local-like operations:
- Local state: Current encoding for standard handles and default locale encoding
- Storage: Mutable GHC
Handlestate forstdin,stdoutandstderr, and GHC’s program-global locale encoding - Fork-fragile symptom: Symptom 1
- Package:
contextis a library that allows arbitrary state to be locally overridden- Package:
context - Local-like operation:
- Local state: User-defined value of type
ctx - Storage:
Storeis implemented with anIORefcontaining a thread-indexedMap - Used by:
withThreadContextinmonad-logger-aesonandBlammo, to attach context to log messagesmodifyResponsesWithContext/modifyRequestsWithContextincontext-http-client, to apply a modification to all incoming requests or outgoing responses
- Fork-fragile symptom: Symptom 2
- Notes:
Store ctxis identical to whatIOScopedRef ctxwould be (withadjustcorresponding tomodifyIOScopedRefandusecorresponding toreadIOScopedRef) except thatIOScopedRefwould have the desired behaviour around access within a new thread whilstStoredoes not (and cannot, with current GHC primitives).
- Package:
OpenTelemetry allows defining enclosing spans for blocks of code for improved observability
- Package:
hs-opentelemetry-api - Local-like operation:
- Local state: Current span stack and context
- Storage: Per-thread
IORef - Fork-fragile symptom: Symptom 2
- Package:
Instana SDK allows defining enclosing spans for blocks of code for improved observability
- Package:
instana-haskell-trace-sdk - Local-like operation:
withRootEntry :: MonadIO m => InstanaContext -> SpanType -> m a -> m awithEntry,withExit, …
- Local state: Current tracing span stack
- Storage: Shared thread-indexed
TVar - Fork-fragile symptom: Symptom 2
- Package:
Thread-indexed logging settings allow logging settings to be locally overridden
- Package:
heavy-logger - Local-like operation:
- Local state: Current logger, backend and log context
- Storage: Global thread-indexed
IORef - Fork-fragile symptom: Symptom 2
- Package:
Global IO logging settings allow logging settings to be locally overridden
- Packages:
logging,simple-logger,hslogger,nvim-hs - Local-like operations:
- Local state: Current global logger set, log level, time format etc.
- Storage: Top-level mutable state
- Fork-fragile symptom: Symptom 1
- Notes: These reader-like operations are “supposed to” be only used at the top level of your program, but nothing enforces that
- Packages:
String-indexed storage allows arbitrary data to be stored under a string key, and locally overridden
- Package:
io-storage - Local-like operation:
- Local state: Current named dynamic store
- Storage: Top-level
IORef - Fork-fragile symptom: Symptom 1
- Notes: Formerly used by
hledger-web
- Package: