go 107 lines · 3 tabs

Graceful Cron-Style Scheduler in Go With Ticker and Context Cancellation

Shared by codesnips Jul 2026
3 tabs
package scheduler

import (
	"context"
	"log"
	"sync"
	"time"
)

type Scheduler struct {
	ctx    context.Context
	cancel context.CancelFunc
	wg     sync.WaitGroup
}

func New(parent context.Context) *Scheduler {
	ctx, cancel := context.WithCancel(parent)
	return &Scheduler{ctx: ctx, cancel: cancel}
}

func (s *Scheduler) Add(job Job) {
	s.wg.Add(1)
	go s.run(job)
}

func (s *Scheduler) run(job Job) {
	defer s.wg.Done()

	ticker := time.NewTicker(job.Interval)
	defer ticker.Stop()

	for {
		select {
		case <-s.ctx.Done():
			log.Printf("job %q stopping: %v", job.Name, s.ctx.Err())
			return
		case <-ticker.C:
			if err := job.Handler(s.ctx); err != nil {
				log.Printf("job %q failed: %v", job.Name, err)
			}
		}
	}
}

func (s *Scheduler) Stop() {
	s.cancel()
	s.wg.Wait()
}
3 files · go Explain with highlit

This snippet demonstrates a small recurring-task scheduler in Go built around time.Ticker and context.Context, showing how to run periodic jobs and then stop them cleanly without leaking goroutines. The core problem it addresses is that naively spawning a goroutine with a ticker often leaves the goroutine blocked forever on the ticker channel when the program shuts down, and never calls ticker.Stop(), which leaks both the goroutine and the underlying runtime timer.

In scheduler.go, a Scheduler owns a context.Context and its cancel function, plus a sync.WaitGroup to track running jobs. Add registers a Job and launches a dedicated goroutine via run. Inside run, the ticker fires on an interval while a select also watches ctx.Done(); when the context is cancelled the loop returns, the deferred ticker.Stop() releases the timer, and the WaitGroup counter drops. This is the key pattern: every goroutine has exactly one owner and one cancellation signal.

The Stop method calls cancel() and then wg.Wait(), so it blocks until all in-flight ticks finish and every goroutine has exited. That makes shutdown deterministic — no orphaned work continues after Stop returns. Passing the derived context into each job.Handler also lets long-running handlers abort mid-execution rather than finishing an expensive operation during shutdown.

A subtle but important choice is how overlapping ticks are handled. Because each tick runs the handler synchronously within the goroutine, a slow handler naturally delays the next tick rather than piling up concurrent executions; time.Ticker drops ticks that arrive while the handler is busy. This avoids unbounded fan-out but means the effective interval can drift under load, a trade-off worth understanding.

In job.go, a Job is just a name, an interval, and a Handler function that receives the context, keeping the unit of work decoupled from scheduling. main.go wires it together: it registers two jobs, listens for SIGINT/SIGTERM via signal.NotifyContext, and calls Stop on shutdown. This structure is what a developer reaches for when a service needs lightweight in-process periodic work without an external cron or job queue.


Related snips

Share this code

Here's the card — post it anywhere.

Graceful Cron-Style Scheduler in Go With Ticker and Context Cancellation — share card
Link copied