← Back to list

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.

Andrei Galkin · 2026-06-09 01:44 · 0 claps · 6.8 min read
#golang #concurrency #scheduling #priority-queue
Open on Medium ↗
Wiki topics: 💪 · Fitness & Wellness

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:

  1. Modifying a slice while iterating it skips elements or panics. The moment you remove a fired task, the indices shift underneath you.
  2. 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.
  3. Granularity is a lie. A task due at *T+0.1s quietly 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/heap is 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 from main while 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