C#
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Object-Oriented C#
-
Section 4: Working with Data
-
Section 5: Error Handling
-
Section 6: Delegates and Events
-
Section 7: Async Programming
-
Section 8: More Language Features
-
Section 9: File I/O and Serialization
-
Section 10: Networking in .NET
-
Section 11: The .NET Ecosystem
-
Section 12: Memory and Performance
-
Section 13: Concurrency Beyond Async
-
Section 14: Reflection and Attributes
-
Section 15: Testing and Best Practices
-
Section 16: Design Patterns in C#
-
Section 17: Standard Library Deep Dive
-
Section 18: Data Structures and Algorithms in C#
-
Section 19: GUI and Desktop Development Overview
-
Section 20: Practical Projects
-
Section 21: More Practice Exercises
-
Section 22: More Standard Library and Text Processing
-
Section 23: More Design Patterns
-
Section 24: More Projects
-
Section 25: Interview Practice
-
Section 26: C# Keywords Reference (Modifiers)
-
Section 27: C# Keywords Reference (Statements)
-
Section 28: C# Keywords Reference (Operators)
-
Section 29: BCL: System.Collections.Generic
-
Section 30: BCL: System.Linq
-
Section 31: BCL: System.Threading
-
Section 32: BCL: System.IO
-
Section 33: BCL: System.Text and System.Text.Json
-
Section 34: BCL: System.Net.Http
-
Section 35: C# Language Specification Topics
-
Section 36: More Practice Exercises
-
Section 37: More Async Patterns
-
Section 38: More BCL: System.Reflection and System.Diagnostics
-
Section 39: Nullable Reference Types In Depth
-
Section 40: C# Records and Pattern Matching In Depth
-
Section 41: Dependency Injection Deep Dive
-
Section 42: More Interview and Whiteboard Practice
130: Building a Simple Task Scheduler
I've been thinking about how we handle recurring background work. Usually, we just slap a library like Quartz.NET or Hangfire on it, but I think it's worth actually building a primitive scheduler from scratch. It'll show us exactly why those big libraries exist and how to handle the "drift" that happens when tasks take longer than their allotted slot.
The "Simple Loop" Trap
My first instinct is always the simplest: just a while loop and a Task.Delay. Let's try to build a scheduler that runs a "Database Cleanup" task every 5 seconds. I'll start with something like this:
public async Task StartScheduler()
{
while (true)
{
Console.WriteLine($"Running cleanup at {DateTime.Now:T}...");
await DoCleanupWork();
await Task.Delay(5000);
}
}
private async Task DoCleanupWork()
{
// Simulate work
await Task.Delay(2000);
}
At first glance, this looks fine. But wait—look at the timing. The cleanup takes 2 seconds, and then we delay for 5 seconds. That means the task actually runs every 7 seconds, not every 5. This is called "drift." If this were a billing system running every hour, we'd be losing minutes every single cycle. That's not going to cut it.
Firing and Forgetting
To fix the drift, I could calculate the remaining time, but that gets messy. Instead, why don't I just fire the task off in the background and let the loop keep ticking? I'll use Task.Run so the loop doesn't wait for the cleanup to finish before starting the next delay.
public async Task StartScheduler()
{
while (true)
{
// Fire and forget the work
_ = Task.Run(async () => await DoCleanupWork());
await Task.Delay(5000);
}
}
Now the timing is perfect. The loop wakes up every 5 seconds exactly. But I've just created a new, much scarier problem. What happens if DoCleanupWork suddenly takes 10 seconds because the database is lagging? Every 5 seconds, the scheduler will spawn a new instance of the cleanup task, and they'll start piling up. I'll end up with five concurrent cleanup tasks fighting over the same database tables. That's a recipe for a deadlock or a crashed server.
Managing Task State
I need a way to say, "Run this every 5 seconds, but only if the previous run is actually finished." I'll introduce a flag—a simple boolean—to track if the task is currently busy. I'm thinking of a ScheduledTask class to encapsulate this logic so I can run multiple different jobs without writing five different loops.
public class ScheduledTask
{
public string Name { get; set; }
public TimeSpan Interval { get; set; }
public Func<Task> Action { get; set; }
private bool _isRunning = false;
public async Task Tick()
{
if (_isRunning)
{
Console.WriteLine($"[{Name}] Skipping: Previous run still active.");
return;
}
_isRunning = true;
try
{
await Action();
}
finally
{
_isRunning = false;
}
}
}
I like this. The finally block is the key here; if the task throws an exception, I still need to set _isRunning back to false, otherwise the scheduler will just stop running that task forever. Now I can manage a list of these and iterate through them in one master loop.
Putting the Engine Together
Now let's wrap this into a manager. I'll use a CancellationTokenSource because we can't just have a while(true) loop running forever in a real app—we need a way to shut it down gracefully when the application closes.
public class TaskSchedulerEngine
{
private readonly List<ScheduledTask> _tasks = new();
public void AddTask(string name, TimeSpan interval, Func<Task> action)
{
_tasks.Add(new ScheduledTask { Name = name, Interval = interval, Action = action });
}
public async Task RunAsync(CancellationToken ct)
{
// We use a PeriodicTimer for better precision in .NET 6+
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(ct))
{
foreach (var task in _tasks)
{
// This is a simplified check. In a real system,
// you'd track the last run time per task.
await task.Tick();
}
}
}
}
I switched to PeriodicTimer here. Unlike Task.Delay, it doesn't "drift" as much because it tracks the next tick relative to the start time, not the end of the previous execution. It's much cleaner for this kind of "heartbeat" logic.
📋 Practical Task
Build a System Maintenance Orchestrator
Using the concepts from this lesson, create a program that simulates a server maintenance orchestrator. Your program should:
- Implement a
TaskSchedulerEngine(or similar) that can handle multiple registered tasks. - Register two specific tasks:
- "Log Rotation": Runs every 3 seconds. It should take roughly 1 second to complete.
- "Disk Cleanup": Runs every 5 seconds. To test the "overlap" protection, make this task randomly take between 1 and 7 seconds to complete (use
Random.Shared.Next()).
- Ensure that if "Disk Cleanup" is still running when the next tick occurs, the scheduler prints a message saying the task was skipped rather than starting a second concurrent cleanup.
- Implement a way to stop the scheduler after 20 seconds of execution using a
CancellationTokenSource.
There are no comments for now.