⏰ 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:
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.log → app.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
- signals — the other way programs react to the outside world.
- processes & exec — running a command on a schedule.
- time (stdlib) — timers, tickers, durations in depth.
- context & cancellation (patterns) — stopping the periodic loop.
Next: the simplest way two processes talk — pipes.
Related topics
Reacting to OS signals in Go — os/signal.Notify, catching SIGINT/SIGTERM for graceful shutdown, signal.NotifyContext, and ignoring or resetting signals.
processesProcesses & execLaunching and managing external programs in Go — os/exec, wiring up stdin/stdout/env, Run vs Start vs Output, process IDs, and killing children.
Check your understanding
Score: 0 / 51. 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.