What We’re Building

A task scheduler that:

  • Accepts tasks via a channel
  • Executes them with configurable concurrency
  • Handles timeouts gracefully
  • Supports priority levels
  • Shuts down cleanly

All using Go’s select statement and channels.

The Select Statement

Think of select as a switch for channels. It blocks until one of multiple channel operations is ready:

select-basics.go
go
select {
case msg := <-messages:
  fmt.Println("Received:", msg)
case <-time.After(3 * time.Second):
  fmt.Println("Timed out")
case <-quit:
  fmt.Println("Shutting down")
default:
  fmt.Println("No messages yet")
}

This is powerful for scheduling because you can wait on multiple channels simultaneously.

Step 1: Define the Task

task.go
go
type Priority int

const (
  Low Priority = iota
  Normal
  High
)

type Task struct {
  ID       string
  Fn       func()
  Priority Priority
  Timeout  time.Duration
}

type Result struct {
  TaskID  string
  Err     error
  Elapsed time.Duration
}

Step 2: The Scheduler Core

scheduler.go
go
type Scheduler struct {
  tasks    chan Task
  results  chan Result
  workers  int
  quit     chan struct{}
  wg       sync.WaitGroup
}

func New(workers int, queueSize int) *Scheduler {
  return &Scheduler{
      tasks:   make(chan Task, queueSize),
      results: make(chan Result, queueSize),
      workers: workers,
      quit:    make(chan struct{}),
  }
}

Step 3: Worker with Select and Timeout

This is where select shines — handling task execution with timeout:

worker.go
go
func (s *Scheduler) worker() {
  defer s.wg.Done()

  for {
      select {
      case task := <-s.tasks:
          s.execute(task)
      case <-s.quit:
          return
      }
  }
}

func (s *Scheduler) execute(task Task) {
  start := time.Now()
  done := make(chan struct{})

  go func() {
      task.Fn()
      close(done)
  }()

  timeout := task.Timeout
  if timeout == 0 {
      timeout = 30 * time.Second
  }

  select {
  case <-done:
      s.results <- Result{
          TaskID:  task.ID,
          Elapsed: time.Since(start),
      }
  case <-time.After(timeout):
      s.results <- Result{
          TaskID:  task.ID,
          Err:     fmt.Errorf("task %s timed out after %v", task.ID, timeout),
          Elapsed: time.Since(start),
      }
  case <-s.quit:
      s.results <- Result{
          TaskID:  task.ID,
          Err:     fmt.Errorf("task %s cancelled", task.ID),
          Elapsed: time.Since(start),
      }
  }
}

The select statement handles three outcomes simultaneously:

  1. Task completes normally
  2. Task times out
  3. Scheduler is shutting down

Step 4: Priority Queue with Select

priority.go
go
type PriorityQueue struct {
  high    chan Task
  normal  chan Task
  low     chan Task
  quit    chan struct{}
}

func (pq *PriorityQueue) next() (Task, bool) {
  select {
  case task := <-pq.high:
      return task, true
  default:
      select {
      case task := <-pq.normal:
          return task, true
      default:
          select {
          case task := <-pq.low:
              return task, true
          case <-pq.quit:
              return Task{}, false
          }
      }
  }
}

This cascading select pattern checks high priority first, then normal, then low. The default case makes it non-blocking for higher priorities.

Step 5: Start and Stop

lifecycle.go
go
func (s *Scheduler) Start() {
  for i := 0; i < s.workers; i++ {
      s.wg.Add(1)
      go s.worker()
  }
}

func (s *Scheduler) Stop() {
  close(s.quit)
  s.wg.Wait()
  close(s.tasks)
  close(s.results)
}

func (s *Scheduler) Submit(task Task) {
  select {
  case s.tasks <- task:
  case <-s.quit:
      fmt.Printf("Task %s rejected: scheduler shutting down\n", task.ID)
  }
}

Putting It All Together

main.go
go
func main() {
  sched := New(4, 100)
  sched.Start()

  // Submit tasks
  for i := 0; i < 10; i++ {
      sched.Submit(Task{
          ID:      fmt.Sprintf("task-%d", i),
          Fn:      func() { time.Sleep(100 * time.Millisecond) },
          Timeout: 2 * time.Second,
      })
  }

  // Collect results
  go func() {
      for result := range sched.results {
          if result.Err != nil {
              fmt.Printf("FAIL %s: %v\n", result.TaskID, result.Err)
          } else {
              fmt.Printf("OK   %s (%v)\n", result.TaskID, result.Elapsed)
          }
      }
  }()

  sched.Stop()
}

Common Patterns

Rate Limiting with Ticker

rate-limit.go
go
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()

for {
  select {
  case task, ok := <-tasks:
      if !ok {
          return
      }
      select {
      case <-ticker.C:
          process(task)
      case <-quit:
          return
      }
  case <-quit:
      return
  }
}

Context Cancellation

context.go
go
func (s *Scheduler) worker(ctx context.Context) {
  for {
      select {
      case task := <-s.tasks:
          s.executeWithCtx(ctx, task)
      case <-ctx.Done():
          return
      }
  }
}

Key Takeaways

  1. select is Go’s scheduler primitive — wait on multiple channels simultaneously
  2. Timeout handlingtime.After in select gives you clean timeout logic
  3. Priority via cascading select — non-blocking checks for higher priorities
  4. Graceful shutdown — always handle quit channel in every select
  5. Backpressure — buffered channels control queue depth naturally

The select statement turns complex scheduling logic into clean, readable code. No timers, no callbacks, no state machines — just channels and goroutines.