Building a Task Scheduler with Go Channels and Select
golangschedulerchannelsselectconcurrency
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:
- Task completes normally
- Task times out
- 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
selectis Go’s scheduler primitive — wait on multiple channels simultaneously- Timeout handling —
time.Afterin select gives you clean timeout logic - Priority via cascading select — non-blocking checks for higher priorities
- Graceful shutdown — always handle
quitchannel in every select - 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.
✓Boom! Another blog conquered.
Mark as unread
Blog