Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/haskell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ jobs:
- name: Install dependencies
run: |
cabal update
cabal build --only-dependencies --enable-tests
cabal build --only-dependencies --enable-tests --enable-benchmarks
- name: Build
run: cabal build --enable-tests all --ghc-options=-Werror
run: cabal build --enable-tests --enable-benchmarks all --ghc-options=-Werror
- name: Run tests
run: cabal test all --ghc-options=-Werror --test-options='+RTS -N2 -RTS'
- name: Package checks
Expand Down
27 changes: 25 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
# Revision history for stm-actor

## 0.4.0.0 -- UNRELEASED
## 0.4.0.0 -- 2026-08-23

* Add `actWith` and `ActorConfig`, which configure the mailbox capacity, the
completion handler, an `onUndelivered` handler that receives the messages
still queued when the actor stopped, and an `onEffectFailure` handler that
is called for each completion effect that throws. `act`, `actBounded`,
`actFinally`, and `actFinallyBounded` are specialisations.
* Make `addAfterEffect` lifecycle-checked by default, matching `send`; the
previous unchecked behaviour is available as `addAfterEffectUnchecked`, and
`addAfterEffectChecked` remains as an alias.
* Add `monitor` and `monitorSTM`, which deliver an actor's completion to the
monitoring actor's mailbox as an ordinary message instead of an asynchronous
exception. Monitoring an actor that has already stopped delivers the message
immediately.
* Add `awaitEffects`, which waits until the completion handler and
after-effects have finished running.
* Make `murder` a no-op once the actor has stopped, so completion effects are
not interrupted.
* Document that an actor blocked in `receive` whose handles have all been
dropped is stopped by the runtime with `BlockedIndefinitelyOnSTM`.
* Add a fan-in throughput benchmark comparing unbounded and bounded mailboxes.

* Make linking to an already-stopped actor fail reliably rather than silently
installing an after-effect that can never run.
Expand All @@ -24,7 +44,10 @@
* Add opt-in bounded actor mailboxes with transactional backpressure through
`actBounded` and `actFinallyBounded`.
* Use `stm-queue`'s incremental real-time queue for unbounded and bounded
mailboxes while keeping compatibility with the published `stm-queue-0.2.0`.
mailboxes. Bounded mailboxes are `stm-queue-0.2.2`'s bounded queues, whose
split read and write credits let senders and the actor conflict on
capacity accounting once per `capacity` sends rather than on every message.
* Require `stm-queue >= 0.2.2.0`.
* Add deterministic lifecycle regression tests and bounded test waits.
* Run the concurrency suite with multiple runtime capabilities.
* Validate the oldest compatible dependency plan in CI.
Expand Down
90 changes: 68 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,12 @@ with other threads and actors.
## Mailboxes and sending

`act` and `actFinally` create an unbounded FIFO mailbox. `actBounded capacity`
and `actFinallyBounded capacity` instead create a bounded FIFO mailbox. Both
use `stm-queue`'s incremental-rotation real-time queue; bounded mailboxes add
transactional occupancy accounting. Sending and lifecycle operations are STM
and `actFinallyBounded capacity` instead create a bounded FIFO mailbox. All
four are specialisations of `actWith`, which takes an `ActorConfig` describing
the mailbox capacity and the handlers run when the actor stops. Both kinds of
mailbox use `stm-queue`'s incremental-rotation real-time queue; bounded mailboxes are
its bounded queues, whose split read and write credits keep senders and the
actor from contending on capacity accounting for every message. Sending and lifecycle operations are STM
transactions, so they can be combined atomically with application state. A
committed `send` means that the actor was alive at that transaction's
linearization point; it does not promise that the actor will eventually process
Expand Down Expand Up @@ -62,7 +65,19 @@ Choose the sending operation based on how the caller handles lifecycle races:
| `trySend actor message` | Returns `ActorStopped` | Returns `MailboxFull` |

`trySend` returns `Sent` after enqueueing and never retries because of capacity.
Actor shutdown drains messages which were already queued.
Actor shutdown drains messages which were already queued and hands them, in
mailbox order, to the `onUndelivered` handler of the actor's `ActorConfig`:

```haskell
worker <- actWith defaultActorConfig
{ mailboxCapacity = Just 256
, onUndelivered = mapM_ requeueElsewhere
}
workerLoop
```

Every committed `send` therefore either reaches a `receive` handler or reaches
`onUndelivered`; the default handler discards the messages.

`receive` removes one message and then runs its handler. `receiveSTM` combines
mailbox removal and a caller-supplied STM action in one transaction, so either
Expand All @@ -75,29 +90,32 @@ An actor transitions exactly once from `Alive` to either `Completed` or
`await` retries in STM until the action has stopped, so it composes with other
transactions without polling.

The transition also closes checked after-effect registration. A registration
racing completion is therefore either committed and later run, or rejected;
it is never silently lost. As with sending, three registration modes are
available:
The transition also closes after-effect registration. A registration racing
completion is therefore either committed and later run, or rejected; it is
never silently lost. As with sending, three registration modes are available:

| Operation | Result if the actor has already stopped |
| --- | --- |
| `addAfterEffect` | Registers unchecked; the effect cannot run |
| `addAfterEffectChecked` | Throws `ActorDead` in STM |
| `addAfterEffect` | Throws `ActorDead` in STM |
| `tryAddAfterEffect` | Returns `False` without registering |

After the lifecycle transition, queued messages are drained and link
notifications are initiated. The completion handler passed to `actFinally`
then runs, followed by registered after-effects in registration order. Every
effect is attempted even if an earlier one throws; after draining the list, the
first effect exception is re-thrown in the actor's terminating thread. User
effects run sequentially, so a blocking effect delays later user effects.

`await` returns after the actor's action result has been recorded and checked
| `addAfterEffectUnchecked` | Registers without checking; the effect cannot run |

After the lifecycle transition, queued messages are drained and link and
monitor notifications are initiated. The completion handler then runs, followed
by `onUndelivered` if any messages were queued, and then registered
after-effects in registration order. Every effect is attempted even if an
earlier one throws: each failure is passed to the `onEffectFailure` handler,
which by default rethrows, so after draining the list the first exception is
re-thrown in the actor's terminating thread. Supply a logging handler to keep
effect failures out of the default uncaught-exception output. User effects run
sequentially, so a blocking effect delays later user effects.

`await` returns after the actor's action result has been recorded and
registration has closed. Completion handlers and after-effects may still be
running, and their failures do not change the recorded `Liveness` result.
running, and their failures do not change the recorded `Liveness` result;
`awaitEffects` additionally waits until every effect has finished.

## Links and cancellation
## Links, monitors, and cancellation

`link target`, called inside an actor, establishes a one-way link: when `target`
stops normally or exceptionally, the calling actor receives `LinkKill`.
Expand All @@ -113,9 +131,37 @@ asynchronous exceptions from blocking the target's completion effects. The
helper itself can remain blocked while the recipient uses
`uninterruptibleMask`.

Links interrupt the recipient, which suits cancellation. To be told that an
actor stopped without being interrupted, use a monitor. `monitor target
toMessage`, called inside an actor, sends `toMessage completion` to the calling
actor's own mailbox when `target` stops, where `completion` is `Nothing` for
normal completion or `Just` the exception. The notification is handled like any
other message, in mailbox order:

```haskell
data Message = Work Job | WorkerDown ThreadId (Maybe SomeException)

supervisor worker = do
monitor worker (WorkerDown (threadId worker))
receive $ \case
Work job -> ...
WorkerDown who reason -> ...
```

Monitoring an actor that has already stopped delivers the message immediately.
`monitorSTM recipient target toMessage` is the same operation in STM.

`murder` requests cancellation by synchronously using `throwTo` with a
`MurderKill` exception. Like any synchronous `throwTo`, it can block while the
target is uninterruptibly masking asynchronous exceptions.
target is uninterruptibly masking asynchronous exceptions. Once the actor has
stopped, `murder` does nothing, so completion effects are not interrupted.

An actor blocked in `receive` whose mailbox is no longer reachable from any
other thread can never receive another message. The runtime detects this at
the next major garbage collection and throws `BlockedIndefinitelyOnSTM` to the
actor, which stops with `ThrewException` and runs its links, monitors, and
completion effects like any other failure. Dropping every handle to an actor
therefore reclaims it, but the failure cascades through links.

## Scope and compatibility

Expand Down
57 changes: 57 additions & 0 deletions bench/Bench.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE BlockArguments #-}
module Main where

import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.Actor
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Control.Concurrent.STM
import Control.Monad (forM_, forever, when)
import Control.Monad.IO.Class (liftIO)
import Data.IORef
import Numeric.Natural (Natural)
import System.Environment (getArgs)

-- | Fan-in throughput: @senders@ threads send to one actor for one second.
-- Compares an unbounded mailbox with a bounded one, which exercises the
-- capacity accounting shared between senders and the actor.
--
-- With more senders than capacity permits, the bounded figure is dominated by
-- STM wakeups: every receive wakes every sender blocked on the full mailbox.
-- That cost grows with the number of capabilities, so compare runs at a fixed
-- @+RTS -N@ setting.
fanIn :: Int -> String -> Maybe Natural -> IO ()
fanIn senders label capacity = do
received <- newIORef (0 :: Int)
actor <- actWith defaultActorConfig { mailboxCapacity = capacity } $
forever $ receive \() -> liftIO (modifyIORef' received (+ 1))
stop <- newTVarIO False
finished <- newEmptyMVar
forM_ [1 .. senders] \_ -> forkIO do
let loop !sent = do
continue <- atomically do
stopped <- readTVar stop
if stopped then pure False else send actor () >> pure True
if continue then loop (sent + 1) else putMVar finished (sent :: Int)
loop 0
threadDelay 1000000
atomically (writeTVar stop True)
sent <- sum <$> mapM (const (takeMVar finished)) [1 .. senders]
murder actor
_ <- atomically (awaitEffects actor)
count <- readIORef received
putStrLn
( label <> ": " <> show senders <> " senders, "
<> show count <> " received, " <> show sent <> " sent"
)

main :: IO ()
main = do
args <- getArgs
let senderCounts = case args of
[] -> [1, 4, 16]
_ -> map read args
forM_ senderCounts \senders -> do
fanIn senders "unbounded mailbox" Nothing
fanIn senders "bounded mailbox (1024)" (Just 1024)
when (senders /= last senderCounts) (putStrLn "")
7 changes: 7 additions & 0 deletions cabal.project
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
packages: .

-- stm-queue-0.2.2.0 is not yet on Hackage. Remove this pin once it is
-- published; the sdist and oldest-dependency CI jobs will fail until then.
source-repository-package
type: git
location: https://github.com/SamuelSchlesinger/stm-queue.git
tag: b1c8f5653a03c93a3243cf7c93aa9c67e3d6a836
Loading
Loading