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()
}
package scheduler
import (
"context"
"time"
)
type Handler func(ctx context.Context) error
type Job struct {
Name string
Interval time.Duration
Handler Handler
}
func NewJob(name string, interval time.Duration, h Handler) Job {
return Job{Name: name, Interval: interval, Handler: h}
}
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"time"
"example.com/app/scheduler"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
sched := scheduler.New(ctx)
sched.Add(scheduler.NewJob("heartbeat", 5*time.Second, func(ctx context.Context) error {
log.Println("heartbeat ok")
return nil
}))
sched.Add(scheduler.NewJob("cleanup", 30*time.Second, func(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
log.Println("cleanup complete")
return nil
}
}))
<-ctx.Done()
log.Println("signal received, shutting down scheduler")
sched.Stop()
log.Println("all jobs stopped cleanly")
os.Exit(0)
}
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
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
Channels (mpsc) for message passing between threads
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
package pools
import (
"bytes"
"sync"
)
sync.Pool for bytes.Buffer to reduce allocations in hot paths
Share this code
Here's the card — post it anywhere.