timewheel

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 11, 2026 License: MIT Imports: 7 Imported by: 0

README

timewheel

Go Reference Go Report Card

A generic, high-performance timer wheel for Go 1.25+.

Features

  • Generics — the payload type T is a type parameter; no interface{} assertions needed
  • Context-aware lifecycle — the wheel stops cleanly when the supplied context.Context is cancelled
  • Multiple timer modes — one-shot, repeating, and bare-closure (AddTimerFunc) variants
  • Bounded worker pool — optionally cap concurrent job goroutines with WithWorkerPool
  • Panic recovery — optionally recover job panics via WithErrorHandler
  • Logger abstraction — optionally pass any logger with Info / Warn methods; no logging package is imported by timewheel
  • Runtime stats — query pending / executed / removed counters at any time via Stats()
  • Low allocation hot pathsync.Pool recycles task objects; slots use slice swap-deletion (O(1), no heap pressure)

Requirements

Go 1.25 or later.

Installation

go get github.com/lib-x/timewheel

Quick start

package main

import (
    "context"
    "fmt"
    "log"
    "time"

    "github.com/lib-x/timewheel"
)

func main() {
    // Create a wheel that ticks every 100 ms across 60 slots.
    // Maximum single-rotation range: 100ms × 60 = 6 s.
    // Delays beyond that are handled transparently via circle counting.
    tw, err := timewheel.New[string](
        100*time.Millisecond, // tick interval (resolution)
        60,                   // number of slots
        func(msg string) {    // default job
            fmt.Println("fired:", msg)
        },
    )
    if err != nil {
        log.Fatal(err)
    }

    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    tw.Start(ctx)

    key := tw.AddTimer(500*time.Millisecond, "hello")
    tw.AddTimer(1*time.Second, "world")

    // Inspect the scheduled fire time immediately after registration.
    if fireAt, ok := tw.NextFireTime(key); ok {
        fmt.Printf("'hello' fires in %s\n", time.Until(fireAt).Round(time.Millisecond))
    }

    time.Sleep(2 * time.Second)
}

API

Construction
func New[T any](
    interval   time.Duration,
    slotNum    int,
    defaultJob Job[T],
    opts       ...Option[T],
) (*TimeWheel[T], error)
Parameter Description
interval Tick resolution. The minimum timer precision equals interval.
slotNum Number of slots. A larger value spreads tasks across more buckets and reduces per-tick scan work.
defaultJob Callback used when a task has no per-task job. May be nil if every timer is registered via AddTimerWithJob.
Options
type Logger interface {
    Info(msg string, args ...any)
    Warn(msg string, args ...any)
}
Option Description
WithWorkerPool[T](n int) Limit concurrent job goroutines to n.
WithErrorHandler[T](fn) Called with the recovered value whenever a job panics.
WithLogger[T](l Logger) Use this logger for internal diagnostics. If omitted or nil, timewheel does not log.

WithLogger accepts any concrete logger or adapter that implements the small interface above, including *slog.Logger. The package itself does not import log/slog, zap, zerolog, or any other logging implementation.

Lifecycle
tw.Start(ctx)  // launch event loop; stops when ctx is cancelled
tw.Wait()      // block until the event loop exits
Timer registration
Method Description
AddTimer(delay, data) uint64 One-shot timer using the default job.
AddTimerWithJob(delay, data, job) uint64 One-shot timer with a per-task job.
AddTimerFunc(delay, fn) uint64 One-shot timer from a plain closure (no payload required).
AddRepeating(delay, data) uint64 Recurring timer using the default job.
AddRepeatingWithJob(delay, data, job) uint64 Recurring timer with a per-task job.
RemoveTimer(key uint64) Cancel a pending timer. No-op for unknown or already-fired keys.

All registration methods return a uint64 key that uniquely identifies the timer within the wheel's lifetime.

Inspecting next fire time
key := tw.AddTimer(5*time.Second, "payload")

// Query a single timer — O(1), no slot lock acquired.
if fireAt, ok := tw.NextFireTime(key); ok {
    fmt.Println("fires at:", fireAt)
    fmt.Println("in:      ", time.Until(fireAt).Round(time.Millisecond))
}

// List all pending timers, sorted by ascending fire time.
for _, info := range tw.PendingTimers() {
    fmt.Printf("key=%-6d  repeating=%-5v  next=%s  in=%s\n",
        info.Key,
        info.Repeating,
        info.NextFireAt.Format(time.TimeOnly),
        time.Until(info.NextFireAt).Round(time.Millisecond),
    )
}

NextFireTime returns (zero, false) when the key does not exist — either because it was never registered, has already fired (one-shot), or was explicitly removed. For repeating timers the returned time advances after every execution.

PendingTimers returns a freshly allocated []TimerInfo snapshot. Each entry carries:

Field Type Description
Key uint64 Timer identifier
NextFireAt time.Time Expected wall-clock fire time
Delay time.Duration Original registration delay
Repeating bool True for AddRepeating / AddRepeatingWithJob timers
Observability
s := tw.Stats()
fmt.Println(s.Pending)  // tasks currently in the wheel
fmt.Println(s.Executed) // total tasks dispatched since Start
fmt.Println(s.Removed)  // total tasks cancelled via RemoveTimer

Design notes

Time wheel basics
slots:  [ 0 ][ 1 ][ 2 ] ... [ N-1 ]
                  ↑
            currentPos

Every interval:
  1. Scan slot[currentPos]: decrement circle for tasks not yet due;
     dispatch tasks whose circle == 0.
  2. Advance currentPos = (currentPos + 1) % slotNum.

A task with delay d is placed at:

ticks  = ceil(d / interval)
offset = ticks - 1             // currentPos is scanned on the next tick
circle = offset / slotNum      // full rotations to wait
pos    = (currentPos + offset) % slotNum
Slot-level locking

Each slot owns its own sync.Mutex. The event loop acquires a slot's lock only while placing or deleting tasks. Concurrent callers operating on different slots do not block one another.

Object pooling

task structs are allocated once and returned to a sync.Pool after use, keeping the hot path largely allocation-free and reducing GC pressure under high timer turnover.

Deletion

deleteTask performs an O(1) swap-and-shrink: the target element is overwritten with the last element in the slice and the slice is shortened by one, avoiding the O(n) copy of append(s[:i], s[i+1:]...).

Example: bounded worker pool + error recovery

tw, _ := timewheel.New[[]byte](
    50*time.Millisecond,
    200,
    processPayload,
    timewheel.WithWorkerPool[[]byte](16),
    timewheel.WithErrorHandler[[]byte](func(r any) {
        slog.Error("job panicked", "err", r)
    }),
    timewheel.WithLogger[[]byte](slog.Default()),
)

ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()

tw.Start(ctx)

// Enqueue work
tw.AddTimer(200*time.Millisecond, payload)

tw.Wait()

Example: repeating ticker with graceful stop

key := tw.AddRepeating(1*time.Second, struct{}{})

// ... later ...
tw.RemoveTimer(key) // stops the repetition

License

MIT

Documentation

Overview

Package timewheel provides a generic, high-performance timer wheel implementation.

A time wheel is a data structure used to efficiently manage a large number of timers. It works by dividing time into fixed-size slots arranged in a circular buffer. Each tick advances the pointer by one slot and executes any tasks scheduled for that slot.

Basic usage

tw, err := timewheel.New[string](
    100*time.Millisecond, // tick interval (resolution)
    60,                   // number of slots
    func(data string) {   // default job
        fmt.Println("fired:", data)
    },
)
if err != nil {
    log.Fatal(err)
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tw.Start(ctx)

key := tw.AddTimer(500*time.Millisecond, "hello")
if t, ok := tw.NextFireTime(key); ok {
    fmt.Println("fires at", t)
}

Generics

TimeWheel is parameterised over the task-data type T. This eliminates the need for type assertions and makes the API type-safe at compile time.

Concurrency

All public methods are safe for concurrent use. Internally each slot owns its own mutex so that concurrent add / remove operations on different slots do not block one another. The event loop itself runs in a single goroutine; actual job execution is dispatched to separate goroutines (optionally bounded by a worker-pool semaphore).

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Job

type Job[T any] func(data T)

Job is the callback signature invoked when a timer fires. T is the type of the payload that was registered with the timer.

type Logger

type Logger interface {
	Info(msg string, args ...any)
	Warn(msg string, args ...any)
}

Logger is the minimal logging interface used by TimeWheel.

The package does not bind to any concrete logging implementation. Callers may pass *slog.Logger directly or adapt zap, zerolog, or another logger. If no logger is configured, TimeWheel does not emit internal logs.

type Option

type Option[T any] func(*config[T])

Option is a functional option for New.

func WithErrorHandler

func WithErrorHandler[T any](h func(recovered any)) Option[T]

WithErrorHandler registers a function that is called with the recovered value whenever a job panics. If not set, panics propagate and crash the program.

func WithLogger

func WithLogger[T any](l Logger) Option[T]

WithLogger configures the logger used for internal diagnostic messages.

func WithWorkerPool

func WithWorkerPool[T any](n int) Option[T]

WithWorkerPool limits the number of concurrently running job goroutines to n. When n <= 0 the option is ignored and goroutines are spawned without bound.

type Stats

type Stats struct {
	// Pending is the number of tasks currently queued in the wheel.
	Pending int64

	// Executed is the total number of tasks that have been dispatched.
	Executed int64

	// Removed is the total number of tasks explicitly cancelled via [TimeWheel.RemoveTimer].
	Removed int64
}

Stats is a snapshot of runtime counters. All fields are read atomically.

type TimeWheel

type TimeWheel[T any] struct {
	// contains filtered or unexported fields
}

TimeWheel is a generic timer wheel.

T is the type of the data payload stored with each timer. Use New to create a TimeWheel; the zero value is not usable.

func New

func New[T any](interval time.Duration, slotNum int, defaultJob Job[T], opts ...Option[T]) (*TimeWheel[T], error)

New creates and initialises a new TimeWheel.

Parameters:

  • interval: tick resolution; the minimum timer precision equals interval.
  • slotNum: number of slots in the wheel. Together with interval this determines the maximum delay before circle counting kicks in: maxDirect = interval × slotNum.
  • defaultJob: callback invoked for tasks that have no per-task job. May be nil if every timer is registered via TimeWheel.AddTimerWithJob.
  • opts: zero or more Option values.

New returns an error if interval or slotNum are not positive.

func (*TimeWheel[T]) AddRepeating

func (tw *TimeWheel[T]) AddRepeating(delay time.Duration, data T) uint64

AddRepeating enqueues a recurring timer. After firing, the task is automatically re-enqueued with the same delay using the wheel's default job. It returns a key that can be passed to TimeWheel.RemoveTimer to stop the repetition, or to TimeWheel.NextFireTime to inspect the next scheduled fire time.

func (*TimeWheel[T]) AddRepeatingWithJob

func (tw *TimeWheel[T]) AddRepeatingWithJob(delay time.Duration, data T, job Job[T]) uint64

AddRepeatingWithJob is like TimeWheel.AddRepeating but uses a per-task job.

func (*TimeWheel[T]) AddTimer

func (tw *TimeWheel[T]) AddTimer(delay time.Duration, data T) uint64

AddTimer enqueues a one-shot timer that fires after delay using the wheel's default job. It returns a key that can be passed to TimeWheel.RemoveTimer or TimeWheel.NextFireTime.

If delay is shorter than the wheel's tick interval it is rounded up to one tick.

func (*TimeWheel[T]) AddTimerFunc

func (tw *TimeWheel[T]) AddTimerFunc(delay time.Duration, fn func()) uint64

AddTimerFunc enqueues a one-shot timer that calls fn after delay. No payload is required; fn captures any needed state via closure. It returns a key that can be passed to TimeWheel.RemoveTimer or TimeWheel.NextFireTime.

func (*TimeWheel[T]) AddTimerWithJob

func (tw *TimeWheel[T]) AddTimerWithJob(delay time.Duration, data T, job Job[T]) uint64

AddTimerWithJob is like TimeWheel.AddTimer but uses job instead of the wheel's default job. job must not be nil.

func (*TimeWheel[T]) NextFireTime

func (tw *TimeWheel[T]) NextFireTime(key uint64) (time.Time, bool)

NextFireTime returns the wall-clock time at which the timer identified by key is next expected to fire, and whether the key is known to the wheel.

For repeating timers the returned time reflects the beginning of the current period (i.e. it advances after every execution).

The returned time is an estimate: it is computed when the task is enqueued and is not adjusted for scheduling jitter. The actual fire time may be up to one tick interval later than the returned value.

NextFireTime returns (zero, false) if the key does not exist — either because it was never registered, has already fired (one-shot), or was removed via TimeWheel.RemoveTimer.

func (*TimeWheel[T]) PendingTimers

func (tw *TimeWheel[T]) PendingTimers() []TimerInfo

PendingTimers returns a snapshot of all timers currently queued in the wheel, sorted by ascending NextFireAt time.

The snapshot is taken under a read lock on the internal index, so it reflects a consistent moment in time. The returned slice is freshly allocated on every call; callers are free to modify it.

func (*TimeWheel[T]) RemoveTimer

func (tw *TimeWheel[T]) RemoveTimer(key uint64)

RemoveTimer cancels the timer identified by key. RemoveTimer is a no-op if the key is unknown or the timer has already fired.

func (*TimeWheel[T]) Start

func (tw *TimeWheel[T]) Start(ctx context.Context)

Start launches the event loop in a background goroutine. The wheel runs until ctx is cancelled. Call TimeWheel.Wait to block until the event loop has fully exited.

Start must be called exactly once.

func (*TimeWheel[T]) Stats

func (tw *TimeWheel[T]) Stats() Stats

Stats returns a snapshot of the wheel's runtime counters.

func (*TimeWheel[T]) Wait

func (tw *TimeWheel[T]) Wait()

Wait blocks until the event loop goroutine started by TimeWheel.Start exits.

type TimerInfo

type TimerInfo struct {
	// Key is the unique timer identifier returned by the Add* methods.
	Key uint64

	// NextFireAt is the wall-clock time at which the timer is next expected to fire.
	// For repeating timers this reflects the start of the current period.
	NextFireAt time.Time

	// Delay is the original delay passed to the Add* method.
	Delay time.Duration

	// Repeating is true if the timer was registered via AddRepeating or
	// AddRepeatingWithJob.
	Repeating bool
}

TimerInfo describes a single live timer as returned by TimeWheel.PendingTimers.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL