Day 43 — time, timers & tickers
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.Tickergoroutines
- Using
time.Sleepin 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/day43Lab 2 — Duration & location drills
Write tests:
ParseDuration("1500ms")equals1500 * time.Millisecond
- Round-trip RFC3339 UTC
- Convert a fixed instant between
UTCandAsia/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.Tickin 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.