Go Stories: The Sleeping Scheduler and the Heap That Woke It Up
Why poll when you can sleep? Building an in-memory task scheduler from scratch in Go.
Go Stories: The Sleeping Scheduler and the Heap That Woke It Up

Why poll when you can sleep? Building an in-memory task scheduler from scratch in Go.
A new problem. A simple idea. A surprisingly deep rabbit hole. I picked a problem that sounds trivial on the surface:
“Build an in-memory task scheduler. Schedule tasks to run at a given future UNIX timestamp.”
How hard could it be?
Note: This implementation requires Go 1.23 or higher to run correctly due to recent updates to internal Go timers.
The Obvious First Attempt
The first thing that comes to mind is a ticker — a loop that wakes up every second and checks: “Is anything due?”
for {
now := time.Now().Unix()
for _, task := range tasks {
if task.ExecuteAt <= now {
go task.Action()
}
}
time.Sleep(1 * time.Second)
}
It works. Kind of. The way a security guard “works” is by walking every corridor every 60 seconds, checking for fires — even at 3 AM when the building is empty.
Three problems surface immediately:
- Modifying a slice while iterating it skips elements or panics. The moment you remove a fired task, the indices shift underneath you.
- No locking. Calling
*Schedule()* from another goroutine while the loop is iterating over the slice is a data race. Go's race detector will let you know. - Granularity is a lie. A task due at
*T+0.1squietly waits until `T+1.0s`*. For anything time-sensitive, that's a problem.
There’s a better way. If you already know when the next task fires, why not just sleep exactly that long — and wake up early only when something new arrives?
The Design: Sleep Until Needed
Two ingredients make this work:
- A min-heap — a data structure that always surfaces the earliest task in O(log n).
- A wakeup channel — so a new, earlier task can interrupt the sleep.
Go’s standard library ships *container/heap. The interruptible sleep is just a `select* over atime.Timer` and a channel. Let's build it piece by piece.
The Task
type Task struct {
ID string
ExecuteAt int64 // UNIX timestamp in seconds
Action func()
index int // Internal field required by container/heap
}
Simple enough. *ID for identification and cancellation. `ExecuteAt*for ordering.Action` for the actual work.
The *index field is the one that looks odd. It's lowercase — unexported, invisible outside the package. But it's critical: it tracks each task's position inside the heap array. Without it, removing a task from the middle of the heap would require a full O(n) scan. With it, `heap.Remove()`* jumps straight to the right slot in O(log n).
You maintain it yourself. Every Push and Swap operation has to keep it up to date.
The Priority Queue
*container/heap doesn't give you a heap — it gives you a contract*. Implement five methods, pass in your slice, and it handles the heap invariant for you.
type PriorityQueue []*Task
func (pq PriorityQueue) Len() int { return len(pq) }
func (pq PriorityQueue) Less(i, j int) bool {
return pq[i].ExecuteAt < pq[j].ExecuteAt // earliest timestamp = highest priority
}
func (pq PriorityQueue) Swap(i, j int) {
pq[i], pq[j] = pq[j], pq[i]
pq[i].index = i
pq[j].index = j
}
func (pq *PriorityQueue) Push(x interface{}) {
n := len(*pq)
item := x.(*Task)
item.index = n
*pq = append(*pq, item)
}
func (pq *PriorityQueue) Pop() interface{} {
old := *pq
n := len(old)
item := old[n-1]
old[n-1] = nil // release the pointer — don't leak memory
item.index = -1
*pq = old[0 : n-1]
return item
}
That *nil assignment in `Pop` *isn't boilerplate; it prevents a memory leak. Without it, the underlying array retains a reference to the deleted task, preventing the garbage collector from reclaiming it. At a scale of thousands of tasks per hour, this creates a slow, agonizing memory leak that you'll inevitably end up debugging at 2 AM.
A convenience method for peeking at the head without removing it:
func (pq *PriorityQueue) Peek() *Task {
if len(*pq) == 0 {
return nil
}
return (*pq)[0]
}
The Scheduler
// code/scheduler.go
type Scheduler struct {
mu sync.Mutex
pq PriorityQueue
tasks map[string]*Task
wakeupChan chan struct{}
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
func New() *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
schedule := &Scheduler{
pq: make(PriorityQueue, 0),
tasks: make(map[string]*Task),
wakeupChan: make(chan struct{}, 1),
ctx: ctx,
cancel: cancel,
}
heap.Init(&schedule.pq)
return schedule
}
The *wakeupChan* is buffered with capacity 1 — not 0, not 10. Exactly 1. It's a binary flag: "something changed, re-evaluate." It doesn't matter how many tasks were added while the dispatcher slept — a single pending signal is enough to wake it and reread the heap.
// code/scheduler.go
func (schedule *Scheduler) Schedule(id string, executeAt int64, action func()) error {
schedule.mu.Lock()
defer schedule.mu.Unlock()
if _, exists := schedule.tasks[id]; exists {
return fmt.Errorf("task %q is already scheduled", id)
}
task := &Task{
ID: id,
ExecuteAt: executeAt,
Action: action,
}
heap.Push(&schedule.pq, task)
schedule.tasks[id] = task
if schedule.pq.Peek() == task {
select {
case schedule.wakeupChan <- struct{}{}:
default: // signal already pending — don't block
}
}
return nil
}
We only send a wakeup if the new task becomes the earliest in the heap. If *Task5 is due in 60 seconds but `Task1`* is already waiting at 10 seconds, the dispatcher's sleep is already short enough — no point interrupting it.
The *default in the `select* is equally important. If the dispatcher hasn't processed the previous wakeup yet, the channel is full. Withoutdefault`, *Schedule()* would block waiting to send. That's a deadlock.
Cancellation
func (schedule *Scheduler) Cancel(id string) bool {
schedule.mu.Lock()
defer schedule.mu.Unlock()
task, ok := schedule.tasks[id]
if !ok || task == nil {
return false
}
if task.index >= 0 {
heap.Remove(&schedule.pq, task.index)
}
delete(schedule.tasks, id)
return true
}
The scan to find the task by ID is O(n) — acceptable for a prototype. In a higher-throughput system, you’d add a *map[string]*Task alongside the heap for O(1) lookup. But heap.Remove uses the index to remove the item in O(log n). That's where maintaining `index* in everyPush` way *Swap *pays off.
The Dispatcher Loop
This is the core. The dispatcher sleeps, wakes, checks, fires, and repeats — without ever polling:
func (schedule *Scheduler) run() {
defer schedule.wg.Done()
var timer *time.Timer
timer = time.NewTimer(1 * time.Hour)
if !timer.Stop() {
<-timer.C
}
for {
schedule.mu.Lock()
now := time.Now().Unix()
nextTask := schedule.pq.Peek()
var duration time.Duration
hasTask := nextTask != nil
if hasTask {
if nextTask.ExecuteAt <= now {
task := heap.Pop(&schedule.pq).(*Task)
schedule.mu.Unlock()
go task.Action() // fire — don't block the dispatcher
continue
} else {
duration = time.Duration(nextTask.ExecuteAt-now) * time.Second
}
}
schedule.mu.Unlock()
if hasTask {
timer.Reset(duration)
} else {
timer.Reset(1 * time.Hour)
}
select {
case <-schedule.ctx.Done():
timer.Stop()
return
case <-timer.C:
// task is due — loop back
case <-schedule.wakeupChan:
// new earlier task — drain timer and re-evaluate
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
}
}
}
Two things worth calling out.
Draining a timer after calling *timer.Stop() is a rite of passage for Go developers. If the timer fired between the `Stop()* call and the drain, there's a stale tick sitting intimer.C`. If you Reset() without draining, your next *select block will pick up that ghost tick and fire a task prematurely. The nested `select { case <-timer.C: default: }`* is idiomatic Go for "drain if there's something, otherwise keep moving."
*go task.Action()* is the other key line. Every task runs in its own goroutine. The dispatcher never waits for it. A slow action — one that takes 10 seconds to complete — doesn't push the next task back by a single millisecond.
Start, Stop, and the Full Simulation
Starting and stopping are clean:
func (schedule *Scheduler) Start() {
schedule.wg.Add(1)
go schedule.run()
}
func (schedule *Scheduler) Stop() {
schedule.cancel()
schedule.wg.Wait()
}
*wg.Wait() in `Stop()* ensures we don't return until the dispatcher goroutine has fully exited. Without it,defer taskScheduler.Stop()` in *main *could return before the dispatcher finishes its last loop iteration — a subtle race on shutdown.
The full simulation in *main.go* exercises all four operations: schedule, run, cancel mid-flight, and add a late arrival:
func main() {
log.SetFlags(log.Ltime)
taskScheduler := scheduler.New()
taskScheduler.Start()
defer taskScheduler.Stop()
startTime := time.Now().Unix()
log.Println("Simulation started.")
taskScheduler.Schedule("Task1", startTime+5, func() {
log.Printf("- Executed: Task1 (Target: %s)", fmtTime(startTime+5))
})
taskScheduler.Schedule("Task2", startTime+10, func() {
log.Printf("- Executed: Task2 (Target: %s)", fmtTime(startTime+10))
})
taskScheduler.Schedule("Task3", startTime+15, func() {
log.Printf("- Executed: Task3 (Target: %s) <-- SHOULD NOT RUN", fmtTime(startTime+15))
})
taskScheduler.Schedule("Task4", startTime+20, func() {
log.Printf("- Executed: Task4 (Target: %s)", fmtTime(startTime+20))
})
// Task1 fires at t+5s. Then we cancel Task3 before it gets a chance.
time.Sleep(7 * time.Second)
if taskScheduler.Cancel("Task3") {
log.Println("- Task3 was successfully cancelled before execution!")
}
// Late arrival — 20 seconds from right now
now := time.Now().Unix()
taskScheduler.Schedule("Task_Late_Bonus", now+20, func() {
log.Printf("- Executed: Task_Late_Bonus (Target: %s)", fmtTime(now+20))
})
time.Sleep(22 * time.Second)
log.Println("- Simulation timeline finished.")
}
func fmtTime(ts int64) string {
return time.Unix(ts, 0).Format("15:04:05")
}
Run it:
cd c:\dev\Scheduler
go run .
Task1 fires at t+5s. Task2 at t+10s. Task3 is silently skipped — cancelled. Task4 at t+20s. The late bonus fires about 20 seconds after it was added.
What We Learned
*container/heapis a contract, not a gift. It handles the invariant — you handle the bookkeeping. Break the index maintenance in `Swap`*, and the heap will silently yield incorrect results. No panic, no error, just tasks firing in the wrong order.- Buffered channels of size 1 are a design pattern. They give you a signal — not a queue. “Something changed, wake up and look.” One pending wakeup is as good as a hundred.
- Timer draining is mandatory.
*time.Timer* is correct, just unintuitive. Stop before reset. Drain after a non-blocking stop. Two lines that prevent ghost wakeups — skip them once and you'll never skip them again. *wg.Wait()* in shutdown is not optional. Without it, returning frommainwhile the dispatcher is mid-loop is a race condition dressed as a graceful exit.*go task.Action()is the whole point. The dispatcher's job is to decide what runs, not to run* it. Keeping those two concerns separate is what makes the scheduler non-blocking.
Wrapping Up
What started as “how hard could this be” turned into a genuine tour of Go’s concurrency primitives — channels, mutexes, context cancellation, WaitGroups, and the *container/heap* contract. The full source code for this project is available on GitHub.
Sleep smart. Wake when needed. Fire and don’t look back.
메타데이터
- post_id
- eaecc378434f
- slug
- go-stories-the-sleeping-scheduler-and-the-heap-that-woke-it-up-eaecc378434f
- url
- https://medium.com/@andron.galkin/go-stories-the-sleeping-scheduler-and-the-heap-that-woke-it-up-eaecc378434f
- canonical_url
- https://medium.com/@andron.galkin/go-stories-the-sleeping-scheduler-and-the-heap-that-woke-it-up-eaecc378434f
- author_url
- https://medium.com/@andron.galkin
- status
- ok
- fetched_at
- 2026-08-11 21:32:20