timer-wheel 0.3.0 → 0.4.0
raw patch · 17 files changed
+549/−588 lines, 17 filesdep +arraydep +kidep −vectordep ~base
Dependencies added: array, ki
Dependencies removed: vector
Dependency ranges changed: base
Files
- CHANGELOG.md +6/−15
- src/Data/TimerWheel.hs +0/−263
- src/Data/TimerWheel/Internal/Config.hs +0/−19
- src/Data/TimerWheel/Internal/Entries.hs +0/−69
- src/Data/TimerWheel/Internal/Micros.hs +0/−46
- src/Data/TimerWheel/Internal/Supply.hs +0/−21
- src/Data/TimerWheel/Internal/Timestamp.hs +0/−42
- src/Data/TimerWheel/Internal/Wheel.hs +0/−85
- src/TimerWheel.hs +229/−0
- src/TimerWheel/Internal/Config.hs +19/−0
- src/TimerWheel/Internal/Entries.hs +67/−0
- src/TimerWheel/Internal/Micros.hs +44/−0
- src/TimerWheel/Internal/Supply.hs +21/−0
- src/TimerWheel/Internal/Timestamp.hs +40/−0
- src/TimerWheel/Internal/Wheel.hs +84/−0
- test/Main.hs +22/−16
- timer-wheel.cabal +17/−12
CHANGELOG.md view
@@ -1,24 +1,20 @@-# Changelog--All notable changes to this project will be documented in this file.+## [0.4.0] - 2022-11-05 -The format is based on [Keep a Changelog](http://keepachangelog.com/)-and this project adheres to the [Haskell Package Versioning Policy](https://pvp.haskell.org/).+- Add `create`+- Rename `Data.TimerWheel` to `TimerWheel`+- Swap out `vector` for `array`+- Treat negative delays as 0+- Drop support for GHC < 8.6 ## [0.3.0] - 2020-06-18 -### Added - Add `with` - Add support for GHC 8.8, GHC 8.10--### Changed - Change type of `spokes` from `Natural` to `Int` - Change order of delay and action arguments in `register`, `register_`, `recurring`, and `recurring_` - Simplify `cancel` to return `True` at most once - Throw an error if a negative delay is provided to `register`, `register_`, `recurring`, or `recurring_` - Fix underflow bug that affected recurring timers--### Removed - Remove `create`, `destroy` - Remove `TimerWheelDied` exception. `with` now simply re-throws the exception that the timer wheel thread throws - Remove `InvalidTimerWheelConfig` exception. `error` is used instead@@ -26,16 +22,12 @@ ## [0.2.0.1] - 2019-05-19 -### Changed - Swap out `ghc-prim` and `primitive` for `vector` ## [0.2.0] - 2019-02-03 -### Added - Add `destroy` function, for reaping the background thread - Add `recurring_` function--### Changed - If the timer wheel reaper thread crashes, it will propagate the exception to the thread that spawned it - `new` may now throw `InvalidTimerWheelConfig`@@ -49,5 +41,4 @@ ## [0.1.0] - 2018-07-18 -### Added - Initial release
− src/Data/TimerWheel.hs
@@ -1,263 +0,0 @@-{-# LANGUAGE RecursiveDo #-}-{-# LANGUAGE StrictData #-}-{-# OPTIONS_GHC -funbox-strict-fields #-}---- | A simple, hashed timer wheel.-module Data.TimerWheel- ( -- * Timer wheel- TimerWheel,- with,- Config (..),- register,- register_,- recurring,- recurring_,- )-where--import Control.Concurrent-import Control.Exception-import Control.Monad (join, void)-import Data.Bool (bool)-import Data.Fixed (E6, Fixed)-import Data.Function (fix)-import Data.IORef (newIORef, readIORef, writeIORef)-import Data.TimerWheel.Internal.Config (Config)-import qualified Data.TimerWheel.Internal.Config as Config-import Data.TimerWheel.Internal.Micros (Micros (Micros))-import qualified Data.TimerWheel.Internal.Micros as Micros-import Data.TimerWheel.Internal.Supply (Supply)-import qualified Data.TimerWheel.Internal.Supply as Supply-import Data.TimerWheel.Internal.Wheel (Wheel)-import qualified Data.TimerWheel.Internal.Wheel as Wheel---- | A timer wheel is a vector-of-collections-of timers to fire. It is configured with a /spoke count/ and /resolution/.--- Timers may be scheduled arbitrarily far in the future. A timeout thread is spawned to step through the timer wheel--- and fire expired timers at regular intervals.------ * The /spoke count/ determines the size of the timer vector.------ * A __larger spoke count__ will result in __less insert contention__ at each spoke and will require--- __more memory__ to store the timer wheel.------ * A __smaller spoke count__ will result in __more insert contention__ at each spoke and will require--- __less memory__ to store the timer wheel.------ * The /resolution/ determines both the duration of time that each spoke corresponds to, and how often the timeout--- thread wakes. For example, with a resolution of __@1s@__, a timer that expires at __@2.5s@__ will not fire until--- the timeout thread wakes at __@3s@__.------ * A __larger resolution__ will result in __more insert contention__ at each spoke, __less accurate__ timers, and--- will require __fewer wakeups__ by the timeout thread.------ * A __smaller resolution__ will result in __less insert contention__ at each spoke, __more accurate__ timers, and--- will require __more wakeups__ by the timeout thread.------ * The timeout thread has some important properties:------ * There is only one, and it fires expired timers synchronously. If your timer actions execute quicky, 'register'--- them directly. Otherwise, consider registering an action that enqueues the /real/ action to be performed on a--- job queue.------ * Synchronous exceptions thrown by enqueued @IO@ actions will bring the thread down, and the exception will be--- propagated to the thread that created the timer wheel. If you want to catch exceptions and log them, for--- example, you will have to bake this into the registered actions yourself.------ As an example, below is a depiction of a timer wheel with @6@ timers inserted across @8@ spokes, and a resolution of--- @.1s@. It depicts a cursor at @.3s@, which indicates where the timeout thread currently is.------ @--- 0 .1 .2 .3 .4 .5 .6 .7--- ┌───────┬───────┬───────┬───────┬───────┬───────┬───────┬───────┐--- │ │ A⁰ │ │ B¹ C⁰ │ D⁰ │ │ │ E² F⁰ │--- └───────┴───────┴───────┴───────┴───────┴───────┴───────┴───────┘--- ↑--- @------ After @.1s@, the timeout thread will advance to the next spoke and process all of the timers it passed over. In--- this case, __C__ will fire, and __B__ will be put back with its count decremented to @0@. This is how the timer wheel--- can schedule a timer to fire arbitrarily far in the future: its count is simply the number of times its delay wraps--- the entire duration of the timer wheel.------ @--- 0 .1 .2 .3 .4 .5 .6 .7--- ┌───────┬───────┬───────┬───────┬───────┬───────┬───────┬───────┐--- │ │ A⁰ │ │ B⁰ │ D⁰ │ │ │ E² F⁰ │--- └───────┴───────┴───────┴───────┴───────┴───────┴───────┴───────┘--- ↑--- @-data TimerWheel = TimerWheel- { -- | A supply of unique ints.- supply :: Supply,- -- | The array of collections of timers.- wheel :: Wheel,- thread :: ThreadId- }---- | The timeout thread died.-newtype TimerWheelDied- = TimerWheelDied SomeException- deriving stock (Show)--instance Exception TimerWheelDied where- toException = asyncExceptionToException- fromException = asyncExceptionFromException---- | Perform an action with a timer wheel.------ /Throws./------ * Calls 'error' if the config is invalid--- * Throws the exception the given action throws, if any--- * Throws the exception the timer wheel thread throws, if any-with :: Config -> (TimerWheel -> IO a) -> IO a-with config action =- case validateConfig config of- () -> _with config action--_with :: Config -> (TimerWheel -> IO a) -> IO a-_with config action = do- wheel <- Wheel.create (Config.spokes config) (Micros.fromFixed (Config.resolution config))- supply <- Supply.new- parentThread <- myThreadId- uninterruptibleMask \restore -> do- thread <-- forkIOWithUnmask $ \unmask ->- unmask (Wheel.reap wheel) `catch` \e ->- case fromException e of- Just ThreadKilled -> pure ()- _ -> throwTo parentThread (TimerWheelDied e)- let cleanup = killThread thread- let handler :: SomeException -> IO void- handler ex = do- cleanup- case fromException ex of- Just (TimerWheelDied ex') -> throwIO ex'- _ -> throwIO ex- result <- restore (action TimerWheel {supply, wheel, thread}) `catch` handler- cleanup- pure result--validateConfig :: Config -> ()-validateConfig config- | invalid = error ("[timer-wheel] invalid config: " ++ show config)- | otherwise = ()- where- invalid :: Bool- invalid =- Config.spokes config <= 0 || Config.resolution config <= 0---- | @register wheel delay action@ registers an action __@action@__ in timer wheel __@wheel@__ to fire after __@delay@__--- seconds.------ Returns an action that, when called, attempts to cancel the timer, and returns whether or not it was successful--- (@False@ means the timer has already fired, or was already cancelled).------ /Throws/.------ * Calls 'error' if the given number of seconds is negative.-register ::- -- |- TimerWheel ->- -- | Delay, in seconds- Fixed E6 ->- -- | Action- IO () ->- IO (IO Bool)-register wheel (Micros.fromSeconds -> delay) =- _register wheel delay---- | Like 'register', but for when you don't intend to cancel the timer.------ /Throws/.------ * Calls 'error' if the given number of seconds is negative.-register_ ::- -- |- TimerWheel ->- -- | Delay, in seconds- Fixed E6 ->- -- | Action- IO () ->- IO ()-register_ wheel delay action =- void (register wheel delay action)--_register :: TimerWheel -> Micros -> IO () -> IO (IO Bool)-_register TimerWheel {supply, wheel} delay action = do- key <- Supply.next supply- Wheel.insert wheel key delay action---- | @recurring wheel action delay@ registers an action __@action@__ in timer wheel __@wheel@__ to fire every--- __@delay@__ seconds (or every /resolution/ seconds, whichever is smaller).------ Returns an action that, when called, cancels the recurring timer.------ /Throws/.------ * Calls 'error' if the given number of seconds is negative.-recurring ::- TimerWheel ->- -- | Delay, in seconds- Fixed E6 ->- -- | Action- IO () ->- IO (IO ())-recurring wheel (Micros.fromSeconds -> delay) action = mdo- let doAction :: IO ()- doAction = do- writeIORef cancelRef =<< _reregister wheel delay doAction- action- cancel <- _register wheel delay doAction- cancelRef <- newIORef cancel- pure (untilTrue (join (readIORef cancelRef)))- where- -- Repeat an IO action until it returns 'True'.- untilTrue :: IO Bool -> IO ()- untilTrue m =- fix \again ->- m >>= bool again (pure ())---- | Like 'recurring', but for when you don't intend to cancel the timer.------ /Throws/.------ * Calls 'error' if the given number of seconds is negative.-recurring_ ::- TimerWheel ->- -- | Delay, in seconds- Fixed E6 ->- -- | Action- IO () ->- IO ()-recurring_ wheel (Micros.fromSeconds -> delay) action =- void (_register wheel delay doAction)- where- doAction :: IO ()- doAction = do- _ <- _reregister wheel delay doAction- action---- Re-register one bucket early, to account for the fact that timers are--- expired at the *end* of a bucket.------ +---+---+---+---+--- { A | | | }--- +---+---+---+---+--- |--- The reaper thread fires 'A' approximately here, so if it's meant--- to be repeated every two buckets, and we just re-register it at--- this time, three buckets will pass before it's run again. So, we--- act as if it's still "one bucket ago" at the moment we re-register--- it.-_reregister :: TimerWheel -> Micros -> IO () -> IO (IO Bool)-_reregister wheel delay =- _register wheel (if reso > delay then Micros 0 else delay `Micros.minus` reso)- where- reso :: Micros- reso =- resolution wheel--resolution :: TimerWheel -> Micros-resolution =- Wheel.resolution . wheel
− src/Data/TimerWheel/Internal/Config.hs
@@ -1,19 +0,0 @@-module Data.TimerWheel.Internal.Config- ( Config (..),- )-where--import Data.Fixed (E6, Fixed)-import GHC.Generics (Generic)---- | Timer wheel config.------ * @spokes@ must be ∈ @(0, maxBound]@--- * @resolution@ must ∈ @(0, ∞]@-data Config = Config- { -- | Spoke count.- spokes :: Int,- -- | Resolution, in seconds.- resolution :: Fixed E6- }- deriving stock (Generic, Show)
− src/Data/TimerWheel/Internal/Entries.hs
@@ -1,69 +0,0 @@-{-# LANGUAGE TypeApplications #-}--module Data.TimerWheel.Internal.Entries- ( Entries,- empty,- Data.TimerWheel.Internal.Entries.null,- size,- insert,- delete,- partition,- )-where--import Data.Coerce-import Data.IntPSQ (IntPSQ)-import qualified Data.IntPSQ as IntPSQ-import Data.Word (Word64)--newtype Entries- = Entries (IntPSQ Word64 (IO ()))---- | An empty collection.-empty :: Entries-empty =- Entries IntPSQ.empty-{-# INLINEABLE empty #-}--null :: Entries -> Bool-null =- coerce (IntPSQ.null @Word64 @(IO ()))-{-# INLINEABLE null #-}---- | The number of timers in the collection.-size :: Entries -> Int-size =- coerce (IntPSQ.size @Word64 @(IO ()))-{-# INLINEABLE size #-}---- | @insert i n m x@ inserts callback @m@ into collection @x@ with unique--- identifier @i@ and "count" @n@. The-insert :: Int -> Word64 -> IO () -> Entries -> Entries-insert i n m =- coerce (IntPSQ.unsafeInsertNew i n m)-{-# INLINEABLE insert #-}---- | Delete a timer by id. Returns 'Nothing' if the timer was not found.-delete :: Int -> Entries -> Maybe Entries-delete =- coerce delete_-{-# INLINEABLE delete #-}--delete_ :: Int -> IntPSQ Word64 (IO ()) -> Maybe (IntPSQ Word64 (IO ()))-delete_ i xs =- (\(_, _, ys) -> ys) <$> IntPSQ.deleteView i xs---- | Extract expired timers.-partition :: Entries -> ([IO ()], Entries)-partition (Entries entries) =- case IntPSQ.atMostView 0 entries of- (expired, alive) ->- (map f expired, Entries (IntPSQ.unsafeMapMonotonic g alive))- where- f :: (Int, Word64, IO ()) -> IO ()- f (_, _, m) =- m- g :: Int -> Word64 -> IO () -> (Word64, IO ())- g _ n m =- (n -1, m)-{-# INLINEABLE partition #-}
− src/Data/TimerWheel/Internal/Micros.hs
@@ -1,46 +0,0 @@-{-# LANGUAGE TypeApplications #-}--module Data.TimerWheel.Internal.Micros- ( Micros (..),- fromFixed,- fromSeconds,- Data.TimerWheel.Internal.Micros.div,- minus,- scale,- sleep,- )-where--import Control.Concurrent (threadDelay)-import Data.Coerce-import Data.Fixed-import Data.Word--newtype Micros = Micros {unMicros :: Word64}- deriving stock (Eq, Ord)---- | Precondition: input is >= 0-fromFixed :: Fixed E6 -> Micros-fromFixed =- coerce @(Integer -> Word64) fromIntegral--fromSeconds :: Fixed E6 -> Micros-fromSeconds seconds@(MkFixed micros)- | micros < 0 = error ("[timer-wheel] invalid seconds: " ++ show seconds)- | otherwise = Micros (fromIntegral micros)--div :: Micros -> Micros -> Micros-div =- coerce (Prelude.div @Word64)--minus :: Micros -> Micros -> Micros-minus =- coerce ((-) @Word64)--scale :: Int -> Micros -> Micros-scale n (Micros w) =- Micros (fromIntegral n * w)--sleep :: Micros -> IO ()-sleep (Micros micros) =- threadDelay (fromIntegral micros)
− src/Data/TimerWheel/Internal/Supply.hs
@@ -1,21 +0,0 @@-module Data.TimerWheel.Internal.Supply- ( Supply,- new,- next,- )-where--import Data.Atomics.Counter (AtomicCounter, incrCounter, newCounter)-import Data.Coerce (coerce)--newtype Supply- = Supply AtomicCounter--new :: IO Supply-new =- coerce (newCounter 0)--next :: Supply -> IO Int-next =- coerce (incrCounter 1)-{-# INLINE next #-}
− src/Data/TimerWheel/Internal/Timestamp.hs
@@ -1,42 +0,0 @@-{-# LANGUAGE TypeApplications #-}--module Data.TimerWheel.Internal.Timestamp- ( Timestamp (..),- epoch,- minus,- now,- plus,- Data.TimerWheel.Internal.Timestamp.rem,- )-where--import Data.Coerce (coerce)-import Data.TimerWheel.Internal.Micros (Micros (..))-import Data.Word (Word64)-import GHC.Clock (getMonotonicTimeNSec)--newtype Timestamp- = Timestamp Word64- deriving stock (Eq, Ord)---- Which epoch does this correspond to, if they are measured in chunks of the given number of milliseconds?-epoch :: Micros -> Timestamp -> Word64-epoch (Micros chunk) (Timestamp timestamp) =- timestamp `div` chunk--minus :: Timestamp -> Timestamp -> Micros-minus =- coerce ((-) @Word64)--now :: IO Timestamp-now = do- nanos <- getMonotonicTimeNSec- pure (Timestamp (nanos `div` 1000))--plus :: Timestamp -> Micros -> Timestamp-plus =- coerce ((+) @Word64)--rem :: Timestamp -> Micros -> Micros-rem =- coerce (Prelude.rem @Word64)
− src/Data/TimerWheel/Internal/Wheel.hs
@@ -1,85 +0,0 @@-{-# LANGUAGE StrictData #-}--module Data.TimerWheel.Internal.Wheel- ( Wheel (resolution),- create,- lenMicros,- insert,- reap,- )-where--import Control.Monad (join, when)-import Data.IORef-import Data.TimerWheel.Internal.Entries (Entries)-import qualified Data.TimerWheel.Internal.Entries as Entries-import Data.TimerWheel.Internal.Micros (Micros (..))-import qualified Data.TimerWheel.Internal.Micros as Micros-import Data.TimerWheel.Internal.Timestamp (Timestamp)-import qualified Data.TimerWheel.Internal.Timestamp as Timestamp-import Data.Vector (Vector)-import qualified Data.Vector as Vector--data Wheel = Wheel- { buckets :: Vector (IORef Entries),- resolution :: Micros- }--create :: Int -> Micros -> IO Wheel-create spokes resolution = do- buckets <- Vector.replicateM spokes (newIORef Entries.empty)- pure Wheel {buckets, resolution}--numSpokes :: Wheel -> Int-numSpokes wheel =- Vector.length (buckets wheel)--lenMicros :: Wheel -> Micros-lenMicros wheel =- Micros.scale (numSpokes wheel) (resolution wheel)--bucket :: Wheel -> Timestamp -> IORef Entries-bucket wheel timestamp =- Vector.unsafeIndex (buckets wheel) (index wheel timestamp)--index :: Wheel -> Timestamp -> Int-index wheel@Wheel {resolution} timestamp =- fromIntegral (Timestamp.epoch resolution timestamp) `rem` numSpokes wheel--insert :: Wheel -> Int -> Micros -> IO () -> IO (IO Bool)-insert wheel key delay action = do- bucketRef <- do- now <- Timestamp.now- pure (bucket wheel (now `Timestamp.plus` delay))-- atomicModifyIORef' bucketRef (\entries -> (insertEntry entries, ()))-- pure do- atomicModifyIORef' bucketRef \entries ->- case Entries.delete key entries of- Nothing -> (entries, False)- Just entries' -> (entries', True)- where- insertEntry :: Entries -> Entries- insertEntry =- Entries.insert key (unMicros (delay `Micros.div` lenMicros wheel)) action--reap :: Wheel -> IO ()-reap wheel@Wheel {buckets, resolution} = do- now <- Timestamp.now- let remainingBucketMicros = resolution `Micros.minus` (now `Timestamp.rem` resolution)- Micros.sleep remainingBucketMicros- loop (now `Timestamp.plus` remainingBucketMicros `Timestamp.plus` resolution) (index wheel now)- where- loop :: Timestamp -> Int -> IO ()- loop nextTime i = do- join (atomicModifyIORef' (Vector.unsafeIndex buckets i) expire)- afterTime <- Timestamp.now- when (afterTime < nextTime) (Micros.sleep (nextTime `Timestamp.minus` afterTime))- loop (nextTime `Timestamp.plus` resolution) ((i + 1) `rem` numSpokes wheel)- expire :: Entries -> (Entries, IO ())- expire entries- | Entries.null entries = (entries, pure ())- | otherwise = (alive, sequence_ expired)- where- (expired, alive) = Entries.partition entries
+ src/TimerWheel.hs view
@@ -0,0 +1,229 @@+{-# LANGUAGE RecursiveDo #-}++-- | A simple, hashed timer wheel.+module TimerWheel+ ( -- * Timer wheel+ TimerWheel,+ create,+ with,+ Config (..),+ register,+ register_,+ recurring,+ recurring_,+ )+where++import Control.Exception (throwIO)+import Control.Monad (when)+import Data.Bool (bool)+import Data.Fixed (E6, Fixed)+import Data.Function (fix)+import Data.IORef (newIORef, readIORef, writeIORef)+import GHC.Exception (errorCallException)+import qualified Ki+import TimerWheel.Internal.Config (Config)+import qualified TimerWheel.Internal.Config as Config+import TimerWheel.Internal.Micros (Micros (Micros))+import qualified TimerWheel.Internal.Micros as Micros+import TimerWheel.Internal.Supply (Supply)+import qualified TimerWheel.Internal.Supply as Supply+import TimerWheel.Internal.Wheel (Wheel)+import qualified TimerWheel.Internal.Wheel as Wheel++-- | A timer wheel is a vector-of-collections-of timers to fire. It is configured with a /spoke count/ and /resolution/.+-- Timers may be scheduled arbitrarily far in the future. A timeout thread is spawned to step through the timer wheel+-- and fire expired timers at regular intervals.+--+-- * The /spoke count/ determines the size of the timer vector.+--+-- * A __larger spoke count__ will result in __less insert contention__ at each spoke and will require+-- __more memory__ to store the timer wheel.+--+-- * A __smaller spoke count__ will result in __more insert contention__ at each spoke and will require+-- __less memory__ to store the timer wheel.+--+-- * The /resolution/ determines both the duration of time that each spoke corresponds to, and how often the timeout+-- thread wakes. For example, with a resolution of __@1s@__, a timer that expires at __@2.5s@__ will not fire until+-- the timeout thread wakes at __@3s@__.+--+-- * A __larger resolution__ will result in __more insert contention__ at each spoke, __less accurate__ timers, and+-- will require __fewer wakeups__ by the timeout thread.+--+-- * A __smaller resolution__ will result in __less insert contention__ at each spoke, __more accurate__ timers, and+-- will require __more wakeups__ by the timeout thread.+--+-- * The timeout thread has some important properties:+--+-- * There is only one, and it fires expired timers synchronously. If your timer actions execute quicky, 'register'+-- them directly. Otherwise, consider registering an action that enqueues the /real/ action to be performed on a+-- job queue.+--+-- * Synchronous exceptions thrown by enqueued @IO@ actions will bring the thread down, and the exception will be+-- propagated to the thread that created the timer wheel. If you want to catch exceptions and log them, for+-- example, you will have to bake this into the registered actions yourself.+--+-- As an example, below is a depiction of a timer wheel with @6@ timers inserted across @8@ spokes, and a resolution of+-- @.1s@. It depicts a cursor at @.3s@, which indicates where the timeout thread currently is.+--+-- @+-- 0 .1 .2 .3 .4 .5 .6 .7+-- ┌───────┬───────┬───────┬───────┬───────┬───────┬───────┬───────┐+-- │ │ A⁰ │ │ B¹ C⁰ │ D⁰ │ │ │ E² F⁰ │+-- └───────┴───────┴───────┴───────┴───────┴───────┴───────┴───────┘+-- ↑+-- @+--+-- After @.1s@, the timeout thread will advance to the next spoke and process all of the timers it passed over. In+-- this case, __C__ will fire, and __B__ will be put back with its count decremented to @0@. This is how the timer wheel+-- can schedule a timer to fire arbitrarily far in the future: its count is simply the number of times its delay wraps+-- the entire duration of the timer wheel.+--+-- @+-- 0 .1 .2 .3 .4 .5 .6 .7+-- ┌───────┬───────┬───────┬───────┬───────┬───────┬───────┬───────┐+-- │ │ A⁰ │ │ B⁰ │ D⁰ │ │ │ E² F⁰ │+-- └───────┴───────┴───────┴───────┴───────┴───────┴───────┴───────┘+-- ↑+-- @+data TimerWheel = TimerWheel+ { -- | A supply of unique ints.+ supply :: {-# UNPACK #-} !Supply,+ -- | The array of collections of timers.+ wheel :: {-# UNPACK #-} !Wheel+ -- thread :: {-# UNPACK #-} !ThreadId+ }++-- | Create a timer wheel in a scope.+--+-- /Throws./+--+-- * Calls 'error' if the config is invalid+create :: Ki.Scope -> Config -> IO TimerWheel+create scope config = do+ validateConfig config+ wheel <- Wheel.create (Config.spokes config) (Micros.fromFixed (Config.resolution config))+ supply <- Supply.new+ Ki.fork_ scope (Wheel.reap wheel)+ pure TimerWheel {supply, wheel}++-- | Perform an action with a timer wheel.+--+-- /Throws./+--+-- * Calls 'error' if the config is invalid+-- * Throws the exception the given action throws, if any+-- * Throws the exception the timer wheel thread throws, if any+with :: Config -> (TimerWheel -> IO a) -> IO a+with config action =+ Ki.scoped \scope -> do+ wheel <- create scope config+ action wheel++validateConfig :: Config -> IO ()+validateConfig config =+ when (Config.spokes config <= 0 || Config.resolution config <= 0) do+ throwIO (errorCallException ("timer-wheel: invalid config: " ++ show config))++-- | @register wheel delay action@ registers an action __@action@__ in timer wheel __@wheel@__ to fire after __@delay@__+-- seconds.+--+-- Returns an action that, when called, attempts to cancel the timer, and returns whether or not it was successful+-- (@False@ means the timer has already fired, or was already cancelled).+register ::+ TimerWheel ->+ -- | Delay, in seconds+ Fixed E6 ->+ -- | Action+ IO () ->+ IO (IO Bool)+register wheel delay =+ registerImpl wheel (Micros.fromSeconds (max 0 delay))++-- | Like 'register', but for when you don't intend to cancel the timer.+register_ ::+ TimerWheel ->+ -- | Delay, in seconds+ Fixed E6 ->+ -- | Action+ IO () ->+ IO ()+register_ wheel delay action = do+ _ <- register wheel delay action+ pure ()++registerImpl :: TimerWheel -> Micros -> IO () -> IO (IO Bool)+registerImpl TimerWheel {supply, wheel} delay action = do+ key <- Supply.next supply+ Wheel.insert wheel key delay action++-- | @recurring wheel action delay@ registers an action __@action@__ in timer wheel __@wheel@__ to fire every+-- __@delay@__ seconds (or every /resolution/ seconds, whichever is smaller).+--+-- Returns an action that, when called, cancels the recurring timer.+recurring ::+ TimerWheel ->+ -- | Delay, in seconds+ Fixed E6 ->+ -- | Action+ IO () ->+ IO (IO ())+recurring wheel (Micros.fromSeconds -> delay) action = mdo+ let doAction :: IO ()+ doAction = do+ cancel <- reregister wheel delay doAction+ writeIORef cancelRef cancel+ action+ cancel0 <- registerImpl wheel delay doAction+ cancelRef <- newIORef cancel0+ pure do+ untilTrue do+ cancel <- readIORef cancelRef+ cancel++-- | Like 'recurring', but for when you don't intend to cancel the timer.+recurring_ ::+ TimerWheel ->+ -- | Delay, in seconds+ Fixed E6 ->+ -- | Action+ IO () ->+ IO ()+recurring_ wheel (Micros.fromSeconds -> delay) action = do+ _ <- registerImpl wheel delay doAction+ pure ()+ where+ doAction :: IO ()+ doAction = do+ _ <- reregister wheel delay doAction+ action++-- Re-register one bucket early, to account for the fact that timers are+-- expired at the *end* of a bucket.+--+-- +---+---+---+---++-- { A | | | }+-- +---+---+---+---++-- |+-- The reaper thread fires 'A' approximately here, so if it's meant+-- to be repeated every two buckets, and we just re-register it at+-- this time, three buckets will pass before it's run again. So, we+-- act as if it's still "one bucket ago" at the moment we re-register+-- it.+reregister :: TimerWheel -> Micros -> IO () -> IO (IO Bool)+reregister wheel delay =+ registerImpl wheel (if reso > delay then Micros 0 else delay `Micros.minus` reso)+ where+ reso :: Micros+ reso =+ resolution wheel++resolution :: TimerWheel -> Micros+resolution =+ Wheel.resolution . wheel++-- Repeat an IO action until it returns 'True'.+untilTrue :: IO Bool -> IO ()+untilTrue m =+ fix \again ->+ m >>= bool again (pure ())
+ src/TimerWheel/Internal/Config.hs view
@@ -0,0 +1,19 @@+module TimerWheel.Internal.Config+ ( Config (..),+ )+where++import Data.Fixed (E6, Fixed)+import GHC.Generics (Generic)++-- | Timer wheel config.+--+-- * @spokes@ must be ∈ @[1, maxBound]@+-- * @resolution@ must ∈ @(0, ∞]@+data Config = Config+ { -- | Spoke count.+ spokes :: {-# UNPACK #-} !Int,+ -- | Resolution, in seconds.+ resolution :: !(Fixed E6)+ }+ deriving stock (Generic, Show)
+ src/TimerWheel/Internal/Entries.hs view
@@ -0,0 +1,67 @@+module TimerWheel.Internal.Entries+ ( Entries,+ empty,+ TimerWheel.Internal.Entries.null,+ size,+ insert,+ delete,+ partition,+ )+where++import Data.Coerce+import Data.IntPSQ (IntPSQ)+import qualified Data.IntPSQ as IntPSQ+import Data.Word (Word64)++newtype Entries+ = Entries (IntPSQ Word64 (IO ()))++-- | An empty collection.+empty :: Entries+empty =+ Entries IntPSQ.empty+{-# INLINEABLE empty #-}++null :: Entries -> Bool+null =+ coerce (IntPSQ.null @Word64 @(IO ()))+{-# INLINEABLE null #-}++-- | The number of timers in the collection.+size :: Entries -> Int+size =+ coerce (IntPSQ.size @Word64 @(IO ()))+{-# INLINEABLE size #-}++-- | @insert i n m x@ inserts callback @m@ into collection @x@ with unique+-- identifier @i@ and "count" @n@. The+insert :: Int -> Word64 -> IO () -> Entries -> Entries+insert i n m =+ coerce (IntPSQ.unsafeInsertNew i n m)+{-# INLINEABLE insert #-}++-- | Delete a timer by id. Returns 'Nothing' if the timer was not found.+delete :: Int -> Entries -> Maybe Entries+delete =+ coerce delete_+{-# INLINEABLE delete #-}++delete_ :: Int -> IntPSQ Word64 (IO ()) -> Maybe (IntPSQ Word64 (IO ()))+delete_ i xs =+ (\(_, _, ys) -> ys) <$> IntPSQ.deleteView i xs++-- | Extract expired timers.+partition :: Entries -> ([IO ()], Entries)+partition (Entries entries) =+ case IntPSQ.atMostView 0 entries of+ (expired, alive) ->+ (map f expired, Entries (IntPSQ.unsafeMapMonotonic g alive))+ where+ f :: (Int, Word64, IO ()) -> IO ()+ f (_, _, m) =+ m+ g :: Int -> Word64 -> IO () -> (Word64, IO ())+ g _ n m =+ (n -1, m)+{-# INLINEABLE partition #-}
+ src/TimerWheel/Internal/Micros.hs view
@@ -0,0 +1,44 @@+module TimerWheel.Internal.Micros+ ( Micros (..),+ fromFixed,+ fromSeconds,+ TimerWheel.Internal.Micros.div,+ minus,+ scale,+ sleep,+ )+where++import Control.Concurrent (threadDelay)+import Data.Coerce+import Data.Fixed+import Data.Word++newtype Micros = Micros {unMicros :: Word64}+ deriving stock (Eq, Ord)++-- | Precondition: input is >= 0+fromFixed :: Fixed E6 -> Micros+fromFixed =+ coerce @(Integer -> Word64) fromIntegral++fromSeconds :: Fixed E6 -> Micros+fromSeconds seconds@(MkFixed micros)+ | micros < 0 = error ("[timer-wheel] invalid seconds: " ++ show seconds)+ | otherwise = Micros (fromIntegral micros)++div :: Micros -> Micros -> Micros+div =+ coerce (Prelude.div @Word64)++minus :: Micros -> Micros -> Micros+minus =+ coerce ((-) @Word64)++scale :: Int -> Micros -> Micros+scale n (Micros w) =+ Micros (fromIntegral n * w)++sleep :: Micros -> IO ()+sleep (Micros micros) =+ threadDelay (fromIntegral micros)
+ src/TimerWheel/Internal/Supply.hs view
@@ -0,0 +1,21 @@+module TimerWheel.Internal.Supply+ ( Supply,+ new,+ next,+ )+where++import Data.Atomics.Counter (AtomicCounter, incrCounter, newCounter)+import Data.Coerce (coerce)++newtype Supply+ = Supply AtomicCounter++new :: IO Supply+new =+ coerce (newCounter 0)++next :: Supply -> IO Int+next =+ coerce (incrCounter 1)+{-# INLINE next #-}
+ src/TimerWheel/Internal/Timestamp.hs view
@@ -0,0 +1,40 @@+module TimerWheel.Internal.Timestamp+ ( Timestamp (..),+ epoch,+ minus,+ now,+ plus,+ TimerWheel.Internal.Timestamp.rem,+ )+where++import Data.Coerce (coerce)+import Data.Word (Word64)+import GHC.Clock (getMonotonicTimeNSec)+import TimerWheel.Internal.Micros (Micros (..))++newtype Timestamp+ = Timestamp Word64+ deriving stock (Eq, Ord)++-- Which epoch does this correspond to, if they are measured in chunks of the given number of milliseconds?+epoch :: Micros -> Timestamp -> Word64+epoch (Micros chunk) (Timestamp timestamp) =+ timestamp `div` chunk++minus :: Timestamp -> Timestamp -> Micros+minus =+ coerce ((-) @Word64)++now :: IO Timestamp+now = do+ nanos <- getMonotonicTimeNSec+ pure (Timestamp (nanos `div` 1000))++plus :: Timestamp -> Micros -> Timestamp+plus =+ coerce ((+) @Word64)++rem :: Timestamp -> Micros -> Micros+rem =+ coerce (Prelude.rem @Word64)
+ src/TimerWheel/Internal/Wheel.hs view
@@ -0,0 +1,84 @@+module TimerWheel.Internal.Wheel+ ( Wheel (resolution),+ create,+ lenMicros,+ insert,+ reap,+ )+where++import Control.Monad (join, replicateM, when)+import Data.Array (Array)+import qualified Data.Array as Array+import Data.IORef+import TimerWheel.Internal.Entries (Entries)+import qualified TimerWheel.Internal.Entries as Entries+import TimerWheel.Internal.Micros (Micros (..))+import qualified TimerWheel.Internal.Micros as Micros+import TimerWheel.Internal.Timestamp (Timestamp)+import qualified TimerWheel.Internal.Timestamp as Timestamp++data Wheel = Wheel+ { buckets :: {-# UNPACK #-} !(Array Int (IORef Entries)),+ resolution :: {-# UNPACK #-} !Micros+ }++create :: Int -> Micros -> IO Wheel+create spokes resolution = do+ refs <- replicateM spokes (newIORef Entries.empty)+ let buckets = Array.listArray (0, spokes - 1) refs+ pure Wheel {buckets, resolution}++numSpokes :: Wheel -> Int+numSpokes wheel =+ length (buckets wheel)++lenMicros :: Wheel -> Micros+lenMicros wheel =+ Micros.scale (numSpokes wheel) (resolution wheel)++bucket :: Wheel -> Timestamp -> IORef Entries+bucket wheel timestamp =+ buckets wheel Array.! index wheel timestamp++index :: Wheel -> Timestamp -> Int+index wheel@Wheel {resolution} timestamp =+ fromIntegral (Timestamp.epoch resolution timestamp) `rem` numSpokes wheel++insert :: Wheel -> Int -> Micros -> IO () -> IO (IO Bool)+insert wheel key delay action = do+ bucketRef <- do+ now <- Timestamp.now+ pure (bucket wheel (now `Timestamp.plus` delay))++ atomicModifyIORef' bucketRef (\entries -> (insertEntry entries, ()))++ pure do+ atomicModifyIORef' bucketRef \entries ->+ case Entries.delete key entries of+ Nothing -> (entries, False)+ Just entries' -> (entries', True)+ where+ insertEntry :: Entries -> Entries+ insertEntry =+ Entries.insert key (unMicros (delay `Micros.div` lenMicros wheel)) action++reap :: Wheel -> IO a+reap wheel@Wheel {buckets, resolution} = do+ now <- Timestamp.now+ let remainingBucketMicros = resolution `Micros.minus` (now `Timestamp.rem` resolution)+ Micros.sleep remainingBucketMicros+ loop (now `Timestamp.plus` remainingBucketMicros `Timestamp.plus` resolution) (index wheel now)+ where+ loop :: Timestamp -> Int -> IO a+ loop nextTime i = do+ join (atomicModifyIORef' (buckets Array.! i) expire)+ afterTime <- Timestamp.now+ when (afterTime < nextTime) (Micros.sleep (nextTime `Timestamp.minus` afterTime))+ loop (nextTime `Timestamp.plus` resolution) ((i + 1) `rem` numSpokes wheel)+ expire :: Entries -> (Entries, IO ())+ expire entries+ | Entries.null entries = (entries, pure ())+ | otherwise = (alive, sequence_ expired)+ where+ (expired, alive) = Entries.partition entries
test/Main.hs view
@@ -1,19 +1,23 @@-{-# LANGUAGE BlockArguments, DeriveAnyClass, DerivingStrategies, FlexibleInstances, MultiParamTypeClasses #-}+{-# LANGUAGE BlockArguments #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DerivingStrategies #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-} import Control.Concurrent import Control.Exception import Control.Monad import Data.IORef import Data.Maybe (isJust)-import Data.TimerWheel import System.Mem (performGC) import System.Mem.Weak (deRefWeak)+import TimerWheel main :: IO () main = do do putStrLn "Timer wheel runs scheduled actions"- with Config { spokes = 4, resolution = 0.05 } \wheel -> do+ with Config {spokes = 4, resolution = 0.05} \wheel -> do var <- newEmptyMVar let n = 1000 replicateM_ n (register_ wheel 0 (putMVar var ()))@@ -22,7 +26,7 @@ do putStrLn "Timers can be canceled" var <- newEmptyMVar- with Config { spokes = 4, resolution = 0.05 } \wheel -> do+ with Config {spokes = 4, resolution = 0.05} \wheel -> do let n = 1000 cancels <- replicateM n (register wheel 0 (putMVar var ())) successes <- sequence (take (n `div` 2) cancels)@@ -30,14 +34,14 @@ do putStrLn "Successful `cancel` returns True (then False)"- with Config { spokes = 4, resolution = 0.05 } \wheel -> do+ with Config {spokes = 4, resolution = 0.05} \wheel -> do cancel <- register wheel 1 (pure ()) cancel `is` True cancel `is` False do putStrLn "Unsuccessful `cancel` returns False"- with Config { spokes = 4, resolution = 0.05 } \wheel -> do+ with Config {spokes = 4, resolution = 0.05} \wheel -> do var <- newEmptyMVar cancel <- register wheel 0 (putMVar var ()) takeMVar var@@ -45,7 +49,7 @@ do putStrLn "Recurring timers work"- with Config { spokes = 4, resolution = 0.05 } \wheel -> do+ with Config {spokes = 4, resolution = 0.05} \wheel -> do canary <- newIORef () -- kept alive only by timer weakCanary <- mkWeakIORef canary (pure ()) var <- newEmptyMVar@@ -58,15 +62,17 @@ do putStrLn "`with` re-throws exception from background thread" catch- (with Config { spokes = 4, resolution = 0.05 } \wheel -> do- var <- newEmptyMVar- register_ wheel 0 (throwIO Bye >> putMVar var ())- takeMVar var- throwIO (userError "fail"))- (\ex ->- case fromException ex of- Just Bye -> pure ()- _ -> throwIO ex)+ ( with Config {spokes = 4, resolution = 0.05} \wheel -> do+ var <- newEmptyMVar+ register_ wheel 0 (throwIO Bye >> putMVar var ())+ takeMVar var+ throwIO (userError "fail")+ )+ ( \ex ->+ case fromException ex of+ Just Bye -> pure ()+ _ -> throwIO ex+ ) data Bye = Bye deriving stock (Show)
timer-wheel.cabal view
@@ -1,7 +1,7 @@ cabal-version: 2.0 name: timer-wheel-version: 0.3.0+version: 0.4.0 category: Data description: This library provides a timer wheel data structure for@@ -9,7 +9,7 @@ * (Almost) /O(1)/ registering @IO@ actions to fire after a given amount of time * /O(1)/ canceling registered actions .- It is similar to @TimerManager@ from "GHC.Event", but supports recurring+ It is similar to @TimerManager@ from @GHC.Event@, but supports recurring timers, can scale to handle many more registered timers. . synopsis: A timer wheel@@ -21,6 +21,7 @@ license: BSD3 license-file: LICENSE build-type: Simple+tested-with: GHC ==8.10.7, GHC ==9.0.2, GHC ==9.2.1 extra-source-files: CHANGELOG.md@@ -33,10 +34,11 @@ library build-depends:+ array ^>= 0.5.2.0, atomic-primops ^>= 0.8,- base ^>= 4.9 || ^>= 4.10 || ^>= 4.11 || ^>= 4.12 || ^>= 4.13 || ^>= 4.14,- psqueues ^>= 0.2.7,- vector ^>= 0.10 || ^>= 0.11 || ^>= 0.12+ base ^>= 4.12 || ^>= 4.13 || ^>= 4.14 || ^>= 4.15 || ^>= 4.16 || ^>= 4.17,+ ki ^>= 1.0.0,+ psqueues ^>= 0.2.7 default-extensions: BlockArguments@@ -56,7 +58,7 @@ Haskell2010 exposed-modules:- Data.TimerWheel+ TimerWheel ghc-options: -Weverything@@ -68,17 +70,20 @@ ghc-options: -Wno-missing-safe-haskell-mode -Wno-prepositive-qualified-module+ if impl(ghc >= 9.2)+ ghc-options:+ -Wno-missing-kind-signatures hs-source-dirs: src other-modules:- Data.TimerWheel.Internal.Config- Data.TimerWheel.Internal.Entries- Data.TimerWheel.Internal.Micros- Data.TimerWheel.Internal.Supply- Data.TimerWheel.Internal.Timestamp- Data.TimerWheel.Internal.Wheel+ TimerWheel.Internal.Config+ TimerWheel.Internal.Entries+ TimerWheel.Internal.Micros+ TimerWheel.Internal.Supply+ TimerWheel.Internal.Timestamp+ TimerWheel.Internal.Wheel test-suite tests build-depends: