pll

package module
v0.3.2 Latest Latest
Warning

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

Go to latest
Published: Nov 12, 2026 License: MIT Imports: 8 Imported by: 0

README

pll

github.com/version2beta/pll is a Go library for keeping time: it paces work at an exact rate, and follows another clock with a phase-locked loop.

Also published at version2beta.com.

The need

Programs that produce or consume things at a steady rate run into the same two problems.

  • Keeping a steady pace on your own clock. Sending samples, frames, packets or requests at a fixed rate sounds like a timer. But a timer that adds a rounded period on every tick drifts, a rate like 30000/1001 per second has no exact period in nanoseconds, and a program that wakes up late has to decide what happens to the slots it missed.
  • Keeping pace with someone else's clock. A sound card, a sensor, a remote server or another program each runs on its own crystal, a little fast or slow, and its readings reach you late and unevenly. To put its timestamps on your timeline, or to keep a device with its own clock fed without overrunning or starving it, you have to estimate how its clock relates to yours, continuously, without being fooled by jitter.

pll has a part for each, and they work alone or together.

  • A Pacer numbers evenly spaced slots at an exact rate and tells you which slots are due. Each slot's time is computed from its number with exact rational arithmetic, so it never drifts.
  • A Follower is a phase-locked loop. Fed readings of another clock taken against your own, it estimates how far apart the two clocks are and how fast the other one runs. It filters out jitter, rejects outliers, notices when the other clock jumps, and translates times in both directions. Its trim can drive a Pacer.

The package never reads a clock, starts a goroutine or touches your data. You pass the time in, as a time.Duration from an origin you choose, and the hot paths allocate nothing.

At a glance

Version v0.3.2
Go 1.23 or newer; standard library only
License MIT
Install go get github.com/version2beta/pll@v0.3.2
Repository github.com/version2beta/pll
API reference pkg.go.dev/github.com/version2beta/pll

Quick start

Pace 48,000 samples per second. Wake up on any schedule; Due hands back whatever has come due since the last call.

package main

import (
	"fmt"
	"time"

	"github.com/version2beta/pll"
)

func main() {
	rate, err := pll.UnitsPerSecond(48_000, 1)
	if err != nil {
		panic(err)
	}
	p, err := pll.NewPacer(rate)
	if err != nil {
		panic(err)
	}

	for _, now := range []time.Duration{0, 10 * time.Millisecond, 25 * time.Millisecond} {
		span, err := p.Due(now, 4096)
		if err != nil {
			panic(err)
		}
		fmt.Printf("at %v: %d due, slots %d to %d\n", now, span.Count, span.First, span.First+span.Count-1)
	}
}
at 0s: 1 due, slots 0 to 0
at 10ms: 480 due, slots 1 to 480
at 25ms: 720 due, slots 481 to 1200

In a real program now is time.Since(start). A late wake-up loses nothing: the overdue slots come back together. span.Time(i) is each slot's scheduled time, rounded down to whole nanoseconds, and p.NextDue() says when the next slot falls due.

Examples

A pacing loop

A Pacer has one owner. Here a goroutine owns it, wakes every 10 ms, and hands each batch of due slots to a consumer.

// pace owns the Pacer: it wakes every 10 ms and sends whatever has come due.
func pace(ctx context.Context, p *pll.Pacer, out chan<- pll.Span) error {
	start := time.Now()
	tick := time.NewTicker(10 * time.Millisecond)
	defer tick.Stop()
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-tick.C:
			span, err := p.Due(time.Since(start), 4096)
			if err != nil {
				return err
			}
			if span.Count > 0 {
				out <- span
			}
		}
	}
}

func main() {
	rate, _ := pll.UnitsPerSecond(48_000, 1)
	p, _ := pll.NewPacer(rate)

	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	spans := make(chan pll.Span)
	go func() {
		pace(ctx, p, spans)
		close(spans)
	}()

	var units uint64
	for span := range spans {
		units += span.Count // write span.Count units, stamped with span.Time(i)
	}
	fmt.Println("units in one second:", units) // about 48,000
}

A Span is a small value that carries its own timing, so it is safe to send to another goroutine.

Variable-size units at a byte rate

Give each byte a slot, and claim one slot per byte of each datagram.

rate, _ := pll.BitsPerSecond(50_000_000, 8) // 50 Mbit/s, one slot per byte
p, _ := pll.NewPacer(rate)
for _, size := range []uint64{1500, 400, 1500} {
	span, _ := p.Claim(size)
	fmt.Printf("%4d bytes: send at %v\n", size, span.Time(0))
}
1500 bytes: send at 0s
 400 bytes: send at 240µs
1500 bytes: send at 304µs
Keeping a device with its own clock fed

A device that consumes units on its own clock tells you how many it has consumed. That count is a reading of its clock. Follow it, and let the follower trim the pacer that feeds it.

rate, _ := pll.UnitsPerSecond(48000, 1)
p, _ := pll.NewPacer(rate)
f, _ := pll.NewFollower(pll.DefaultFollowerConfig(rate))

consumed := func(t time.Duration) int64 { // the device runs 40 ppm fast
	return int64(t.Seconds() * 48000 * (1 + 40e-6))
}
for t := time.Duration(0); t <= 5*time.Minute; t += 100 * time.Millisecond {
	f.Observe(t, consumed(t))
	p.SetTrim(f.Trim())
}
fmt.Printf("pacer trim %.1f ppm\n", float64(p.Trim())/1e6) // pacer trim 40.3 ppm

In the simulator, a linked pacer holds the device's queue within one unit for five minutes; unlinked, the queue drifts by 576 units.

Behavior worth knowing

Rates
  • UnitsPerSecond(num, den) is num/den units per second, reduced to lowest terms; BitsPerSecond(bits, bitsPerUnit) is the same with bits/bitsPerUnit. A zero term returns ErrInvalidRate.
  • Any positive rate works. Above 10⁹ units per second, several slots share a nanosecond, and times are non-decreasing rather than strictly increasing.
Slots
  • Slot s is at exactly s × den × 10⁹ / num nanoseconds from the origin; Span.Time reports the floor. A slot is due when its exact time is at or before now, compared without rounding: a slot at 8/3 ns has time 2 ns but is due from 3 ns.
  • The cursor marks the next slot not yet handed out or skipped. It never moves back, so an earlier now than before finds nothing new and is not an error.
  • Due(now, max) hands out the oldest due slots, at most max. Claim(n) hands out the next n slots whether or not they are due. Skip(now, keep) passes over due slots, keeping the newest keep. NextDue() returns the cursor slot and the first whole nanosecond at which it is due.
  • A Span covers slots First to First+Count−1. Span.Time(i) panics when i >= Count, as slice indexing does. Span.All() is an iter.Seq2 over slots and times.
Trim
  • SetTrim(ppt) runs the pacer at nominal × (1 + ppt/10¹²) from the cursor on, for ppt strictly between −10¹² and 10¹².
  • The new timing starts at the floor of the cursor slot's exact time, so the phase moves by less than 1 ns and times never decrease. Spans already handed out keep their times. Setting the trim the pacer already has changes nothing.
The follower
  • The first reading after NewFollower or Reset anchors the mapping. After that, readings must not go back in local time, and remote ticks must increase; otherwise Observe returns ErrOrder and changes nothing.
  • Observe reports what it did with each reading: StatusAnchored, StatusAccepted, StatusPending (waiting in a window), StatusRejected (an outlier), or StatusStepped (enough agreeing outliers to show the other clock jumped; the mapping re-anchors and keeps its rate estimate).
  • State(now) is StateLocked after LockCount consecutive samples within LockThreshold, StateHoldover when no sample has been accepted for HoldoverAfter, and StateAcquiring otherwise. In holdover the mapping keeps running at the last rate estimate.
  • Local and Remote translate between remote ticks and local time. Offset is the estimated rate offset, Trim is that offset in parts per trillion for Pacer.SetTrim, and Jitter is a running RMS of recent phase errors.
  • The defaults suit readings with microseconds of jitter. Readings that cross a network need a slower loop that looks at the least-delayed reading in each window:
Setting Low jitter (the defaults) Network
Bandwidth 0.1 Hz 0.005 Hz
Window 0 (every reading) 1 s
LockThreshold 100 µs 2 ms
OutlierThreshold 5 ms 20 ms
In the simulator Locks in 80 ms; 12 µs RMS error with 20 µs of jitter Locks in 18–92 s; 0.7 ms RMS error with 2 ms of jitter and batched delivery

The loop equations, and every rule above in full, are in the specification.

Limits and errors
  • Errors are matched with errors.Is: ErrInvalidRate, ErrInvalidArgument (a negative time, a zero count, a trim out of range), ErrOverflow (a slot index past 2⁶⁴ − 2, or a time or mapping that does not fit in a time.Duration), ErrOrder and ErrNotAnchored. A call that returns an error changes nothing.
  • An invalid follower setting returns a *ConfigError naming the first bad field; use errors.As.
  • Pacer arithmetic is exact for every input, including 64-bit rate terms, slot numbers near 2⁶⁴ and times near the largest time.Duration. The follower's estimates are float64; its anchor is held as whole nanoseconds plus a fraction, so its precision does not depend on how long the program has run.
  • On an Apple M5, handing out one slot takes about 5 ns, and 64 slots with their times about 155 ns. Due, Claim, Skip, NextDue, Span.Time and Observe do not allocate.
Concurrency
  • A Pacer or a Follower is not safe for concurrent use. Give each one owner, as in the pacing loop above, or guard it yourself.
  • A Span is an immutable value and may be shared freely.

How we know it's correct

  • A deliberately naive reference for the pacer. It recomputes every slot time from scratch with big.Rat, straight from the formula, with no fast path.
  • Differential tests run thousands of random operation sequences against that reference, including extreme rates, trims, slot numbers near 2⁶⁴ and times near the largest time.Duration, and require identical results. A fuzz target does the same with generated input, and invariants are checked after every operation.
  • A literal model of the follower's loop equations checks every update, and invariant tests and a fuzz target check that the rate estimate stays in bounds, the mapping never runs backwards, and an update never moves the phase by more than the error that caused it.
  • A deterministic simulator runs the follower against clocks that are fast, slow, drifting, changing rate or jumping, delivered with jitter, heavy-tailed delays, batching and outages, and holds each scenario to thresholds.
  • Planted bugs, 8 in the pacer and 13 in the follower, were all caught. CI runs every test with the race detector on Go 1.23 and the current release, and fuzzes each target for 30 seconds.

TESTING.md describes each test file and the reasoning behind these checks.

Check it yourself
  1. Clone the repository and run the tests:
    git clone https://github.com/version2beta/pll.git
    cd pll
    make verify
    
  2. Fuzz each target against its reference for 30 seconds, and print the simulator's results:
    make fuzz
    make sim
    
            scenario  locked at  RMS error  max error  rate error  steps  rejected  end state
          sound-card       80ms     11.7µs     21.9µs   0.608 ppm      0         0     locked
      sensor-network    18.301s    667.4µs    987.1µs   7.029 ppm      1         2     locked
         rate-change       80ms     11.7µs     21.8µs   0.706 ppm      0         0     locked
              outage       80ms     11.6µs     22.3µs   2.246 ppm      0         0     locked
          clock-step       80ms     11.6µs     22.7µs   1.066 ppm      1         2     locked
       aging-crystal       80ms     11.7µs     25.3µs   1.903 ppm      0         0     locked
    
  3. Plant a bug. In pacer.go, find the line in ceilTime that rounds a slot's time up to the next whole nanosecond:
    if r != 0 {
    
    Change it to if r > 1 {, then run:
    go test -run TestDifferential
    
    The test fails immediately. It shows what the pacer returned, what the reference returned, and the operations that led there. Undo the change with git checkout pacer.go.

How it compares

pll golang.org/x/time/rate go.uber.org/ratelimit facebook/time servo
Purpose Exact pacing, and following another clock Rate limiting Rate limiting Steering a clock's frequency from measured offsets, in Meta's PTP and NTP libraries
Method Exact rational schedule; type-II phase-locked loop Token bucket Leaky bucket PI servo
Rate Exact fraction of units per second, with a trim float64 events per second, with a burst size Events per period, with optional slack Not applicable
Waiting You wake up; Due says what is due Allow, Reserve or a blocking Wait Take sleeps until the next event is allowed Not applicable
Time source Passed in time.Now, or a time passed to the …At methods A Clock, replaceable for tests Offsets passed in
Follows another clock Yes, from readings, with windowing, outlier rejection, steps and holdover No No Yes, from clock offsets, with a spike filter and holdover state

Outside Go, delay-locked loops such as PipeWire's spa_dll and FFmpeg's timefilter filter a device's clock against the system clock, configured by a single bandwidth.

When to choose something else:

  • If you want to limit how often something happens, with bursts allowed, x/time/rate is the standard choice; pll spaces slots evenly and has no burst allowance.
  • If you want a call that simply blocks until the next event is allowed, uber-go/ratelimit does that in one line; with pll you write the wake-up loop yourself.
  • If you are disciplining a system clock to PTP or NTP, use a servo built for that, such as the one in facebook/time, which works with clock offsets and frequency adjustments rather than readings of another clock.

Colophon

Design and copyright by M. Robert Martin.
Implemented with assistance from Claude Opus 5.5, Claude Haiku 5.5 and OpenAI gpt-6.1-sol.
License: MIT License.

Documentation

Overview

Package pll keeps time for programs that must produce or consume things at a steady rate, or follow a clock other than their own.

A Pacer divides time into evenly spaced, numbered slots at an exact Rate and says which slots are due. Every slot's time is computed from its number with exact rational arithmetic, so it never drifts, even at rates such as 30000/1001 units per second. A Pacer can run slightly faster or slower than its nominal rate through a trim, given in parts per trillion.

A Follower is a phase-locked loop. Fed readings of a remote clock taken against the local one, it learns how far apart the two clocks are and how fast the remote one runs, filters out delivery jitter and outliers, notices when the remote clock jumps, and maps any remote time to local time and back. Its Trim can drive a Pacer, so output keeps pace with a device that runs on its own clock.

The package never reads a clock, starts a goroutine or touches your data. Every call that depends on the present takes a time you pass in, a duration from an origin you choose, and the hot paths allocate nothing.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidRate reports a rate with a zero numerator or denominator,
	// including the zero Rate.
	ErrInvalidRate = errors.New("pll: invalid rate")

	// ErrInvalidArgument reports a negative time, a zero count, or a trim
	// outside the open interval (-1e12, 1e12).
	ErrInvalidArgument = errors.New("pll: invalid argument")

	// ErrOverflow reports a slot index or a slot time that does not fit.
	ErrOverflow = errors.New("pll: overflow")
)
View Source
var (
	// ErrOrder reports a reading whose local time went backwards or whose
	// remote ticks did not increase. The reading is ignored.
	ErrOrder = errors.New("pll: reading out of order")

	// ErrNotAnchored reports a mapping requested before the first reading.
	ErrNotAnchored = errors.New("pll: follower has no reading yet")
)

Functions

This section is empty.

Types

type ConfigError

type ConfigError struct {
	Field  string // the field name, such as "Bandwidth"
	Reason string // what the field must satisfy, such as "must be finite and > 0"
}

ConfigError reports an invalid FollowerConfig field.

func (*ConfigError) Error

func (e *ConfigError) Error() string

Error returns, for example, "pll: FollowerConfig.Bandwidth must be finite and > 0".

type Follower

type Follower struct {
	// contains filtered or unexported fields
}

Follower is a second-order (type-II) phase-locked loop that tracks a remote clock against the local clock from (local time, remote ticks) readings. It is not safe for concurrent use. Construct it with NewFollower.

Example

Put a sensor's readings on the local timeline. The sensor counts microseconds on its own crystal, which runs 50 ppm fast.

package main

import (
	"fmt"
	"time"

	"github.com/version2beta/pll"
)

func main() {
	nominal, _ := pll.UnitsPerSecond(1_000_000, 1)
	f, _ := pll.NewFollower(pll.DefaultFollowerConfig(nominal))

	sensor := func(local time.Duration) int64 { // the sensor's counter at local time
		return int64(local.Seconds() * 1e6 * (1 + 50e-6))
	}
	for local := time.Duration(0); local <= time.Minute; local += 10 * time.Millisecond {
		f.Observe(local, sensor(local))
	}
	when, _ := f.Local(sensor(time.Minute + 500*time.Millisecond))
	fmt.Printf("rate offset %.1f ppm, %v\n", f.Offset()*1e6, f.State(time.Minute))
	fmt.Println("a reading taken at 60.5 s maps to", when.Round(time.Microsecond))
}
Output:
rate offset 50.0 ppm, locked
a reading taken at 60.5 s maps to 1m0.5s

func NewFollower

func NewFollower(cfg FollowerConfig) (*Follower, error)

NewFollower returns a Follower with no readings. An invalid field returns nil and a *ConfigError naming the first bad field in declaration order.

func (*Follower) Jitter

func (f *Follower) Jitter() time.Duration

Jitter returns the root mean square of accepted samples' phase errors, weighted 1/16 per sample, rounded to the nearest nanosecond. It is 0 until two samples have been accepted since the last anchor, step or Reset.

func (*Follower) Local

func (f *Follower) Local(remote int64) (time.Duration, error)

Local maps remote ticks to local time, rounded to the nearest nanosecond.

func (*Follower) Observe

func (f *Follower) Observe(local time.Duration, remote int64) (Observation, error)

Observe feeds one reading: the remote clock showed remote ticks at local time local. It returns what happened to the reading and, for a sample fed to the loop, its phase error. A reading out of order returns ErrOrder, and a reading whose loop update would move the anchor beyond the range of time.Duration returns ErrOverflow; either way nothing changes.

func (*Follower) Offset

func (f *Follower) Offset() float64

Offset returns the estimated fractional rate offset y: the remote clock runs at nominal × (1 + y) as measured by the local clock.

func (*Follower) Remote

func (f *Follower) Remote(local time.Duration) (int64, error)

Remote maps local time to remote ticks, rounded to the nearest tick.

func (*Follower) Reset

func (f *Follower) Reset()

Reset forgets every reading, the rate estimate included; the next reading anchors.

func (*Follower) State

func (f *Follower) State(now time.Duration) State

State returns StateHoldover when now is more than HoldoverAfter past the last accepted sample, and otherwise the lock state.

func (*Follower) Trim

func (f *Follower) Trim() int64

Trim returns round(y × 1e12), ready for Pacer.SetTrim.

Example

Keep a device with its own clock fed: the units it has consumed are a reading of its clock, and the follower's trim makes the pacer keep up.

package main

import (
	"fmt"
	"time"

	"github.com/version2beta/pll"
)

func main() {
	rate, _ := pll.UnitsPerSecond(48000, 1)
	p, _ := pll.NewPacer(rate)
	f, _ := pll.NewFollower(pll.DefaultFollowerConfig(rate))

	consumed := func(t time.Duration) int64 { // the device runs 40 ppm fast
		return int64(t.Seconds() * 48000 * (1 + 40e-6))
	}
	for t := time.Duration(0); t <= 5*time.Minute; t += 100 * time.Millisecond {
		f.Observe(t, consumed(t))
		_ = p.SetTrim(f.Trim())
	}
	fmt.Printf("pacer trim %.0f ppm\n", float64(p.Trim())/1e6)
}
Output:
pacer trim 40 ppm

type FollowerConfig

type FollowerConfig struct {
	// Nominal is the remote clock's nominal tick rate, in ticks per second.
	Nominal Rate
	// Bandwidth is the loop's natural frequency in Hz. Lower filters more
	// jitter and settles more slowly.
	Bandwidth float64
	// Damping is the loop's damping ratio.
	Damping float64
	// MaxOffsetPPM bounds the rate offset the loop will estimate, in parts
	// per million.
	MaxOffsetPPM float64
	// Window groups readings into windows of this much local time and feeds
	// the loop only the earliest-looking reading of each. 0 feeds every reading.
	Window time.Duration
	// OutlierThreshold: a sample whose phase error exceeds this is rejected.
	OutlierThreshold time.Duration
	// StepAfter is how many consecutive, agreeing rejections force a phase step.
	StepAfter int
	// LockThreshold is the largest phase error that counts toward lock.
	LockThreshold time.Duration
	// LockCount is how many consecutive good samples declare lock.
	LockCount int
	// HoldoverAfter is how long without an accepted sample before State
	// reports holdover.
	HoldoverAfter time.Duration
}

FollowerConfig configures a Follower. Start from DefaultFollowerConfig and change what you need; NewFollower checks every field.

func DefaultFollowerConfig

func DefaultFollowerConfig(nominal Rate) FollowerConfig

DefaultFollowerConfig returns the defaults for a remote clock with the given nominal tick rate: Bandwidth 0.1 Hz, Damping √2/2, MaxOffsetPPM 500, Window 0, OutlierThreshold 5 ms, StepAfter 3, LockThreshold 100 µs, LockCount 8, HoldoverAfter 5 s.

type Observation

type Observation struct {
	Status Status
	Error  time.Duration // phase error of the sample fed to the loop, ns
}

Observation reports what Observe did with a reading.

type Pacer

type Pacer struct {
	// contains filtered or unexported fields
}

Pacer divides time into evenly spaced slots numbered 0, 1, 2, … at an exact rate and hands them out in order. Slot 0 is due at time zero. A cursor marks the next slot not yet handed out or skipped; it never moves back.

Times are durations from an origin the caller chooses; the Pacer never reads a clock. A Pacer is not safe for concurrent use. Construct it with NewPacer; the zero Pacer is not usable.

func NewPacer

func NewPacer(r Rate) (*Pacer, error)

NewPacer returns a Pacer for rate r with no trim. An invalid Rate returns nil and ErrInvalidRate.

func (*Pacer) Claim

func (p *Pacer) Claim(n uint64) (Span, error)

Claim hands out the next n slots whether or not they are due, and moves the cursor past them.

Errors: ErrInvalidArgument when n is 0; ErrOverflow when the span would reach slot math.MaxUint64 or its last slot's time would exceed the largest time.Duration. On error nothing changes.

Example

Send variable-size datagrams at 50 Mbit/s: one slot per byte.

package main

import (
	"fmt"

	"github.com/version2beta/pll"
)

func main() {
	rate, _ := pll.BitsPerSecond(50_000_000, 8)
	p, _ := pll.NewPacer(rate)

	for _, size := range []uint64{1500, 400, 1500} {
		span, _ := p.Claim(size)
		fmt.Printf("%4d bytes: send at %v\n", size, span.Time(0))
	}
}
Output:
1500 bytes: send at 0s
 400 bytes: send at 240µs
1500 bytes: send at 304µs

func (*Pacer) Due

func (p *Pacer) Due(now time.Duration, max uint64) (Span, error)

Due hands out the oldest slots whose exact time is at or before now, at most max of them, and moves the cursor past them. When nothing is due it returns a Span with Count 0 and First at the cursor. A now earlier than before is not an error; it finds nothing new.

Errors: ErrInvalidArgument when now < 0 or max is 0; ErrOverflow when the cursor has reached math.MaxUint64 and that slot is due. On error nothing changes.

Example

Pace audio samples at 48 kHz: wake on any cadence and write what came due.

package main

import (
	"fmt"
	"time"

	"github.com/version2beta/pll"
)

func main() {
	rate, _ := pll.UnitsPerSecond(48_000, 1)
	p, _ := pll.NewPacer(rate)

	for _, now := range []time.Duration{0, 10 * time.Millisecond, 25 * time.Millisecond} {
		span, _ := p.Due(now, 4096)
		fmt.Printf("at %v: %d samples, slots %d-%d\n", now, span.Count, span.First, span.First+span.Count-1)
	}
}
Output:
at 0s: 1 samples, slots 0-0
at 10ms: 480 samples, slots 1-480
at 25ms: 720 samples, slots 481-1200

func (*Pacer) NextDue

func (p *Pacer) NextDue() (slot uint64, at time.Duration, err error)

NextDue returns the cursor slot and the earliest whole nanosecond at which it is due: the ceiling of its exact time. A Due call at that time or later hands it out.

Errors: ErrOverflow when the cursor has reached math.MaxUint64 or that time exceeds the largest time.Duration; slot and at are then zero.

func (*Pacer) SetTrim

func (p *Pacer) SetTrim(ppt int64) error

SetTrim sets the rate to nominal × (1 + ppt/1e12) for the cursor slot and every slot after it. Spans already handed out keep their times.

The new timing starts at the floor of the cursor slot's exact time under the old timing, so the phase moves by less than 1 ns and times never decrease. Setting the trim it already has changes nothing.

Errors: ErrInvalidArgument when ppt <= -1e12 or ppt >= 1e12; ErrOverflow when the cursor slot's time exceeds the largest time.Duration. On error nothing changes.

Example

Follow a device whose clock runs 50 ppm fast.

package main

import (
	"fmt"

	"github.com/version2beta/pll"
)

func main() {
	rate, _ := pll.UnitsPerSecond(1000, 1)
	p, _ := pll.NewPacer(rate)
	p.Claim(1000)
	_ = p.SetTrim(50_000_000) // 50 ppm = 50,000,000 parts per trillion
	span, _ := p.Claim(1001)
	fmt.Println(span.Time(1000))
}
Output:
1.999950002s

func (*Pacer) Skip

func (p *Pacer) Skip(now time.Duration, keep uint64) (uint64, error)

Skip moves the cursor past the slots that are due at now without handing them out, except the newest keep of them, and returns how many it skipped. Use it when there is nothing to put in old slots: Skip(now, k) followed by Due(now, k) hands out the newest k due slots.

Errors: ErrInvalidArgument when now < 0; ErrOverflow when the cursor has reached math.MaxUint64 and that slot is due. On error nothing changes.

func (*Pacer) Trim

func (p *Pacer) Trim() int64

Trim returns the current trim in parts per trillion.

type Rate

type Rate struct {
	// contains filtered or unexported fields
}

Rate is an exact number of units per second, held as a fraction reduced to lowest terms. Construct one with UnitsPerSecond or BitsPerSecond; the zero Rate is invalid.

func BitsPerSecond

func BitsPerSecond(bits, bitsPerUnit uint64) (Rate, error)

BitsPerSecond returns the rate of bitsPerUnit-bit units that carries bits bits per second: bits/bitsPerUnit units per second, reduced to lowest terms. BitsPerSecond(50_000_000, 8) is 6250000 units (bytes) per second. A zero argument returns the zero Rate and ErrInvalidRate.

func UnitsPerSecond

func UnitsPerSecond(num, den uint64) (Rate, error)

UnitsPerSecond returns the rate num/den units per second, reduced to lowest terms: UnitsPerSecond(60000, 2002) has Num 30000 and Den 1001. A zero num or den returns the zero Rate and ErrInvalidRate.

func (Rate) Den

func (r Rate) Den() uint64

Den returns the denominator of the reduced fraction.

func (Rate) Num

func (r Rate) Num() uint64

Num returns the numerator of the reduced fraction.

func (Rate) String

func (r Rate) String() string

String returns "N units/s" when the denominator is 1, "N/D units/s" otherwise, and "invalid rate" for the zero Rate.

type Span

type Span struct {
	First, Count uint64
	// contains filtered or unexported fields
}

Span is a run of consecutive slots handed out by a Pacer: slots First through First+Count-1. It carries the timing it was issued under, so a later SetTrim never changes its times. A Span is a small value; copy it freely.

func (Span) All

func (s Span) All() iter.Seq2[uint64, time.Duration]

All yields each slot in the span with its time, in order.

Example

A rate that is not a whole number of units per second stays exact forever.

package main

import (
	"fmt"

	"github.com/version2beta/pll"
)

func main() {
	rate, _ := pll.UnitsPerSecond(30000, 1001)
	p, _ := pll.NewPacer(rate)
	span, _ := p.Claim(4)
	for slot, at := range span.All() {
		fmt.Println(slot, at)
	}
	fmt.Println(rate)
}
Output:
0 0s
1 33.366666ms
2 66.733333ms
3 100.1ms
30000/1001 units/s

func (Span) Time

func (s Span) Time(i uint64) time.Duration

Time returns the time of slot First+i: the floor of its exact time, in nanoseconds from the origin. It panics if i >= Count, like an index out of range.

type State

type State uint8

State is a follower's lock state.

const (
	// StateAcquiring: anchored or not yet, but not locked.
	StateAcquiring State = iota
	// StateLocked: LockCount consecutive accepted samples were within LockThreshold.
	StateLocked
	// StateHoldover: no sample accepted for more than HoldoverAfter; the
	// mapping keeps running at the last rate estimate.
	StateHoldover
)

func (State) String

func (s State) String() string

String returns the state in lower case, such as "locked", or "State(9)".

type Status

type Status uint8

Status says what Observe did with a reading.

const (
	// StatusAnchored: the first reading after NewFollower or Reset set the mapping.
	StatusAnchored Status = iota + 1
	// StatusAccepted: a sample was fed to the loop and used.
	StatusAccepted
	// StatusPending: the reading joined the current lower-envelope window.
	StatusPending
	// StatusRejected: the sample fed to the loop was an outlier and was ignored.
	StatusRejected
	// StatusStepped: enough agreeing outliers showed that the remote clock
	// jumped; the loop re-anchored on the latest one.
	StatusStepped
)

func (Status) String

func (s Status) String() string

String returns the status in lower case, such as "accepted", or "Status(9)".

Directories

Path Synopsis
cmd
pllsim command
Command pllsim runs the simulator scenarios against the Follower and prints what each one measured.
Command pllsim runs the simulator scenarios against the Follower and prints what each one measured.
internal
sim
Package sim is a deterministic simulator for exercising pll: remote clocks that are offset, fast or slow, drifting, changing rate or jumping; a delivery link that adds jitter, heavy-tailed delays, batching and outages; a device that drains a buffer at its own rate; and a caller that wakes up late.
Package sim is a deterministic simulator for exercising pll: remote clocks that are offset, fast or slow, drifting, changing rate or jumping; a delivery link that adds jitter, heavy-tailed delays, batching and outages; a device that drains a buffer at its own rate; and a caller that wakes up late.

Jump to

Keyboard shortcuts

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