Haskell (GHC IO Manager, blockio-uring)
Two layers of Haskell async I/O: the threaded RTS owns the event loop and parks cheap green threads on epoll/kqueue readiness (the MIO event manager) — a "runtime owns the loop" model like Go — while blockio-uring is a separate, batching-only io_uring binding for block-device I/O, with no io_uring in the GHC RTS by default.
| Field | Value |
|---|---|
| Language | Haskell (GHC; blockio-uring tested with GHC 9.2 – 9.14) |
| License | GHC RTS / base: BSD-3-Clause · blockio-uring: BSD-3-Clause (© Well-Typed LLP 2022–2025) |
| Repository | well-typed/blockio-uring · GHC base/RTS GHC |
| Documentation | GHC.Event (base) · blockio-uring on Hackage |
| Key Authors | RTS event manager: Bryan O'Sullivan & Johan Tibell (2010); MIO multicore manager: Andreas Voellmy et al. (2013); Simon Marlow (RTS scheduler) · blockio-uring: Duncan Coutts & Joris Dral (Well-Typed) |
| Pattern | RTS IO manager: Reactor (readiness: epoll/kqueue) parking green threads · blockio-uring: Proactor (io_uring completions) batching library, no suspension of its own |
Scope & the two-layer story. Haskell's standard concurrency story is the first layer: the threaded RTS IO manager, an
epoll/kqueuereactor baked into the runtime that lets you write blocking-looking socket code on millions of cheap green threads. That is the analogue of Go's netpoller — the runtime owns the loop and the application never sees it. The second layer,blockio-uring, is a separate library, not part of the RTS: a thinio_uringbinding for batched block-device reads/writes (the storage engine under Well-Typed'slsm-tree). It is a batching mechanism with no fiber/continuation machinery of its own — a higher-level scheduler (inblockio-uring's case, ordinary GHC green threads plus a per-capability completion thread) drives it. Crucially, GHC's RTS has noio_uringbackend by default; that remains an in-progress proposal (see § io_uring in the RTS). Allblockio-uringpaths below are relative to the well-typed/blockio-uring repo at tagblockio-uring-0.2.0.0.
Overview
What it solves
A Haskell program built with the threaded RTS (-threaded) runs a few OS threads ("capabilities", -N) and multiplexes potentially millions of lightweight Haskell green threads onto them. When a green thread does a socket recv, the runtime does not block the underlying OS thread: it registers the fd with an in-RTS IO manager and parks the green thread, freeing the capability to run other work. When the kernel reports the fd readable, the IO manager wakes the green thread and it resumes as if the recv had simply returned. The programmer writes ordinary direct-style, blocking-looking code; the runtime turns it into event-loop-class scalability behind the curtain. This is the same philosophy documented for the Go runtime netpoller and contrasts with the explicit user-facing loops of Tokio or libuv.
That model is excellent for sockets — which have a natural readiness signal — but weak for regular-file I/O, which has no readiness model: a disk read is either pending or done. The RTS IO manager therefore handles file I/O by handing the blocking syscall to a separate OS thread (a "safe" FFI call), which does not scale to the tens of thousands of concurrent random reads a modern NVMe SSD can absorb. blockio-uring fills exactly that gap: it uses Linux io_uring to submit batches of block reads/writes and reap their completions, letting a single program saturate an SSD's queue depth — the library's own benchmark reaches ~92% of fio's IOPS (see § Performance).
Design philosophy
- The runtime owns the loop (layer 1). The application never instantiates an event loop.
threadWaitRead/threadWaitWriteand every networking primitive are built on the RTS IO manager; green-thread park/unpark is the only mechanism the user sees, and only indirectly. blockio-uringis a batching library, not a runtime (layer 2). It deliberately has no scheduler, no fiber, no continuation capture. ItssubmitIOblocks the calling green thread (via anMVar) while a dedicated completion thread reaps CQEs; concurrency comes from the caller spawning many green threads, each submitting a batch. The RTS IO manager (layer 1) is what keeps those callers cheap. A higher-level scheduler — in practice the storage enginelsm-tree— drives it.- Per-capability isolation. To avoid lock contention,
blockio-uringcreates one independentio_uringper GHC capability (IOCtxis a vector ofIOCapCtx), so threads on different capabilities never share a submission queue. - No
io_uringin the RTS. Disk batching viaio_uringis opt-in through a library; the runtime's own IO manager stays onepoll/kqueue. Anio_uringRTS backend has been a long-term work-in-progress for years but is not in mainline GHC.
Layer 1 — the threaded RTS IO manager (MIO / GHC.Event)
The reactor inside the runtime
GHC's threaded RTS ships a portable, readiness-based IO manager. Its public surface lives in the GHC.Event module of base, which exposes an EventManager and a TimerManager, plus the registration API registerFd, unregisterFd, closeFd, and the timer API registerTimeout/updateTimeout/unregisterTimeout (GHC.Event docs). Interest is expressed with the Event type (evtRead = "data available", evtWrite = "ready to write") and a Lifetime of OneShot or MultiShot. The platform backend is chosen at build time:
| Platform | RTS IO-manager backend | Kernel mechanism |
|---|---|---|
| Linux | GHC.Event.EPoll | epoll |
| BSD / macOS | GHC.Event.KQueue | kqueue |
| other POSIX | GHC.Event.Poll | poll |
| Windows | WinIO (GHC.Event.Windows) | IOCP |
This is a Reactor: register interest, wait for readiness, then perform the (non-blocking) syscall — the same shape as the Go netpoller and the JDK's selector. For the general "suspend on I/O, resume on its event" round-trip that this implements, see Effects & event loops.
How a green thread parks and is woken
The user-facing primitives are threadWaitRead / threadWaitWrite (and the higher-level networking library built on them). The flow for a socket read on the threaded RTS:
- The green thread calls a non-blocking
recv; the fd returnsEAGAIN. - It calls
threadWaitRead fd, whichregisterFds interest inevtReadwith theEventManagerand blocks the green thread (anMVar-style wait), yielding the capability to the scheduler. The OS thread is not blocked. - The IO manager's loop sits in
epoll_wait/kqueue. Whenfdbecomes readable it delivers the registeredIOCallback, which wakes the parked green thread by completing its wait. - The scheduler re-runs the green thread; it retries
recv, which now succeeds.
The key property is the same as Go's: one OS thread can carry thousands of parked green threads, because parking is a cheap heap operation, not an OS-thread block.
MIO: making the IO manager multicore
The original single-threaded event manager ("Scalable I/O Event Handling for GHC", O'Sullivan & Tibell, Haskell Symposium 2010 paper) had a single dispatcher thread, which became a scaling bottleneck on multicore. MIO ("Mio: A High-Performance Multicore IO Manager for GHC", Voellmy et al., Haskell Symposium 2013 paper) replaced it with one event manager per capability, each with its own epoll/kqueue instance, plus a separate timer manager. The paper reports scaling to 40+ cores and >20M requests/s, ~6.5× the throughput of the prior design. MIO is the IO manager in every modern threaded-RTS GHC; this is why the -N capability count matters for network server throughput. Notably, blockio-uring mirrors MIO's per-capability structure for its own io_uring contexts (layer 2) for the same anti-contention reason.
File I/O is the weak spot
Regular files have no readiness model, so the RTS IO manager cannot park a green thread on a file read. Instead, file reads/writes go through a "safe" FFI call, which the RTS runs on a separate OS thread so it does not block the capability. This is correct but does not scale to high-queue-depth random I/O: each in-flight read costs an OS thread. This is exactly the gap blockio-uring was built to close — and it is the Haskell analogue of the filesystem-pinning problem that motivates io_uring file backends elsewhere (Java's JUringBlocking + Loom).
Layer 2 — blockio-uring
Source:
well-typed/blockio-uring, version0.2.0.0(2026-04-30).blockio-uring.cabaldeclarestested-with: GHC ==9.2 || ... || ==9.14,pkgconfig-depends: liburing >=2.0 && <3(the README requires liburing ≥ 2.1; the>=2.0floor is a documented workaround for liburing-2.1 shipping2.0in its.pcfile), andlicense: BSD-3-Clause,author: Duncan Coutts. It is the Linuxio_uringbackend of the higher-levelblockiopackage, the storage layer underlsm-tree(developed by Well-Typed for the Cardano Development Foundation / Intersect).
What it is — and what it is not
blockio-uring is a library for submitting batches of asynchronous disk I/O. The .cabal synopsis is "Perform batches of asynchronous disk IO operations", and the description is explicit about the limits: "It only supports disk operations, not socket operations" and "only supports recent versions of Linux, because it uses the io_uring kernel API." Within blockio, this is the real Linux implementation; on Windows/macOS blockio falls back to performing each operation sequentially (blockio Hackage).
It is not an event loop, a fiber runtime, or a continuation system. It has no Suspend effect, no captured continuations, no scheduler. The only "suspension" is an ordinary takeMVar on the calling green thread — which is cheap precisely because layer 1 (the RTS IO manager + green threads) makes blocking a green thread free. The library batches and reaps; the caller (or a higher-level engine like lsm-tree) provides the concurrency by spawning many submitting green threads.
Core abstractions and types
The public API is small (src/System/IO/BlockIO.hs, module export list lines 4–20): IOCtx, IOCtxParams/defaultIOCtxParams, withIOCtx/initIOCtx/closeIOCtx, submitIO, the operation type IOOp (constructors IOOpRead, IOOpWrite), and the result type IOResult (patterns IOResult, IOError).
IOCtx — per-capability fan-out. The headline structural decision:
-- src/System/IO/BlockIO.hs
-- | IO context: a handle used by threads submitting IO batches.
--
-- Internally, each GHC capability in the program creates its own independent IO
-- context. This means that each capability can process batches of I/O
-- operations independently. As such, running with more capabilities can
-- increase throughput.
newtype IOCtx = IOCtx (V.Vector IOCapCtx) -- one per RTS capability.initIOCtx calls getNumCapabilities and builds one IOCapCtx per capability (V.generateM ncaps (initIOCapCtx ...)). It refuses to run on the non-threaded RTS: unless hostIsThreaded $ throwIO rtsNotThreaded ("make sure you are passing the -threaded flag"). The per-capability IOCapCtx bundles the contention-control and communication state:
-- src/System/IO/BlockIO.hs
data IOCapCtx = IOCapCtx {
ioctxBatchSizeLimit' :: !Int, -- max ops processed as one sub-batch
ioctxQSemN :: !QSemN, -- concurrency limit (reserve right to submit)
ioctxURing :: !(MVar (Maybe URing.URing)), -- writer lock on the SQ
ioctxChanIOBatch :: !(Chan IOBatch), -- writers -> completion thread
ioctxChanIOBatchIx :: !(Chan IOBatchIx),-- completion thread -> writers (free batch ix)
ioctxCloseSync :: !(MVar ()) -- shutdown rendezvous
}Each IOCapCtx owns one URing and starts one completion thread, pinned to its capability with forkOn capno and labelled "System.IO.BlockIO.completionThread (for cap N)". So with -N4 there are 4 independent rings and 4 completion threads — no cross-capability sharing of a submission queue.
IOOp — the operation, with a pinned-buffer contract.
-- src/System/IO/BlockIO.hs
-- | The 'MutableByteArray' buffers within __must__ be pinned. Addresses into
-- these buffers are passed to @io_uring@, and the buffers must therefore not be
-- moved around. 'submitIO' will check that buffers are pinned ...
data IOOp s = IOOpRead !Fd !FileOffset !(MutableByteArray s) !Int !ByteCount
| IOOpWrite !Fd !FileOffset !(MutableByteArray s) !Int !ByteCountBecause GHC's GC can move heap objects, buffers handed to the kernel must be pinned; submitIO enforces this with guardPinned (throwing InvalidArgument "MutableByteArray is unpinned" otherwise). The op records the fd, file offset, buffer + offset, and byte count.
IOResult — errors in-band, not as exceptions. Defined in src/System/IO/BlockIO/URing.hs as a newtype IOResult = IOResult_ Int with two bidirectional pattern synonyms and a {-# COMPLETE IOResult, IOError #-} pragma:
-- src/System/IO/BlockIO/URing.hs
pattern IOResult :: ByteCount -> IOResult -- non-negative: bytes transferred
pattern IOError :: Errno -> IOResult -- negative: -errno from the CQE
viewIOResult (IOResult_ c) | c >= 0 = Just (fromIntegral c) | otherwise = Nothing
viewIOError (IOResult_ e) | e < 0 = Just (Errno (fromIntegral e)) | otherwise = NothingThis mirrors the io_uring CQE convention exactly: cqe.res is bytes-transferred when ≥ 0 and -errno when < 0. submitIO's docstring stresses that "Any I/O errors are reported in the result list, not as IO exceptions" — each op's outcome is one IOResult in the returned vector, positionally matched to the input batch.
IOOpId — the SQE↔CQE tag. A newtype IOOpId = IOOpId Word64, the user-data carried through io_uring. blockio-uring packs two indices into it (packIOOpId :: IOBatchIx -> IOOpIx -> IOOpId): the high 32 bits are the batch index, the low 32 bits the operation's index within its batch. On completion unpackIOOpId recovers the pair, which is how the completion thread routes a CQE back to the right slot. This is the direct analogue of the io_job user-data tag in Eio's io_uring backend — except here the payload is two integers indexing tracking arrays, not a captured continuation.
The URing binding — submit / await / completion polling
src/System/IO/BlockIO/URing.hs is the thin liburing wrapper. It exposes exactly three submission primitives and one completion primitive, and the FFI layer (src/System/IO/BlockIO/URingFFI.hsc) binds only the handful of liburing symbols they need.
Setup. setupURing calls io_uring_queue_init_params with just one flag — IORING_SETUP_CQSIZE — to size the completion ring; it sets no SQPOLL, no DEFER_TASKRUN, no SINGLE_ISSUER:
-- src/System/IO/BlockIO/URing.hs (setupURing, abridged)
flags = FFI.iORING_SETUP_CQSIZE
params = FFI.URingParams { sq_entries = 0, cq_entries = fromIntegral sizeCQRing,
flags = flags, features = 0 }(URingFFI.hsc zeroes the rest of struct io_uring_params with fillBytes before poking the four fields it models — sq_entries, cq_entries, flags, features.)
Submission. Each prepare* grabs an SQE with io_uring_get_sqe (throwing "URing I/O queue full" if it returns NULL), fills it, and attaches the IOOpId as user-data via io_uring_sqe_set_data:
-- src/System/IO/BlockIO/URing.hs
prepareRead URing {uringptr} fd off buf len (IOOpId ioopid) = do
sqeptr <- throwErrResIfNull "prepareRead" fullErrorType "URing I/O queue full" $
FFI.io_uring_get_sqe uringptr
FFI.io_uring_prep_read sqeptr fd buf (fromIntegral len) (fromIntegral off)
FFI.io_uring_sqe_set_data sqeptr (fromIntegral ioopid)submitIO :: URing -> IO () then flushes the accumulated SQEs with oneio_uring_submit (one syscall per batch), retrying on EINTR.
Completion polling. awaitIO is the heart of the reaping path, and its FFI choice is what makes it cooperate with the RTS IO manager:
-- src/System/IO/BlockIO/URing.hs (awaitIO, abridged)
peekres <- FFI.io_uring_peek_cqe uringptr cqeptrptr -- unsafe FFI: non-blocking
when (peekres /= 0) $
if Errno (-peekres) == eAGAIN
then throwErrnoResIfNegRetry_ "awaitIO (blocking)" $
FFI.io_uring_wait_cqe uringptr cqeptrptr -- safe FFI: may block this OS thread
else throwIO ...
cqeptr <- peek cqeptrptr
FFI.URingCQE { cqe_data, cqe_res } <- peek cqeptr
FFI.io_uring_cqe_seen uringptr cqeptr
return $! IOCompletion (IOOpId (fromIntegral cqe_data)) (IOResult_ (fromIntegral cqe_res))The two-step design is deliberate (and commented in the source):
io_uring_peek_cqeis imported as anunsafeFFI call — cheap, non-blocking, used first to drain already-available completions without paying the safe-call cost.io_uring_wait_cqeis imported as asafeFFI call — when nothing is ready (EAGAIN), the completion thread blocks here. A safe call lets the RTS move that OS thread out of the capability so other green threads keep running while this completion thread sleeps in the kernel.
awaitIO's comment also explains a subtle perf hack: it uses unsafeForeignPtrToPtr/touchForeignPtr instead of withForeignPtr so GHC's CPR analysis returns the IOCompletion in registers rather than the heap. The FFI module (URingFFI.hsc) imports io_uring_wait_cqe as capi safe and io_uring_peek_cqe / io_uring_cqe_seen / io_uring_get_sqe / the prep_* helpers as capi unsafe, matching that fast/slow split.
Which io_uring ops it uses
blockio-uring uses a deliberately minimal opcode set — block reads, block writes, and a shutdown no-op. There are exactly three prepare* functions and three corresponding FFI imports:
URing primitive | liburing helper (URingFFI.hsc) | io_uring opcode | Used for |
|---|---|---|---|
prepareRead | io_uring_prep_read | IORING_OP_READ | a block read (IOOpRead) |
prepareWrite | io_uring_prep_write | IORING_OP_WRITE | a block write (IOOpWrite) |
prepareNop | io_uring_prep_nop | IORING_OP_NOP | shutdown sentinel (IOOpId maxBound) |
That is the entire op surface. There is no READV/WRITEV, no *_FIXED registered-buffer variant, no OPENAT/STATX/ACCEPT/CONNECT/SEND/RECV, and no ASYNC_CANCEL — unlike the broad opcode tables of Eio or JUring. The library buys its performance from batching plain reads/writes, not from the exotic opcodes. For the full opcode catalogue and version gating, see the io_uring opcode reference.
Version gating and fallback
blockio-uring's version gating is far simpler than a full proactor runtime's, because its op set is so small that everything it uses (READ/WRITE/NOP, IORING_SETUP_CQSIZE) has been available since the earliest io_uring (Linux ≥ 5.1; the README states liburing ≥ 2.1 and recent Linux). There are only two conditional paths:
io_uring_set_iowait(liburing ≥ 2.10, kernel ≥ 6.15). New in0.2.0.0, theioctxIOWaitMetricsflag toggles IOWAIT accounting. The FFI binding is guarded with aCPP#ifdef IORING_FEAT_NO_IOWAIT; when the symbol is absent the stub returnsEOPNOTSUPP, andsetIOWaitswallows that viacallIfSupported_— so it is a no-op on older liburing, not a build error.io_uring_sqe_set_datavs_set_data64.URingFFI.hscpicks the 64-bitio_uring_sqe_set_data64when#ifdef LIBURING_HAVE_DATA64, else pokesstruct io_uring_sqe.user_datadirectly.
There is no in-library fallback to epoll/threadpool the way Eio falls back to a posix reactor: if io_uring (or the threaded RTS) is unavailable, initIOCtx simply throws. The fallback lives one layer up, in the blockio package, whose non-Linux backend performs each op sequentially.
How it works — submission/completion flow
Putting layer-2 together, a submitIO ctx ops call flows as follows. The most important detail is that all of submitIO runs in a fresh bound thread — runInBoundThread — a 0.2.0.0 bug fix: submitting from an unbound green thread that the RTS might reschedule across capabilities could yield EFAULT (CHANGELOG issue #58):
Pick the capability's ring.
submitIOfinds the current capability withmyThreadId/threadCapabilityand selectscapctxs V.! (capnomodlength). Using the same capability's ring is "more performant" but not required for correctness (source comment).Reserve concurrency.
prepAndSubmitIOBatchacquiresiobatchOpCounttokens from the per-capQSemN(waitQSemN), blocking the calling green thread if theioctxConcurrencyLimitwould be exceeded — this is the back-pressure mechanism. It then pulls a freeIOBatchIxfromioctxChanIOBatchIx. Async-exception safety is handled withmask_+onException undoAcquisition.Split oversized batches. A batch larger than
ioctxBatchSizeLimit(default 64) is split into sub-batches of at most that size (V.take/V.droploop); each becomes one ring submission. (submitIOitself imposes no limit on the caller's batch size.)Prepare + submit SQEs. Holding the per-cap
ioctxURingMVar(the writer lock),V.iforM_over the batch callsguardPinnedthenprepareRead/prepareWritefor each op, tagging each SQE withpackIOOpId batchIx opIx. OneURing.submitIOflushes the whole sub-batch to the kernel in a singleio_uring_submitsyscall.Hand off to the completion thread. Write an
IOBatchrecord (carrying the batch ix, op count, the completionMVar, andiobatchKeepAlives= the originalIOOpvector kept live so the GC won't collect the buffers the kernel is writing into) ontoioctxChanIOBatch.Block the caller. The submitting green thread does
takeMVar iobatchCompletion, parking until its whole batch is done. (Layer 1 makes this cheap.)Reap (the completion thread). The per-cap
completionThreadloops onURing.awaitIO, decoding oneIOCompletion (IOOpId, IOResult)at a time. It maintains four arrays indexed byIOBatchIx—counts,results,completions,keepAlives. For each completion itunpackIOOpIds to(batchIx, opIx), writes theIOResultinto the batch's result array, decrements the remainingcount, and when the count hits zero freezes the result vector,putMVars it into the batch's completion var (waking the submitter at step 6), releases theIOBatchIx, andsignalQSemNs the reserved tokens back. A count of-1for an arriving completion means "new batch": it reads pendingIOBatchrecords offioctxChanIOBatchuntil it finds the needed index.Shutdown.
closeIOCapCtxsubmits aprepareNoptaggedIOOpId maxBound; the completion thread treatsmaxBoundas the stop sentinel and exits.
So the "scheduler" here is just GHC's green threads + one completion thread per ring. There are no continuations: the suspension at step 6 is an MVar wait, woken by a putMVar at step 7. Contrast Eio, where the CQE resumes a captured one-shot continuation; in blockio-uring the CQE just fills in an array slot and eventually completes an MVar.
io_uring in the RTS (still a proposal)
There is no io_uring backend in the GHC RTS IO manager as of GHC 9.14. The runtime's networking still uses epoll/kqueue (MIO). Adding an io_uring RTS backend is a long-term effort led by Duncan Coutts at Well-Typed: as the Well-Typed GHC activities report (Mar–May 2024) puts it, "Duncan is gradually working on a long-term project to introduce a new RTS I/O manager based on the io_uring Linux kernel system call interface," and the recent work has been preparatory refactoring of the RTS IO-manager code to make multiple managers selectable at startup — not the io_uring integration itself. The tracking issue (GHC #18390) and the draft implementation remain unmerged. So today, the only production io_uring in Haskell is library-level (blockio-uring), and it is for block I/O only — there is no io_uring-backed socket path in standard GHC.
For the broader question of where suspension/resumption meets the kernel across runtimes — including how Haskell's green-thread parking compares to algebraic-effect continuations and Future/poll models — see Effects & event loops and the effect-library neighbours haskell-eff and haskell-effectful, which build typed effect layers atop exactly the IO monad that the RTS IO manager and blockio-uring operate in. Algebraic-effect handlers like Koka's are an alternative way to express the same "suspend here, resume on completion" structure that Haskell achieves with green threads + MVars.
Performance approach
- Batching amortises syscalls (layer 2). Each
blockio-uringsub-batch is oneio_uring_submitfor up toioctxBatchSizeLimit(default 64) ops; reaping drains CQEs with cheap non-blockingio_uring_peek_cqeand only falls back to the blockingio_uring_wait_cqewhen idle. The README reports the high-level Haskell API reaching ~215k IOPS vsfio's ~234k on a 4 KiB random-read workload — about 92% of thefiobaseline (and matchingfioon some machines). - Concurrency over deep queues. The benchmark (
benchmark/Bench.hs) spawns4 × ncapsgreen-thread tasks (Async.asyncOn), each repeatedlysubmitIO-ing 32-op batches, keeping many batches in flight. The README notes the high-level benchmark beats the low-level one precisely because lightweight Haskell threads keep more I/O in flight even on a single OS thread. - Per-capability rings remove contention (layer 2). One ring + one completion thread per capability means submitting threads on different capabilities never contend on a shared SQ
MVar— the same anti-contention idea MIO applied to the RTS IO manager (layer 1). - Near-zero steady-state allocation. The benchmark reports ~95 bytes allocated per I/O op; reusable pinned
MutableByteArraybuffers and reusedIOOpvectors (mkGenerateIOOpsBatch) keep the hot path allocation-light, andawaitIO's register-return trick avoids heap-allocating each completion. - Green-thread parking is cheap (layer 1). Both the socket reactor and
blockio-uring'stakeMVar-based waiting rely on the RTS making a parked green thread far cheaper than a blocked OS thread — the foundation of the whole "blocking-looking, actually-async" model. unsafevssafeFFI split. Non-blocking hot calls (get_sqe,submit,peek_cqe,cqe_seen) areunsafeimports (no capability hand-off); the one potentially-blocking call (wait_cqe) issafe, so a sleeping completion thread frees its capability.
Strengths
- Direct-style scalability for free (layer 1). Ordinary blocking-looking socket code scales to millions of green threads with no callbacks,
async/await, or coloured functions — the runtime owns the loop. MIO makes that multicore. - SSD-saturating block I/O (layer 2).
blockio-uringreaches ~92% offioIOPS for random 4 KiB reads via batching + concurrency, closing the file-I/O gap the RTS IO manager leaves open. - Clean, minimal, auditable binding.
blockio-uringuses only threeio_uringopcodes (READ/WRITE/NOP) and a tiny FFI surface; errors are in-bandIOResults, and the whole library is a few hundred lines. - Per-capability isolation avoids cross-thread SQ contention in both layers.
- Composes, doesn't replace.
blockio-uringis a library that rides on the existing green-thread runtime rather than introducing a new concurrency model;blockioadds the cross-platform fallback above it. - Careful async-exception & lifetime handling.
mask_/onException, semaphore-undo, and thekeepAlivesGC-pinning discipline make submit-time interruption safe.
Weaknesses
- No
io_uringin the RTS. Socket I/O is stillepoll/kqueue; theio_uringRTS manager is an unmerged, multi-year work-in-progress. There is noio_uring-backed network path in standard GHC. - Block I/O only.
blockio-uringdoes not do sockets — "It only supports disk operations, not socket operations." It is not a general async runtime. - Linux-only, no in-library fallback.
initIOCtxthrows on non-Linux or non-threaded RTS; the cross-platform story requires the higher-levelblockiowrapper, whose non-Linux path is plain sequential I/O. - Manual pinning & unsafe FFI. Buffers must be pinned by the caller (checked, but a runtime error), and the binding relies on
unsafeFFI calls andunsafeForeignPtrToPtr— misuse risks corruption rather than a typed error. - Narrow opcode surface. No vectored, fixed-buffer, or cancel opcodes; advanced
io_uringfeatures (fixed buffers, multishot, SQPOLL) are intentionally absent. - One completion thread per ring is a potential reaping bottleneck at extreme queue depths, and submission must run in a bound thread (the
EFAULTworkaround) — a slight cost.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
RTS owns the loop; green threads park on epoll/kqueue | Direct-style blocking code scales like an event loop; no coloured functions | File I/O has no readiness model → handled by a blocking safe-FFI OS thread |
| MIO: one event manager per capability | Removes the single-dispatcher bottleneck; scales to 40+ cores | More epoll/kqueue instances and per-cap state to coordinate |
blockio-uring is a batching library, not a runtime | Rides on existing green threads; no new scheduler/continuation machinery to build | Caller must supply concurrency (spawn many submitting threads) to fill the ring |
One io_uring + one completion thread per capability | No cross-capability SQ contention; throughput scales with -N | IOCtx is a vector; more rings/threads; each ring reaped by a single thread |
Block ops only (READ/WRITE/NOP), errors in-band | Tiny, auditable surface; matches the SSD-batching use case | No sockets, no vectored/fixed/cancel opcodes; not a general async runtime |
submitIO blocks the caller via MVar, woken by reaper | Cheapest possible "suspension" given cheap green threads — no continuations needed | Per-batch takeMVar + handoff overhead vs a CQE directly resuming a fiber |
peek_cqe (unsafe) first, wait_cqe (safe) when idle | Non-blocking drain is cheap; blocking call frees the OS thread for other work | Two FFI import flavours; subtle EAGAIN-then-block control flow |
Run all of submitIO in a fresh bound thread | Avoids EFAULT from green-thread rescheduling across capabilities (#58) | Bound-thread cost on every submit; acknowledged as overly conservative (#61) |
QSemN concurrency limit + keepAlives GC pinning | Back-pressure on in-flight ops; buffers stay live while the kernel uses them | Tuning ioctxConcurrencyLimit/BatchSizeLimit is workload/hardware-specific |
io_uring stays out of the RTS (library, not runtime) | Ship working block-I/O now without an RTS-wide redesign | No io_uring network path; the RTS io_uring manager is a long, unmerged project |
Sources
- well-typed/blockio-uring — source of all
System.IO.BlockIO*paths quoted above (tagblockio-uring-0.2.0.0) blockio-uringon Hackage — package metadata, synopsis ("Perform batches of asynchronous disk IO operations")blockioon Hackage — higher-level package; Linux backend =blockio-uring, non-Linux = sequential fallback- IntersectMBO/lsm-tree — the storage engine that drives
blockio-uring(Well-Typed, for Cardano Development Foundation / Intersect) GHC.Eventdocumentation —EventManager,registerFd,evtRead/evtWrite,Lifetime, timer API- Scalable I/O Event Handling for GHC (Haskell '10) — the original single-dispatcher event manager
- Mio: A High-Performance Multicore IO Manager for GHC (Haskell '13) — the per-capability multicore IO manager
- Well-Typed GHC activities report, Mar–May 2024 — status of the
io_uringRTS IO manager (preparatory refactoring; unmerged) - Companion: Go runtime netpoller — same "runtime owns the loop + cheap green threads" philosophy
- Companion: io_uring overview · opcodes reference · features
- Companion: Effects & event loops
- Companion: Eio's io_uring backend · Java io_uring + Loom
- Neighbours:
haskell-eff·haskell-effectful· Koka effects