Day 43 — time, timers & tickers

Updated

July 30, 2026

Day 43 — time, timers & tickers

Stage V · ~3h
Goal: Use time correctly for scheduling and deadlines—locations, monotonic measurements, timers/tickers that you actually stop—and build a context-aware mini-scheduler.

Why this day exists

Distributed systems fail on time:

  • “Works in UTC, breaks in local TZ”
  • Comparing wall clocks across machines
  • Leaking time.Ticker goroutines
  • Using time.Sleep in hot loops without cancellation

time is small API surface, large operational impact.


Theory 1 — Instant, duration, location

now := time.Now()              // wall + monotonic reading
d := 500 * time.Millisecond
later := now.Add(d)
elapsed := time.Since(now)     // uses monotonic when possible
delta := later.Sub(now)

Duration

const day = 24 * time.Hour
d, err := time.ParseDuration("1h30m") // "300ms", "2h45m"

Format for humans: d.String()"1h30m0s".

Wall vs monotonic

time.Now() stores wall clock and monotonic clock. Subtractions between Time values from Now ignore wall adjustments (NTP step) for elapsed measurement. Serializing to JSON loses monotonic reading—only wall remains after Marshal/Unmarshal.


Theory 2 — Parsing and formatting

Go’s reference time is not YYYY-MM-DD. It is:

Mon Jan 2 15:04:05 MST 2006
t, err := time.Parse(time.RFC3339, "2026-07-27T15:04:05Z")
s := t.UTC().Format(time.RFC3339Nano)
// custom:
s = t.Format("2006-01-02 15:04:05")

Prefer predefined layouts (RFC3339, DateOnly since 1.20, etc.) when possible.

Locations

loc, err := time.LoadLocation("America/New_York")
local := t.In(loc)

time.Local depends on machine config. Servers: store UTC, display with explicit location.

time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)

Theory 3 — Timer and Ticker

Timer (once)

timer := time.NewTimer(2 * time.Second)
defer timer.Stop()
select {
case <-timer.C:
    // fired
case <-ctx.Done():
    if !timer.Stop() {
        <-timer.C // drain if stop lost the race
    }
    return ctx.Err()
}

Or time.After (convenient, but cannot be garbage-collected until it fires—avoid in tight loops).

Ticker (periodic)

ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
    select {
    case t := <-ticker.C:
        _ = t // tick time
    case <-ctx.Done():
        return ctx.Err()
    }
}

Always Stop tickers you create when the loop ends.

time.Tick

Discouraged for long-lived processes—no way to stop. Prefer NewTicker.


Theory 4 — Sleep and cancellation

// cancellable sleep
func Sleep(ctx context.Context, d time.Duration) error {
    timer := time.NewTimer(d)
    defer timer.Stop()
    select {
    case <-timer.C:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

Never block shutdown paths on uncancellable time.Sleep.


Theory 5 — Deadlines for I/O (preview)

deadline := time.Now().Add(5 * time.Second)
_ = conn.SetDeadline(deadline)

Day 44 uses this on sockets; the idea starts here: absolute deadlines often beat stacked relative sleeps.


Worked example — mini scheduler

package sched

import (
    "context"
    "log/slog"
    "time"
)

// Every runs fn immediately (optional) then every interval until ctx cancel.
func Every(ctx context.Context, interval time.Duration, runImmediately bool, fn func(context.Context) error) error {
    if interval <= 0 {
        return errNonPositive
    }
    if runImmediately {
        if err := fn(ctx); err != nil {
            return err
        }
    }
    t := time.NewTicker(interval)
    defer t.Stop()
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-t.C:
            if err := fn(ctx); err != nil {
                slog.Error("tick failed", "err", err)
                // choose: continue or return — document policy
            }
        }
    }
}

Define errNonPositive with errors.New. Production schedulers add jitter, overlap guards (sync.Mutex or skip if previous still running), and metrics—keep today focused.

Overlap-safe variant sketch

var gate sync.Mutex
// on tick:
if !gate.TryLock() {
    return // skip overlapping run
}
defer gate.Unlock()
_ = fn(ctx)

(TryLock is available in modern Go sync.Mutex.)


Lab 1 — Setup

mkdir -p ~/lab/90daysofx/01-go/day43
cd ~/lab/90daysofx/01-go/day43
go mod init example.com/day43

Lab 2 — Duration & location drills

Write tests:

  1. ParseDuration("1500ms") equals 1500 * time.Millisecond
  2. Round-trip RFC3339 UTC
  3. Convert a fixed instant between UTC and Asia/Kolkata (or your TZ)—assert hour offset
func TestRFC3339RoundTrip(t *testing.T) {
    in := "2026-01-02T03:04:05Z"
    ts, err := time.Parse(time.RFC3339, in)
    if err != nil {
        t.Fatal(err)
    }
    if ts.UTC().Format(time.RFC3339) != in {
        t.Fatal(ts)
    }
}

Lab 3 — Scheduler with cancel

Implement Every and test with a fake clock or short real intervals:

func TestEveryCancel(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    var n atomic.Int32
    done := make(chan error, 1)
    go func() {
        done <- Every(ctx, 20*time.Millisecond, true, func(context.Context) error {
            n.Add(1)
            return nil
        })
    }()
    time.Sleep(70 * time.Millisecond)
    cancel()
    err := <-done
    if !errors.Is(err, context.Canceled) {
        t.Fatalf("err=%v", err)
    }
    if n.Load() < 2 {
        t.Fatalf("ticks=%d", n.Load())
    }
}

Lab 4 — Cancellable sleep helper

Unit test that Sleep(ctx, time.Hour) returns quickly when cancel() is called.


Lab 5 — Wall clock journaling (stretch)

Log time.Now().Format(time.RFC3339Nano) every second for 5s. Change system TZ mid-lab if you can; note display changes vs UTC storage practice.


Common gotchas

Gotcha Fix
time.Tick leak NewTicker + Stop
time.After in loop NewTimer + reset/stop carefully
Local TZ in APIs UTC on wire
Wrong layout (YYYY) Use 2006 reference
Ignoring ticker.Stop Always defer Stop
Sleep without ctx Cancellable helper
Comparing times from different zones carelessly Normalize with .UTC() or .Equal

Checkpoint

  • Explain monotonic vs wall in one sentence
  • Format/parse RFC3339 correctly
  • Scheduler stops on cancel and stops ticker
  • Overlap policy documented (skip vs queue)
  • No time.Tick in long-lived code
  • Cancellable sleep tested

Commit

git add .
git commit -m "day43: time scheduler with tickers and cancel"

Write three personal gotchas before continuing.


Tomorrow

Day 44 — net basics: TCP listen/dial, net.Conn deadlines, and an echo server + client you can test on localhost.