{} The Go Reference

Processes · Systems · Intermediate

Scheduling & Watching

Programs that wake on a schedule or a change — tickers and timers, cron-style scheduling, watching the filesystem with fsnotify, and log rotation.

Processes Intermediate ⏱ 4 min read Complete

⏰ Analogy

Two kinds of “wake me up.” One is an alarm clock: ring every morning at the same interval, whether or not anything happened — that’s a ticker doing periodic work. The other is a doorbell: stay quiet until something actually arrives, then notify instantly — that’s the OS telling you a file changed, via fsnotify. Polling (“check the door every second”) works but wastes effort; the doorbell (kernel notifications) is both cheaper and faster. Good system software uses the alarm for routine chores and the doorbell for reacting to change.

Periodic work: tickers

For “do something every N”, use a time.Ticker and select on it alongside cancellation so the loop shuts down cleanly. This runs anywhere:

ticker.go — editable & runnable
package main

import (
"context"
"fmt"
"time"
)

func main() {
// Cancel after a short while so the demo terminates.
ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
defer cancel()

ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop() // release the runtime timer — don't leak it

n := 0
for {
	select {
	case t := <-ticker.C: // fires every 50ms
		n++
		fmt.Printf("tick %d at +%dms\n", n, t.Sub(t.Truncate(time.Second)).Milliseconds()%1000)
	case <-ctx.Done(): // shutdown / cancellation
		fmt.Printf("stopped after %d ticks\n", n)
		return
	}
}
}

The select { case <-ticker.C ... case <-ctx.Done() ... } shape is the canonical periodic loop: it does work on each tick and exits the instant the context is cancelled (a signal, a deadline, a shutdown). A for { time.Sleep(d) } loop can’t do the second part.

For a one-shot delay use time.NewTimer / time.After; for cron-style calendar schedules (0 3 * * *), reach for an in-process scheduler like github.com/robfig/cron.

Reacting to change: fsnotify

Polling a directory (ReadDir in a loop) is wasteful and laggy. The OS can push you change events through its native API — inotify on Linux, kqueue on BSD/macOS, ReadDirectoryChangesW on Windows — and github.com/fsnotify/fsnotify wraps all three behind one channel:

watcher, _ := fsnotify.NewWatcher()
defer watcher.Close()
watcher.Add("/etc/myapp") // watch a directory (or file)

for {
	select {
	case ev := <-watcher.Events:
		if ev.Op&fsnotify.Write != 0 {
			log.Println("changed, reloading:", ev.Name)
			reloadConfig(ev.Name)
		}
	case err := <-watcher.Errors:
		log.Println("watch error:", err)
	}
}
graph LR
FS["file changes"] -->|"inotify / kqueue / Win32"| W["fsnotify watcher"]
W -->|"Events channel"| APP["reload config / reindex / rotate"]

This is how tools do live config reload, hot asset rebuilds, and watch-mode test runners.

Log rotation

A long-running daemon’s log file grows forever. Rotation caps it: when the file passes a size (or on a schedule), rename it aside (app.logapp.log.1), open a fresh app.log, and delete old generations. Libraries like gopkg.in/natefinch/lumberjack.v2 implement this as an io.Writer you point your logger at:

log.SetOutput(&lumberjack.Logger{
	Filename:   "/var/log/app.log",
	MaxSize:    100, // MB before rotating
	MaxBackups: 3,   // keep app.log.1..3
	MaxAge:     28,  // days
	Compress:   true,
})

🐹 Keep scheduling in-process

A big part of why Go suits infrastructure is that one static binary can own its own timers, watchers, and rotation — no system cron, no external logrotate, no extra moving parts to deploy. Tickers + select-on-ctx, fsnotify for change, and a rotating io.Writer for logs let a single Go service schedule and react entirely on its own. That self-containment is exactly the property that makes Go binaries easy to ship and operate.

⚠️ Stop your tickers; debounce your watchers

Always defer ticker.Stop() — an unstopped ticker leaks a runtime timer and can pin a goroutine forever. fsnotify is chatty — a single “save” in an editor often fires multiple Write/Create/Rename events (and editors write via temp-file-rename, so you may see the temp file), so debounce: wait a beat after the last event before acting, and re-Add watches on directories since some editors replace files. And watching a directory is not recursive — add subdirectories yourself (or use a helper that walks the tree).

See also

Next: the simplest way two processes talk — pipes.

Check your understanding

Score: 0 / 5

1. What's the difference between time.Timer and time.Ticker?

time.NewTimer(d) sends one value on its channel after d. time.NewTicker(d) sends on every tick of d until you Stop it. Use a Timer for a one-shot delay/timeout, a Ticker for periodic work (poll every 30s).

2. Why must you call Stop() on a Ticker?

time.NewTicker allocates a runtime timer that keeps firing. `defer ticker.Stop()` releases it; otherwise it lingers for the program's life, and any goroutine ranging its channel may never exit — a classic leak.

3. How do you watch a directory for file changes (created/modified/deleted) efficiently?

github.com/fsnotify/fsnotify wraps each OS's kernel notification mechanism (Linux inotify, BSD/macOS kqueue, Windows ReadDirectoryChangesW) so you get Create/Write/Remove/Rename events pushed to a channel — far cheaper and timelier than polling.

4. For periodic work that must respect cancellation, what's the idiomatic loop?

Select on both the ticker and ctx.Done() so the loop does periodic work AND exits promptly on cancellation/shutdown. A bare time.Sleep loop can't be cancelled mid-sleep and ignores shutdown signals.

5. What's the simplest robust approach to scheduled jobs (cron-like) in a Go service?

For 'every N minutes', a ticker loop is enough. For real calendar schedules ('0 3 * * *' = 3am daily), an in-process scheduler like robfig/cron parses cron expressions and runs jobs on goroutines — keeping scheduling inside your single binary rather than depending on system cron.

Comments

Sign in with GitHub to join the discussion.