ki
A lightweight structured concurrency library
https://github.com/awkward-squad/ki
| LTS Haskell 24.16: | 1.0.1.2@rev:2 |
| Stackage Nightly 2025-10-24: | 1.0.1.2@rev:2 |
| Latest on Hackage: | 1.0.1.2@rev:2 |
ki-1.0.1.2@sha256:6f9f5aa6add1e3fac54b19853e4355780b5f2243f1ab390f8bd362c0ae51a3a5,2889Module documentation for 1.0.1.2
ki |
ki-unlifted |
|---|---|
Overview
ki is a lightweight structured-concurrency library inspired by many other projects and blog posts:
- libdill
- trio
- Kotlin coroutines
- Notes on structured concurrency, or: Go statement considered harmful
- Structured Concurrency in High-level Languages
- Update on Structured Concurrency
- Two Approaches to Structured Concurrency
- libdill: Structured Concurrency for C
A previous version of ki also included a mechanism for soft-cancellation/graceful shutdown, which took inspiration
from:
- Go Concurrency Patterns: Context
- .NET 4 Cancellation Framework
- Timeouts and cancellation for humans
- Graceful Shutdown
However, this feature was removed (perhaps temporarily) because the design of the API was unsatisfactory.
Documentation
Example: Happy Eyeballs
The Happy Eyeballs algorithm is a particularly common example used to demonstrate the advantages of structured concurrency, because it is simple to describe, but can be surprisingly difficult to implement.
The problem can be abstractly described as follows: we have a small set of actions to run, each of which can take arbitrarily long, or fail. Each action is a different way of computing the same value, so we only need to wait for one action to return successfully. We don’t want to run the actions one at a time (because that is likely to take too long), nor all at once (because that is an improper use of resources). Rather, we will begin executing the first action, then wait 250 milliseconds, then begin executing the second, and so on, until one returns successfully.
There are of course a number of ways to implement this algorithm. We’ll do something non-optimal, but simple. Let’s get the imports out of the way first.
import Control.Concurrent
import Control.Monad (when)
import Control.Monad.STM (atomically)
import Data.Function ((&))
import Data.Functor (void)
import Data.List qualified as List
import Data.Maybe (isJust)
import Ki qualified
Next, let’s define a staggeredSpawner helper that implements the majority of the core algorithm: given a list of
actions, spawn them all at 250 millisecond intervals. After all actions are spawned, we block until all of them have
returned.
staggeredSpawner :: [IO ()] -> IO ()
staggeredSpawner actions = do
Ki.scoped \scope -> do
actions
& map (\action -> void (Ki.fork scope action))
& List.intersperse (threadDelay 250_000)
& sequence_
atomically (Ki.awaitAll scope)
And finally, we wrap this helper with happyEyeballs, which accepts a list of actions, and returns when one action
returns successfully, or returns Nothing if all actions fail. Note that in a real implementation, we may want to
consider what to do if an action throws an exception. Here, we trust each action to signal failure by returning
Nothing.
happyEyeballs :: [IO (Maybe a)] -> IO (Maybe a)
happyEyeballs actions = do
resultVar <- newEmptyMVar
let worker action = do
result <- action
when (isJust result) do
_ <- tryPutMVar resultVar result
pure ()
Ki.scoped \scope -> do
_ <-
Ki.fork scope do
staggeredSpawner (map worker actions)
tryPutMVar resultVar Nothing
takeMVar resultVar
Changes
[1.0.1.2] - July 15, 2024
- Bugfix #33: A scope could erroneously fail to propagate an exception to one of its children.
- Refactor: depend on (rather than inline)
int-supplypackage.
[1.0.1.1] - October 10, 2023
- Compat: support GHC 9.8.1
[1.0.1.0] - April 3, 2023
- Change #25: Attempting to fork a thread in a closing scope now acts as
if it were a child being terminated due to the scope closing. Previously, attempting to fork a thread in a closing
scope would throw a runtime exception like
error "ki: scope closed". - Change #27: Calling
awaitAllon a closed scope now returns()instead of blocking forever.
[1.0.0.2] - January 25, 2023
- Bugfix #20: previously, a child thread could deadlock when attempting to propagate an exception to its parent.
[1.0.0.1] - August 14, 2022
- Compat: support GHC 9.4.1
[1.0.0] - June 30, 2022
-
Breaking: Remove
Contexttype,Ki.Implicitmodule, and the ability to soft-cancel aScope. -
Breaking: Remove
Durationtype and its associated API, includingwaitForandawaitFor. -
Breaking: Remove
Ki.Internalmodule. -
Breaking: Generalize
asynctoforkTry. -
Breaking: Generalize
forkWithUnmasktoforkWith. -
Breaking: Make
fork_take anIO Voidrather than anIO (). -
Breaking: Make
forkcreate an unmasked thread, rather than inherit the parent’s masking state. -
Breaking: Rename
waitSTMtoawaitAll(replacing the oldwaitinIO). -
Change: Make
scopedkill threads in the order they were created. -
Bugfix: Fix small memory leak related to closing a scope.
-
Bugfix: Fix subtle bug related to GHC’s treatment of deadlocked threads.
-
Bugfix: make
async(nowforkTry) propagate async exceptions. -
Bugfix: make
scopedsafe to run with asynchronous exceptions masked. -
Bugfix: propagate exceptions to creator of scope, not creator of thread
-
Performance: Use atomic fetch-and-add rather than a
TVarto track internal child thread ids.
[0.2.0] - December 17, 2020
- Breaking: Remove
ThreadFailedexception wrapper. - Breaking: Rename
cancelScopetocancel.
[0.1.0.1] - November 30, 2020
-
Misc: Replace
AtomicCounterwithIntto drop theatomic-primopsdependency. -
Bounds: Lower
cabal-versionfrom 3.0 to 2.2 becausestackcannot parse 3.0.
[0.1.0] - November 11, 2020
- Initial release.