Logo

CPU Scheduling: Practice and a C# Simulation

44 min read
Lesson slides
1 / 20

Operating Systems I - Lesson 7

CPU Scheduling: Practice and a C# Simulation

Trace Gantt charts by hand, then verify them against a real C# simulator built around a ready queue.

By the end of this lesson you will be able to trace and evaluate FCFS, SJF, Priority and Round Robin scheduling by hand, and verify your hand-solved Gantt charts against a C# simulator built around a real ready queue.

Objectives

  • Define the scheduling criteria and use them to judge whether a scheduling algorithm is doing a good job.
  • Trace, by hand, the Gantt chart produced by FCFS, SJF (non-preemptive and preemptive), Priority (non-preemptive and preemptive), and Round Robin scheduling.
  • Explain, conceptually, how Multilevel Queue and Multilevel Feedback Queue scheduling route processes between queues, including how aging fights starvation in the feedback queue variant. These two are covered by definition and diagram only: neither one gets a hand-traced numeric example or a lab task, since a worked problem needs a full arrival/burst-time dataset that none of the algorithm's own material provides.
  • Compute completion time, turnaround time, waiting time, their averages, and CPU efficiency for a set of processes.
  • Extend a burst-time-only scheduling class into a simulator that understands arrival times, a ready queue, and a configurable time quantum.
  • Verify a hand-solved Gantt chart against a running C# program and against the arithmetic checks (CT/TAT/WT consistency) used throughout this lesson.

Prerequisites

  • Completed the earlier FCFS lesson and its FCFS class (a double[] of burst times with GetTurnAroundTime, GetWaitingTime, GetAverageWaitingTime, GetAverageTurnAroundTime).
  • Basic understanding of process states and the process control block (PCB: the per-process record that the OS saves and restores whenever it switches the CPU from one process to another).
  • The .NET SDK installed and basic C# (classes, List<T>, simple LINQ). Confirm it with dotnet --version before starting Task 3; if that command is not found, install the SDK from the official .NET download page for your OS first.
  • Willingness to draw Gantt charts on paper; the simulation half of this lesson is much easier once the theory half is solid.

Scheduling Criteria

Before comparing algorithms we need a common vocabulary for "good". Five criteria recur throughout this lesson; three of them (efficiency, turnaround time and waiting time) get a full numeric worked example, while throughput and response time are defined here and then made concrete with a short calculation right after the list:

  • Efficiency: how much of the time the CPU does useful work instead of sitting idle, computed as useful time divided by total time. This is the value calculated in Problem 2 below.
  • Throughput: the number of processes completed per unit of time. An algorithm that finishes short processes quickly (like SJF) tends to produce higher throughput than one that lets a long process hog the CPU.
  • Turnaround time: the total time a process spends in the system, from arrival in the ready queue until it fully completes: Turnaround Time = Completion Time - Arrival Time.
  • Waiting time: the time a process spends sitting in the ready queue, not on the CPU. Since burst time is the time actually spent on the CPU, whatever is left over from turnaround time is waiting: Waiting Time = Turnaround Time - Burst Time.
  • Response time: the time from a process's arrival until it first gets the CPU, counting only the first time it is dispatched, not any later time it gets the CPU back after being preempted. It matters most in interactive, time-sharing systems, where the user needs to see the system react even before the process fully finishes.

To make throughput and response time concrete instead of purely definitional: in Problem 1 below, five processes are all done by time 19, so throughput = 5 processes / 19 ms, about 0.26 processes per ms (about one process every 3.8 ms). Applying the response-time definition to Problem 4's Gantt chart (preemptive SRTF) below shows why response time and waiting time are genuinely different numbers: response time only counts the first dispatch, so P1 and P2 both get the CPU the instant they arrive and have a response time of 0, P3 (arrives at 3, first runs at 6) has a response time of 3 ms, and P4 (arrives at 8, first runs at 12) has a response time of 4 ms, for an average response time of (0+0+3+4)/4 = 1.75 ms, well below that problem's average waiting time of 5.5 ms.

Every worked problem below reduces to filling in a table of arrival time (AT), burst time (BT), completion time (CT), turnaround time (TAT) and waiting time (WT), then averaging the TAT and WT columns. The step with no shortcut is building the Gantt chart correctly; once that is right, everything else is arithmetic.

First-Come, First-Served (FCFS) Scheduling

Definition: the process that requests the CPU first is the one that gets it first. Purpose: the simplest possible policy, easy to understand and to implement. How it works: the ready queue is a plain FIFO queue. A process's PCB is linked to the tail on arrival; when the CPU is free, it goes to the process at the head, which is then removed from the queue. FCFS is non-preemptive: once a process has the CPU, nothing takes it away until it terminates or blocks for I/O.

flowchart LR
    New([Process arrives]) -->|linked to tail| Q["FIFO ready queue"]
    Q -->|head, when CPU is free| Dispatch["Dispatch to CPU\n(removed from queue)"]
    Dispatch -->|terminates or blocks for I/O| Done([Leaves the CPU])

Example (the convoy effect): three processes P1 (burst 24), P2 (burst 3), P3 (burst 3), all arriving at time 0.

If they arrive in the order P1, P2, P3:

|   P1   |   P2   |   P3   |
0       24       27       30

Waiting times are 0, 24, 27, so the average waiting time is (0+24+27)/3 = 17 ms.

If the same three processes arrive in the order P2, P3, P1:

|   P2   |   P3   |   P1   |
0        3        6       30

Waiting times are now 0, 3, 6, so the average waiting time drops to (0+3+6)/3 = 3 ms.

Same processes, same burst times, only the arrival order changed, yet the average waiting time fell from 17 ms to 3 ms. This is the convoy effect: a long process arriving before several short ones forces every short process to wait behind it. FCFS is easy to implement but its average waiting time is generally not minimal, which can be disastrous for time-sharing systems, where every process is expected to get a share of the CPU at regular intervals.

Shortest Job First (SJF) Scheduling

Definition: the CPU is given to whichever ready process has the shortest next CPU burst, not the shortest total lifetime; a more accurate name would be "shortest-next-CPU-burst scheduling". Ties are broken by arrival order (FCFS). Purpose: minimize average waiting time; SJF is provably optimal in that sense among non-preemptive algorithms. How it works: it comes in two flavors.

  • Non-preemptive SJF: once a process starts running, it runs to completion, even if a shorter process arrives while it is executing.
  • Preemptive SJF, also called Shortest-Remaining-Time-First (SRTF): if a new process arrives with a next burst shorter than the remaining time of the process currently running, the running process is preempted and the CPU is handed to the new arrival. The preempted process keeps whatever remaining burst time it had left and re-enters the comparison later.
flowchart TD
    Arrive([New process arrives]) --> Compare{"Is its next burst shorter than\nthe running process's remaining time?"}
    Compare -->|No, or nothing is running| Queue["Joins the ready queue"]
    Compare -->|"Yes, and scheduling is preemptive (SRTF)"| Preempt["Running process is preempted\nand re-enters the ready queue"]
    Preempt --> Dispatch["New arrival gets the CPU"]
    Queue --> Pick["Whoever has the shortest\nnext burst runs next"]

The real difficulty is that the length of a process's next CPU burst is not knowable in advance; a scheduler can only approximate it, usually by assuming the next burst is similar in length to the previous ones. Because of this, SJF cannot literally be implemented at the short-term scheduling level (the level of the scheduler that picks which already-ready process gets the CPU next), though the approximation is still useful, and the next algorithm borrows its core idea.

Example (non-preemptive, four processes P1 to P4 with burst times 6, 8, 7, 3 ms, all arriving at time 0):

|   P4   |   P1   |   P3   |   P2   |
0        3        9       16       24

The full waiting-time and average calculation for this exact example is worked out as Problem 3 below.

Problem with SJF/SRTF: chasing the smallest possible average waiting time has a cost. If shorter processes keep arriving, a long process sitting in the ready queue (or, under SRTF, a long process that keeps getting preempted) can be skipped over indefinitely, since something shorter is always available to run first. This is the same starvation problem discussed below for Priority scheduling, and preemptive scheduling in general can cause it for exactly this reason: whatever rule decides who runs next can always find a "better" candidate than the one that has been waiting longest.

Priority Scheduling

Definition: every process carries a priority number, and the CPU goes to the ready process with the highest priority; ties are broken by FCFS. Purpose: let the system express that some work matters more than other work. SJF is in fact a special case of priority scheduling, where the priority is the inverse of the predicted next CPU burst: the shorter the burst, the higher the priority. How it works: like SJF, it can be non-preemptive (a higher-priority arrival simply waits at the head of the ready queue until the running process finishes) or preemptive (a higher-priority arrival takes the CPU away immediately).

flowchart TD
    Arrive([New process arrives]) --> Compare{"Is its priority better than\nthe running process's priority?"}
    Compare -->|No| Queue["Joins the ready queue,\nordered by priority (ties: FCFS)"]
    Compare -->|"Yes, non-preemptive"| WaitHead["Waits at the head of the queue\nuntil the running process finishes"]
    Compare -->|"Yes, preemptive"| Takeover["Takes the CPU immediately;\nrunning process re-enters the queue"]

Watch the convention used in a given problem: some say the lowest number is the highest priority (0 = most important), others say the highest number is. Always check before drawing the Gantt chart.

Example (non-preemptive, lower number = higher priority): five processes all arrive at time 0: P1 (BT 10, priority 3), P2 (BT 1, priority 1), P3 (BT 2, priority 4), P4 (BT 1, priority 5), P5 (BT 5, priority 2).

|   P2   |   P5   |   P1   |   P3   |   P4   |
0        1        6       16       18       19

Average waiting time = (6+0+16+18+1)/5 = 8.2 ms. This dataset is reused as the Priority (non-preemptive) block in the complete program listing at the end of this lesson (Tasks 3 to 5).

Example (non-preemptive, higher number = higher priority, staggered arrivals): this second example exists specifically to exercise the convention warning above with a case where it actually matters. Five processes: P1 (AT 0, BT 4, priority 2), P2 (AT 1, BT 3, priority 3), P3 (AT 2, BT 1, priority 4), P4 (AT 3, BT 5, priority 5), P5 (AT 4, BT 2, priority 5). Here the higher number is the higher priority.

|   P1   |   P4   |   P5   |   P3   |   P2   |
0        4        9       11       12       15

P1 arrives first and runs to completion regardless of priority, since this is non-preemptive: even though every other process (priority 3, 4, 5, 5) outranks P1 (priority 2), none of them can take the CPU away once P1 has it. At time 4, P1 finishes, and P2, P3, P4 and P5 have all already arrived and are waiting. The highest priority among them is 5, shared by P4 and P5; the tie is broken by FCFS, and P4 arrived first (time 3, versus P5's time 4), so P4 runs next. After P4, the highest remaining priority is P5's 5, then P3's 4, then P2's 3.

ProcessATBTCTTATWT
P104440
P213151411
P32112109
P435961
P5421175

Average turnaround time = (4+14+10+6+7)/5 = 8.2 ms. Average waiting time = (0+11+9+1+5)/5 = 5.2 ms.

Problem with priority scheduling: a low-priority process that is ready but never gets the CPU is blocked, and a steady stream of higher-priority arrivals can leave it waiting forever, a condition called indefinite blocking or starvation. The fix is aging: gradually raise the priority of a process the longer it waits. For example, with priorities from 0 (highest) to 127 (lowest), a waiting process's number could drop by 1 every 15 minutes, so that even a process starting at 127 eventually becomes the highest priority in the system and is guaranteed to run. The Multilevel Feedback Queue section below diagrams this same aging mechanism.

Round Robin (RR) Scheduling

Definition: Round Robin is designed specifically for time-sharing systems. It behaves like FCFS, but with preemption added through a time quantum (typically 10 to 100 ms): every process is allowed to run for at most one quantum before it is preempted and sent to the back of the queue. How it works: the ready queue is treated as a circular FIFO queue.

flowchart LR
    P1 --> P2 --> P3 --> P4 --> Pdots["..."] --> P10 --> P1
    Head(("head of the\ncircular queue")) -.-> P1
    Sched["CPU scheduler"] -. "always dispatches\nwhoever is at the head" .-> Head

P1 is only drawn at the head because the diagram has to start somewhere; as processes are dequeued and sent back to the tail, a different process sits at the head on every pass around the circle. The scheduler always dispatches whatever is at the head of the queue and sets a timer for one quantum. Two things can then happen: if the process's remaining burst is shorter than the quantum, it finishes and voluntarily releases the CPU; otherwise the timer fires, the process is preempted, and it is placed at the tail of the queue behind any processes that arrived while it was running. The tie-break that matters for the exact boundary: if a process's quantum expires at the exact same millisecond a new process arrives, the new arrival joins the queue first, and the just-preempted process goes in behind it.

Choosing the quantum matters a great deal. Too large, and Round Robin degenerates into FCFS, because the first process simply runs to completion before anyone else gets a turn. Too small, and excessive context switching sets in, because the overhead of saving and restoring process state starts to dominate the useful work. A well-chosen quantum is the whole point of the algorithm.

Round Robin also gives a markedly better response time than FCFS. In FCFS, a process at the head of the queue runs to completion with no preemption, so if it happens to have a long burst, every process behind it waits through the whole thing before getting the CPU even once. Round Robin's preemption guarantees every process a turn at least once per lap around the queue, which keeps response time low regardless of how long any one process's total burst is.

Multilevel Queue Scheduling

Definition: instead of one ready queue, processes are classified into groups (for example, foreground/interactive versus background/batch) and each group gets its own permanent queue. Purpose: different classes of processes have different response-time needs, so they are worth scheduling differently. How it works: each queue runs its own scheduling algorithm internally (the foreground queue might use Round Robin to stay interactive, the background queue plain FCFS since waiting there is acceptable), and there is also scheduling among the queues, most commonly fixed-priority preemptive: a process in a higher-priority queue can preempt one running in a lower-priority queue. Once assigned, a process stays in its queue permanently, which is the key property that separates this algorithm from the next one.

A typical example uses five queues in priority order: system, interactive, interactive editing, batch, and student processes. The student queue, being lowest priority, only runs when every queue above it is empty, and an arrival in any higher-priority queue immediately preempts it.

flowchart TB
    Sys["System processes (highest priority)"]
    Int["Interactive processes"]
    IntEdit["Interactive editing processes"]
    Batch["Batch processes"]
    Student["Student processes (lowest priority)"]
    Sys -->|preempts| Int -->|preempts| IntEdit -->|preempts| Batch -->|preempts| Student

Multilevel Feedback Queue (MLFQ) Scheduling

Definition: an extension of multilevel queue scheduling in which processes are allowed to move between queues instead of being permanently assigned to one. Purpose: separate processes by the actual behavior of their CPU bursts, so a CPU-heavy process does not block interactive or I/O-bound processes forever, while still protecting long-waiting processes from starvation. How it works: a process that uses too much CPU time in a high-priority queue is demoted to a lower one; a process that waits too long in a low-priority queue is promoted to a higher one (the same aging idea used against starvation in Priority scheduling).

flowchart LR
    New([New process]) --> Q0["Q0 - quantum 8 ms"]
    Q0 -->|does not finish in 8 ms| Q1["Q1 - quantum 16 ms"]
    Q1 -->|does not finish in 16 ms| Q2["Q2 - FCFS"]
    Q1 -->|waited too long: aging| Q0
    Q2 -->|waited too long: aging| Q1
    Q0 -->|finishes| Done([Terminates])
    Q1 -->|finishes| Done
    Q2 -->|finishes| Done

An MLFQ scheduler is fully defined by five parameters: the number of queues, the scheduling algorithm used inside each queue, the method for deciding when to promote a process to a higher-priority queue, the method for deciding when to demote a process to a lower-priority queue, and the method for deciding which queue a process enters when it first needs service. In the diagram above, a process gets 8 ms in the top queue; if it needs more, it drops to 16 ms in the middle queue; if it still needs more, it finishes on FCFS in the bottom queue. Higher-priority queues are always served first, exactly like multilevel queue scheduling, but here no process is permanently stuck in the queue it started in.

Task 1: Solve six fully worked scheduling problems by hand

Work through all six problems below on paper. For each one, draw the Gantt chart first; every other value follows from it.

Problem 1 (FCFS with different arrival times). Five processes: P1 (AT 4, BT 5), P2 (AT 6, BT 4), P3 (AT 0, BT 3), P4 (AT 6, BT 2), P5 (AT 5, BT 4). When two processes arrive at the same time, the one with the smaller process ID goes first. Build the Gantt chart, remembering to mark any CPU idle time.

|   P3   |  idle  |   P1   |   P5   |   P2   |   P4   |
0        3        4        9       13       17       19
ProcessATBTCTTATWT
P145950
P26417117
P303330
P462191311
P5541384

Average turnaround time = (5+11+3+13+8)/5 = 8 ms. Average waiting time = (0+7+0+11+4)/5 = 4.4 ms.

Problem 2 (FCFS with one unit of scheduling overhead). Six processes P1..P6 arrive at times 0, 1, 2, 3, 4, 5 with burst times 3, 2, 1, 4, 5, 2. This time the system needs one extra unit of delay before it can hand the CPU to a process, whether that is its first dispatch or a switch from another process. Find the efficiency of the schedule.

|  del   |   P1   |  del   |   P2   |  del   |   P3   |  del   |   P4   |  del   |   P5   |  del   |   P6   |
0        1        4        5        7        8        9       10       14       15       20       21       23

The wasted (idle/overhead) time is the six one-unit delays: 6 x 1 = 6 units. The total time to finish all six processes is 23 units (P6's completion time). Useful time = total time - wasted time = 23 - 6 = 17 units.

Efficiency = useful time / total time = 17 / 23 = 0.7391 = 73.91%.

Problem 3 (SJF, non-preemptive). Four processes P1..P4 all arrive at time 0 with burst times 6, 8, 7, 3 ms. Schedule them with non-preemptive SJF.

|   P4   |   P1   |   P3   |   P2   |
0        3        9       16       24

Because all four arrive together, the waiting time of each process is simply the time at which it starts: P4 waits 0, P1 waits 3, P3 waits 9, P2 waits 16.

Average waiting time = (3+16+9+0)/4 = 7 ms. For comparison, running the very same four processes under FCFS (in ID order) gives an average waiting time of 10.25 ms, which is worse: giving the CPU to the shortest job first reduces the total amount of waiting in the system.

Problem 4 (SJF, preemptive / SRTF). Four processes arrive as follows: P1 (AT 0, BT 12), P2 (AT 2, BT 4), P3 (AT 3, BT 6), P4 (AT 8, BT 5). The system uses preemptive shortest-remaining-time-first scheduling. Find the average waiting time.

|   P1   |   P2   |   P3   |   P4   |   P1   |
0        2        6       12       17       27

Walking through it: P1 starts at 0. At time 2, P2 arrives with burst 4, less than P1's remaining 10, so P1 is preempted. At time 3, P3 arrives with burst 6, but P2 only has 3 ms remaining, so P2 keeps running and finishes at 6. At time 8, P4 arrives with burst 5, but P3 (running since 6) has only 4 ms remaining, so P3 finishes at 12. At 12 the choice is P1 (10 ms remaining) versus P4 (5 ms, untouched): P4 runs from 12 to 17, then P1 runs its last 10 ms from 17 to 27.

For a preemptive schedule it is easier to read waiting time straight off the Gantt chart than to first work out a completion time, using Waiting Time = (last time the process got the CPU) - (milliseconds already executed before that) - Arrival Time. This is not a new formula, just the familiar WT = TAT - BT = (CT - AT) - BT rearranged: once a process starts its last slice, nothing preempts it again, so its completion time equals that last start time plus whatever burst it still had left, and "burst still left" is just its total burst time minus the milliseconds it had already executed earlier. Substituting CT = (last start) + BT - (executed earlier) into WT = CT - AT - BT cancels the BT term and leaves exactly the formula above:

ProcessLast startExecuted earlierATWT
P1172015
P22020
P36033
P412084

Average waiting time = (15+0+3+4)/4 = 5.5 ms.

Problem 5 (Priority, preemptive, 0 = highest priority). Five processes: P1 (AT 0, BT 11, priority 2), P2 (AT 5, BT 28, priority 0), P3 (AT 12, BT 2, priority 3), P4 (AT 2, BT 10, priority 1), P5 (AT 9, BT 16, priority 4). The scheduler is preemptive priority scheduling.

|   P1   |   P4   |   P2   |   P4   |   P1   |   P3   |   P5   |
0        2        5       33       40       49       51       67

Tracing it: P1 starts at 0. At time 2, P4 arrives with priority 1, better than P1's priority 2, so P1 (2 of 11 ms done) is preempted. At time 5, P2 arrives with priority 0, the best possible, preempts P4 and runs undisturbed to completion at 33, since nothing arriving later (P5 at 9, P3 at 12) beats priority 0. At 33 the ready queue holds P1 (priority 2, 9 ms left), P3 (priority 3, 2 ms), P4 (priority 1, 7 ms left) and P5 (priority 4, 16 ms): best priority first gives P4 (finishes 40), then P1 (finishes 49), then P3 (finishes 51), then P5 (finishes 67).

Using the same Waiting Time = (last start) - (executed earlier) - Arrival Time formula derived in Problem 4:

ProcessLast startExecuted earlierATWT
P1402038
P25050
P34901237
P4333228
P5510942

Average waiting time = (38+0+37+28+42)/5 = 29 ms.

Problem 6 (Round Robin, quantum = 4 ms). Three processes P1 (BT 24), P2 (BT 3), P3 (BT 3), all arriving at time 0.

|   P1   |   P2   |   P3   |   P1   |   P1   |   P1   |   P1   |   P1   |
0        4        7       10       14       18       22       26       30

P1 uses its first quantum (0-4), then P2 and P3 finish within their own single quantum each (they only need 3 ms, so they release the CPU voluntarily), and then P1 is the only process left, so it keeps getting the CPU back to back until its remaining 20 ms are used up in five more 4 ms slices.

ProcessCTTATWT
P130306
P2774
P310107

Average turnaround time = (30+7+10)/3 = 15.67 ms. Average waiting time = (6+4+7)/3 = 5.67 ms.

Task 2: Solve four scheduling problems on your own

Now do the same thing without a worked solution in front of you: draw the Gantt chart, fill in a CT/TAT/WT table like the ones above, and compute the requested averages. Check your own work the way every worked problem above can be checked: WT should always equal TAT minus BT for every process, the last completion time should equal the sum of all burst times plus any idle intervals you marked, and (for problem 4 below) picking the tie-break rule stated in the Round Robin section should be the only judgment call left, not a guess. Task 5 later has you wire one of these same four datasets into the simulator, which gives you a second, independent check once you get there.

  1. FCFS. P1 (AT 0, BT 6), P2 (AT 1, BT 2), P3 (AT 2, BT 8), P4 (AT 3, BT 3). Find the average waiting time and average turnaround time.
  2. SJF, non-preemptive. P1, P2, P3, P4 all arrive at time 0 with burst times 7, 4, 1, 4 ms. Find the average waiting time. (Two processes tie on burst time; remember how ties are broken.)
  3. Priority, non-preemptive, lower number = higher priority. All four processes arrive at time 0: P1 (BT 8, priority 3), P2 (BT 6, priority 1), P3 (BT 1, priority 4), P4 (BT 3, priority 2). Find the average waiting time and average turnaround time.
  4. Round Robin, quantum = 3 ms. P1 (AT 0, BT 8), P2 (AT 1, BT 4), P3 (AT 2, BT 9), P4 (AT 3, BT 5). Draw the full Gantt chart and find the average waiting time and average turnaround time.

Expected output: for each problem, a Gantt chart (drawn as an ASCII table, the same style used in Task 1), a CT/TAT/WT table, and the two requested averages, with WT = TAT - BT holding for every row. The final numbers to check against: Problem 1 gives average turnaround time 10.75 ms and average waiting time 6.00 ms; Problem 2 gives average waiting time 3.75 ms; Problem 3 gives average turnaround time 12.5 ms and average waiting time 8.00 ms; Problem 4 gives average turnaround time 20.00 ms and average waiting time 13.5 ms.

Task 3: Extend the FCFS class into an arrival-time-aware simulator

The complete program listing at the end of this lesson builds directly on the earlier FCFS class: the turnaround-time and waiting-time formulas are unchanged, but instead of a flat double[] of burst times, processes are now a Process class carrying an ID, an arrival time, a burst time and a priority, and instead of assuming every process is ready at time 0, a real ready queue only makes a process eligible once its arrival time has passed.

The three supporting types are:

public class Process
{
    public string Id { get; }
    public int ArrivalTime { get; }
    public int BurstTime { get; }
    public int Priority { get; }
    public int RemainingTime { get; set; }
 
    public Process(string id, int arrivalTime, int burstTime, int priority = 0)
    {
        Id = id;
        ArrivalTime = arrivalTime;
        BurstTime = burstTime;
        Priority = priority;
        RemainingTime = burstTime;
    }
}
 
public readonly struct GanttSlice
{
    public string ProcessId { get; }
    public int Start { get; }
    public int End { get; }
 
    public GanttSlice(string processId, int start, int end)
    {
        ProcessId = processId;
        Start = start;
        End = end;
    }
}
 
public class ScheduleResult
{
    public List<GanttSlice> Gantt { get; } = new();
    public Dictionary<string, int> CompletionTime { get; } = new();
}

Going through this line by line:

  • public string Id { get; } is a read-only auto-property: the { get; } with no set means C# generates a hidden backing field for you, but that field can only be assigned inside the class's own constructor, never from outside. Once a Process is built, its Id, ArrivalTime, BurstTime and Priority can never change (RemainingTime, covered next, is the one exception).
  • public int RemainingTime { get; set; } is the one property on Process that does have a set, because Round Robin (Task 4) needs to shrink it every time the process gives up the CPU without finishing.
  • The Process constructor takes the four values a process needs and assigns each one to its matching property; priority = 0 is a default parameter, so FCFS and SJF datasets (which do not use priority) can call new Process(id, arrivalTime, burstTime) without supplying one. RemainingTime starts out equal to BurstTime, since nothing has run yet.
  • GanttSlice replaces the plain Process/burst-time array with one bar of the chart: which process ran (ProcessId, which is "IDLE" for a gap), and the Start and End time of that bar. It is declared as a readonly struct rather than a class: a struct is a value type (copied, not referenced, when passed around) and readonly means none of its properties can be changed after construction, which fits a Gantt bar that is written once and never edited again. A class would work too, but a small, immutable, frequently-created type like this is the textbook case for a struct.
  • ScheduleResult is everything an algorithm needs to hand back: Gantt is the full ordered list of bars, and CompletionTime is a Dictionary<string, int> mapping each process's Id to the time it finished. That single dictionary is all Report.Print needs afterwards to derive turnaround time and waiting time, using the exact same two formulas as the original FCFS class. = new() on each property initializes an empty list and an empty dictionary the moment a ScheduleResult is created, so every algorithm below can start appending to Gantt and setting entries in CompletionTime without a separate initialization step.
  1. Look at the Scheduler.RunFcfs method below (reproduced in full in the complete program listing at the end of this lesson). It sorts processes by arrival time, then walks through them, and inserts an "IDLE" slice whenever the next process has not arrived yet by the time the CPU is free.
public static ScheduleResult RunFcfs(List<Process> processes)
{
    var result = new ScheduleResult();
    var ordered = processes.OrderBy(p => p.ArrivalTime).ThenBy(p => p.Id).ToList();
    int time = 0;
 
    foreach (var p in ordered)
    {
        if (p.ArrivalTime > time)
        {
            result.Gantt.Add(new GanttSlice("IDLE", time, p.ArrivalTime));
            time = p.ArrivalTime;
        }
 
        int end = time + p.BurstTime;
        result.Gantt.Add(new GanttSlice(p.Id, time, end));
        result.CompletionTime[p.Id] = end;
        time = end;
    }
 
    return result;
}

Going through it line by line:

  • var ordered = processes.OrderBy(p => p.ArrivalTime).ThenBy(p => p.Id).ToList(); sorts the whole process list once, by arrival time first and process Id as the tie-break, matching Problem 1's rule that "the one with the smaller process ID goes first"; .ToList() freezes that order so the rest of the method can just walk it from front to back.
  • int time = 0; is the running clock. It only ever moves forward.
  • foreach (var p in ordered) visits each process exactly once, in the fixed order decided above. FCFS never has to reconsider that order, since nothing ever preempts anything.
  • if (p.ArrivalTime > time) { ... } is the idle-time check: if the next process in line has not arrived yet by the time the CPU is free, an "IDLE" bar is appended covering the gap, and the clock jumps forward to that arrival, exactly the idle bar drawn in Problem 1's Gantt chart.
  • int end = time + p.BurstTime; computes where this process's bar ends: it runs, uninterrupted, for its whole burst time, since FCFS is non-preemptive.
  • result.Gantt.Add(new GanttSlice(p.Id, time, end)); appends this process's bar to the chart.
  • result.CompletionTime[p.Id] = end; records this process's completion time, the one number Report.Print needs afterwards to compute TAT and WT.
  • time = end; moves the clock to the end of this process's slice before the loop looks at the next one.
  1. Run the program with dotnet run.
  2. Expected output: the first report block, labelled FCFS, should show the same Gantt chart as Problem 1 in Task 1, and its two final lines should read Average Waiting Time = 4.40 and Average Turnaround Time = 8.00.

Task 4: Compare all four algorithms on the same run

The complete program listing also implements Scheduler.RunSjf, Scheduler.RunPriority and Scheduler.RunRoundRobin(processes, quantum). RunFcfs sorts every process once and then walks that fixed order in a single foreach loop, because arrival order never changes once the processes are sorted. RunSjf and RunPriority cannot do that: since the "best" process to run next depends on which processes have arrived so far, they instead use a while (remaining.Count > 0) loop that re-filters the arrived-but-not-yet-run processes and re-picks the best one (smallest burst time, or best priority) at every single dispatch point. So the two loops have a genuinely different shape, not just a different sort key; what they DO share is how they handle a gap with nothing ready yet, inserting an "IDLE" slice and jumping the clock forward to the next arrival, exactly like RunFcfs does. RunRoundRobin is the one that needs a live Queue<Process>, since it is the only algorithm here that puts a process back after giving it the CPU.

public static ScheduleResult RunSjf(List<Process> processes)
{
    var result = new ScheduleResult();
    var remaining = new List<Process>(processes);
    int time = 0;
 
    while (remaining.Count > 0)
    {
        var ready = remaining.Where(p => p.ArrivalTime <= time).ToList();
        if (ready.Count == 0)
        {
            int nextArrival = remaining.Min(p => p.ArrivalTime);
            result.Gantt.Add(new GanttSlice("IDLE", time, nextArrival));
            time = nextArrival;
            ready = remaining.Where(p => p.ArrivalTime <= time).ToList();
        }
 
        var next = ready
            .OrderBy(p => p.BurstTime)
            .ThenBy(p => p.ArrivalTime)
            .ThenBy(p => p.Id)
            .First();
 
        int end = time + next.BurstTime;
        result.Gantt.Add(new GanttSlice(next.Id, time, end));
        result.CompletionTime[next.Id] = end;
        time = end;
        remaining.Remove(next);
    }
 
    return result;
}

Line by line:

  • var remaining = new List<Process>(processes); copies the input list, so the caller's list is untouched; processes are removed from this copy one at a time as they run, which is how the loop below knows when to stop.
  • while (remaining.Count > 0) replaces RunFcfs's foreach: SJF cannot fix the run order up front, because which process is "best" depends on who has arrived by the time the CPU is free, so the method has to make a fresh decision at every dispatch.
  • var ready = remaining.Where(p => p.ArrivalTime <= time).ToList(); filters remaining down to only the processes that have already arrived at the current time.
  • The if (ready.Count == 0) block is the same idle-time move as RunFcfs, just written for a list instead of a single "next" process: if nobody has arrived yet, insert an "IDLE" bar, jump the clock to the earliest remaining arrival, and re-filter ready.
  • .OrderBy(p => p.BurstTime).ThenBy(p => p.ArrivalTime).ThenBy(p => p.Id).First() is the SJF rule itself: among everyone ready right now, pick the smallest burst time, breaking ties by earliest arrival and then by Id, the same tie-break used throughout Task 1.
  • The remaining four lines (int end = ... through remaining.Remove(next)) are the same bookkeeping as RunFcfs (append the bar, record the completion time, advance the clock), with one addition: remaining.Remove(next) takes the process that just ran out of the pool, so the while loop eventually runs out of work and stops.

Scheduler.RunPriority is the same method with a single line changed: .OrderBy(p => p.BurstTime) becomes .OrderBy(p => p.Priority). Everything else, the idle-time handling, the tie-break chain, the bookkeeping, is identical, which is the code's way of expressing the earlier point that SJF is priority scheduling where the priority happens to be the burst time itself.

public static ScheduleResult RunRoundRobin(List<Process> processes, int quantum)
{
    var result = new ScheduleResult();
    var byArrival = processes.OrderBy(p => p.ArrivalTime).ThenBy(p => p.Id).ToList();
    var readyQueue = new Queue<Process>();
    int time = 0;
    int nextToArrive = 0;
 
    void EnqueueArrivals(int upToTime)
    {
        while (nextToArrive < byArrival.Count && byArrival[nextToArrive].ArrivalTime <= upToTime)
        {
            readyQueue.Enqueue(byArrival[nextToArrive]);
            nextToArrive++;
        }
    }
 
    void AdvanceToNextArrivalIfIdle()
    {
        if (readyQueue.Count == 0 && nextToArrive < byArrival.Count)
        {
            int nextArrival = byArrival[nextToArrive].ArrivalTime;
            if (nextArrival > time)
            {
                result.Gantt.Add(new GanttSlice("IDLE", time, nextArrival));
            }
            time = nextArrival;
            EnqueueArrivals(time);
        }
    }
 
    EnqueueArrivals(0);
    AdvanceToNextArrivalIfIdle();
 
    while (readyQueue.Count > 0)
    {
        var current = readyQueue.Dequeue();
        int start = time;
        int slice = Math.Min(quantum, current.RemainingTime);
        time += slice;
        current.RemainingTime -= slice;
        result.Gantt.Add(new GanttSlice(current.Id, start, time));
 
        EnqueueArrivals(time);
        if (current.RemainingTime > 0)
        {
            readyQueue.Enqueue(current);
        }
        else
        {
            result.CompletionTime[current.Id] = time;
        }
 
        AdvanceToNextArrivalIfIdle();
    }
 
    return result;
}

EnqueueArrivals and AdvanceToNextArrivalIfIdle are local functions: methods declared inside another method, visible only inside RunRoundRobin, and callable like any other method from anywhere below their declaration in that same method. What is worth pausing on is that they read and write time and nextToArrive, two variables declared in the outer method, without either variable being passed in as a parameter. This is legal because a local function is a closure: it captures the variables of its enclosing method by reference, so when EnqueueArrivals runs nextToArrive++, it changes the exact same variable the rest of RunRoundRobin reads on its next line, not a private copy. Nothing in Tasks 1 to 3 needed this, because RunFcfs, RunSjf and RunPriority only ever check for an idle gap in one place each; Round Robin has to do that same check every single time the queue is dequeued, so pulling the repeated logic into two named, closure-capturing helpers avoids writing the same four or five lines three times over.

Line by line:

  • var byArrival = processes.OrderBy(p => p.ArrivalTime).ThenBy(p => p.Id).ToList(); sorts everyone by arrival time once, with the same tie-break as before. Unlike RunFcfs, this order only decides who joins the queue and when, not the order processes run in.
  • var readyQueue = new Queue<Process>(); is a real FIFO queue (System.Collections.Generic.Queue<T>), because Round Robin is the only algorithm here that ever gives a process back its place in line after it has already run.
  • int nextToArrive = 0; is an index into byArrival: everyone from index 0 up to nextToArrive - 1 has already joined the queue at some point; everyone from nextToArrive onward has not arrived yet.
  • EnqueueArrivals(int upToTime) walks byArrival starting at nextToArrive and enqueues everyone whose arrival time is at or before upToTime, advancing nextToArrive past each one it enqueues; its while condition stops the moment it reaches someone who has not arrived yet, or runs out of processes.
  • AdvanceToNextArrivalIfIdle() handles the case where the queue has completely drained but processes are still waiting to arrive: it records an "IDLE" bar if there is a gap, jumps time forward to that next arrival, and immediately calls EnqueueArrivals so the queue is not still empty on the next loop check.
  • EnqueueArrivals(0); AdvanceToNextArrivalIfIdle(); before the main loop seed the queue for time 0, covering the case where even the first process does not arrive until after time 0.
  • Inside while (readyQueue.Count > 0): var current = readyQueue.Dequeue(); takes whoever is at the head of the queue. int slice = Math.Min(quantum, current.RemainingTime); is the quantum rule itself: run for one quantum, or for however much burst is left, whichever is smaller. time += slice; current.RemainingTime -= slice; advances the clock and shrinks the one mutable field on Process, exactly why RemainingTime has a set in the type walkthrough above, while every other property is read-only.
  • EnqueueArrivals(time); runs before the just-run process is possibly put back in the queue. That order is the tie-break rule from the Round Robin section made concrete in code: "if a process's quantum expires at the exact same millisecond a new process arrives, the new arrival joins the queue first, and the just-preempted process goes in behind it."
  • if (current.RemainingTime > 0) { readyQueue.Enqueue(current); } else { result.CompletionTime[current.Id] = time; } is the fork every other algorithm here lacks: a process that still has burst time left goes back into the queue instead of being marked complete.
  • AdvanceToNextArrivalIfIdle(); at the bottom of the loop covers a queue that just went empty because the process that finished, or got preempted, was the last one present, while others are still due to arrive later.
  1. Run the program again and read all four report blocks: FCFS, SJF (non-preemptive), Priority (non-preemptive), Round Robin (quantum = 4).
  2. Match each block's dataset against the worked examples in this lesson: the FCFS block matches Problem 1 from Task 1, the SJF block matches Problem 3 from Task 1, the Priority block matches the first priority example from the Priority Scheduling section (lower number = higher priority, all processes arriving at time 0), and the Round Robin block matches Problem 6 from Task 1.
  3. Expected output: SJF (non-preemptive) prints Average Waiting Time = 7.00; Priority (non-preemptive) prints Average Waiting Time = 8.20; Round Robin (quantum = 4) prints Average Waiting Time = 5.67 and Average Turnaround Time = 15.67. A mismatch is almost always a tie-break bug (earliest arrival, then smallest ID) or a queue-ordering bug: a newly-arrived process must join the queue before the preempted process goes back in.

Task 5: Add your own dataset and quantum

  1. In the Main method (shown in the complete program listing at the end of this lesson), add a fifth block that runs one of the four problems you solved yourself in Task 2 (pick the Round Robin one, since it exercises the most code) through the matching Scheduler.Run... method, and print its report with Report.Print.
  2. Change the quantum passed to Scheduler.RunRoundRobin from 4 to a much larger value (say, 30) on the Problem 6 dataset from Task 1, and run the program again.
  3. Expected output: the new block's averages should match your hand-solved Task 2 answer. With quantum 30, the Round Robin block on the Problem 6 dataset should collapse into an unbroken FCFS-style run of P1, then P2, then P3, with a visibly worse (higher) average waiting time than the quantum-4 run: the quantum trade-off from the Round Robin section, made concrete.

The graded submission for this lesson follows the same model as the earlier FCFS assignment: it is checked against a hidden unit-test suite delivered through GitHub Classroom, not against the complete program listing shown at the end of this lesson, so match your own method names and return shapes to whatever the classroom template for this assignment specifies before relying on this listing as a reference.

Summary

  • Every algorithm is judged against the same five criteria: efficiency, throughput, turnaround time, waiting time and response time; every problem reduces to a correct Gantt chart followed by arithmetic.
  • FCFS is simple and non-preemptive but suffers from the convoy effect: a long process arriving early forces every short process behind it to wait far longer than necessary.
  • SJF (and its preemptive form, SRTF) minimizes average waiting time by favoring the shortest next CPU burst, at the cost of needing to predict a burst length that cannot truly be known in advance.
  • Priority scheduling generalizes SJF to an arbitrary priority number, preemptive or not; its weakness, starvation of low-priority processes, is fixed with aging.
  • Round Robin adds a time quantum to FCFS-style scheduling so every process gets a share of the CPU, essential for time-sharing systems; the quantum is a trade-off between FCFS-like behavior (too large) and excessive context-switch overhead (too small).
  • Multilevel Queue scheduling partitions processes into permanent groups, each with its own algorithm and fixed-priority scheduling among the queues; Multilevel Feedback Queue scheduling adds promotion and demotion between queues, using aging against starvation.
  • The simulator shown in the complete program listing below ties all four concrete algorithms together around the same Process, GanttSlice and ScheduleResult types and the same turnaround/waiting-time formulas from the original FCFS class; only the rule for picking the next process, and whether it ever returns to the ready queue, changes between them.

Complete program listing

using System;
using System.Collections.Generic;
using System.Linq;
 
namespace SchedulingSimulator
{
    /// <summary>
    /// A single process, arrival-time aware. This replaces the plain
    /// double[] of burst times used by the earlier FCFS class: every
    /// process now knows when it is allowed to enter the ready queue,
    /// and carries a priority for the Priority-scheduling algorithm.
    /// </summary>
    public class Process
    {
        public string Id { get; }
        public int ArrivalTime { get; }
        public int BurstTime { get; }
        public int Priority { get; }       // lower value = higher priority
        public int RemainingTime { get; set; }
 
        public Process(string id, int arrivalTime, int burstTime, int priority = 0)
        {
            Id = id;
            ArrivalTime = arrivalTime;
            BurstTime = burstTime;
            Priority = priority;
            RemainingTime = burstTime;
        }
    }
 
    /// <summary>One bar of the Gantt chart: which process ran, and from when to when.</summary>
    public readonly struct GanttSlice
    {
        public string ProcessId { get; }
        public int Start { get; }
        public int End { get; }
 
        public GanttSlice(string processId, int start, int end)
        {
            ProcessId = processId;
            Start = start;
            End = end;
        }
    }
 
    /// <summary>
    /// Everything a scheduling run produces: the full Gantt chart, plus
    /// a completion time per process. That is all Report.Print needs to
    /// derive turnaround time and waiting time, using the same two
    /// formulas as the original FCFS class.
    /// </summary>
    public class ScheduleResult
    {
        public List<GanttSlice> Gantt { get; } = new();
        public Dictionary<string, int> CompletionTime { get; } = new();
    }
 
    public static class Scheduler
    {
        /// <summary>
        /// First-Come, First-Served: sort by arrival time (ties broken by
        /// process Id), then run each process to completion in that
        /// order. Non-preemptive. Inserts an "IDLE" slice whenever the
        /// CPU would otherwise sit empty waiting for the next arrival.
        /// </summary>
        public static ScheduleResult RunFcfs(List<Process> processes)
        {
            var result = new ScheduleResult();
            var ordered = processes.OrderBy(p => p.ArrivalTime).ThenBy(p => p.Id).ToList();
            int time = 0;
 
            foreach (var p in ordered)
            {
                if (p.ArrivalTime > time)
                {
                    result.Gantt.Add(new GanttSlice("IDLE", time, p.ArrivalTime));
                    time = p.ArrivalTime;
                }
 
                int end = time + p.BurstTime;
                result.Gantt.Add(new GanttSlice(p.Id, time, end));
                result.CompletionTime[p.Id] = end;
                time = end;
            }
 
            return result;
        }
 
        /// <summary>
        /// Shortest Job First, non-preemptive: at every decision point,
        /// pick the arrived process with the smallest burst time. Ties
        /// go to whoever arrived first, then to the smaller Id, exactly
        /// the FCFS tie-break rule from the lecture.
        /// </summary>
        public static ScheduleResult RunSjf(List<Process> processes)
        {
            var result = new ScheduleResult();
            var remaining = new List<Process>(processes);
            int time = 0;
 
            while (remaining.Count > 0)
            {
                var ready = remaining.Where(p => p.ArrivalTime <= time).ToList();
                if (ready.Count == 0)
                {
                    int nextArrival = remaining.Min(p => p.ArrivalTime);
                    result.Gantt.Add(new GanttSlice("IDLE", time, nextArrival));
                    time = nextArrival;
                    ready = remaining.Where(p => p.ArrivalTime <= time).ToList();
                }
 
                var next = ready
                    .OrderBy(p => p.BurstTime)
                    .ThenBy(p => p.ArrivalTime)
                    .ThenBy(p => p.Id)
                    .First();
 
                int end = time + next.BurstTime;
                result.Gantt.Add(new GanttSlice(next.Id, time, end));
                result.CompletionTime[next.Id] = end;
                time = end;
                remaining.Remove(next);
            }
 
            return result;
        }
 
        /// <summary>
        /// Priority scheduling, non-preemptive: lower Priority value
        /// wins; ties go to whoever arrived first, then to the smaller
        /// Id. Structurally identical to RunSjf above, just ordering by
        /// Priority instead of BurstTime -- SJF is, after all, priority
        /// scheduling where the priority is the burst time itself.
        /// </summary>
        public static ScheduleResult RunPriority(List<Process> processes)
        {
            var result = new ScheduleResult();
            var remaining = new List<Process>(processes);
            int time = 0;
 
            while (remaining.Count > 0)
            {
                var ready = remaining.Where(p => p.ArrivalTime <= time).ToList();
                if (ready.Count == 0)
                {
                    int nextArrival = remaining.Min(p => p.ArrivalTime);
                    result.Gantt.Add(new GanttSlice("IDLE", time, nextArrival));
                    time = nextArrival;
                    ready = remaining.Where(p => p.ArrivalTime <= time).ToList();
                }
 
                var next = ready
                    .OrderBy(p => p.Priority)
                    .ThenBy(p => p.ArrivalTime)
                    .ThenBy(p => p.Id)
                    .First();
 
                int end = time + next.BurstTime;
                result.Gantt.Add(new GanttSlice(next.Id, time, end));
                result.CompletionTime[next.Id] = end;
                time = end;
                remaining.Remove(next);
            }
 
            return result;
        }
 
        /// <summary>
        /// Round Robin: a real FIFO ready queue and a configurable time
        /// quantum. This is the one algorithm above that ever puts a
        /// process back into the queue. A newly-arrived process is
        /// always enqueued BEFORE the process being preempted goes back
        /// in, matching the worked example in the lesson: an arrival at
        /// the exact instant of preemption gets ahead of the preempted
        /// process.
        /// </summary>
        public static ScheduleResult RunRoundRobin(List<Process> processes, int quantum)
        {
            var result = new ScheduleResult();
            var byArrival = processes.OrderBy(p => p.ArrivalTime).ThenBy(p => p.Id).ToList();
            var readyQueue = new Queue<Process>();
            int time = 0;
            int nextToArrive = 0;
 
            void EnqueueArrivals(int upToTime)
            {
                while (nextToArrive < byArrival.Count && byArrival[nextToArrive].ArrivalTime <= upToTime)
                {
                    readyQueue.Enqueue(byArrival[nextToArrive]);
                    nextToArrive++;
                }
            }
 
            void AdvanceToNextArrivalIfIdle()
            {
                if (readyQueue.Count == 0 && nextToArrive < byArrival.Count)
                {
                    int nextArrival = byArrival[nextToArrive].ArrivalTime;
                    if (nextArrival > time)
                    {
                        result.Gantt.Add(new GanttSlice("IDLE", time, nextArrival));
                    }
                    time = nextArrival;
                    EnqueueArrivals(time);
                }
            }
 
            EnqueueArrivals(0);
            AdvanceToNextArrivalIfIdle();
 
            while (readyQueue.Count > 0)
            {
                var current = readyQueue.Dequeue();
                int start = time;
                int slice = Math.Min(quantum, current.RemainingTime);
                time += slice;
                current.RemainingTime -= slice;
                result.Gantt.Add(new GanttSlice(current.Id, start, time));
 
                // Arrivals up to "time" join the tail first...
                EnqueueArrivals(time);
                // ...then the process we just preempted goes in behind them.
                if (current.RemainingTime > 0)
                {
                    readyQueue.Enqueue(current);
                }
                else
                {
                    result.CompletionTime[current.Id] = time;
                }
 
                AdvanceToNextArrivalIfIdle();
            }
 
            return result;
        }
    }
 
    public static class Report
    {
        /// <summary>
        /// Turnaround Time = Completion Time - Arrival Time
        /// Waiting Time    = Turnaround Time - Burst Time
        /// These are exactly the formulas from the earlier FCFS class;
        /// they still work here because every algorithm above only
        /// needs to fill in a completion time per process.
        /// </summary>
        public static void Print(string title, List<Process> processes, ScheduleResult result)
        {
            Console.WriteLine();
            Console.WriteLine($"=== {title} ===");
 
            Console.WriteLine("Gantt chart:");
            Console.WriteLine(string.Join(" | ", result.Gantt.Select(s => $"{s.ProcessId} ({s.Start}-{s.End})")));
            Console.WriteLine();
 
            double totalWaiting = 0;
            double totalTurnaround = 0;
 
            Console.WriteLine("Process  AT  BT  CT  TAT  WT");
            foreach (var p in processes.OrderBy(p => p.Id))
            {
                int ct = result.CompletionTime[p.Id];
                int tat = ct - p.ArrivalTime;
                int wt = tat - p.BurstTime;
                totalTurnaround += tat;
                totalWaiting += wt;
                Console.WriteLine($"{p.Id,-7}  {p.ArrivalTime,2}  {p.BurstTime,2}  {ct,2}  {tat,3}  {wt,3}");
            }
 
            Console.WriteLine();
            Console.WriteLine($"Average Waiting Time = {totalWaiting / processes.Count:0.00}");
            Console.WriteLine($"Average Turnaround Time = {totalTurnaround / processes.Count:0.00}");
        }
    }
 
    public static class Program
    {
        public static void Main()
        {
            // Dataset from Problem 1 (FCFS, Task 1). Expected:
            // Average Waiting Time = 4.40, Average Turnaround Time = 8.00
            var fcfsProcesses = new List<Process>
            {
                new("P1", arrivalTime: 4, burstTime: 5),
                new("P2", arrivalTime: 6, burstTime: 4),
                new("P3", arrivalTime: 0, burstTime: 3),
                new("P4", arrivalTime: 6, burstTime: 2),
                new("P5", arrivalTime: 5, burstTime: 4),
            };
            Report.Print("FCFS", fcfsProcesses, Scheduler.RunFcfs(fcfsProcesses));
 
            // Dataset from Problem 3 (SJF, non-preemptive, Task 1). Expected:
            // Average Waiting Time = 7.00
            var sjfProcesses = new List<Process>
            {
                new("P1", arrivalTime: 0, burstTime: 6),
                new("P2", arrivalTime: 0, burstTime: 8),
                new("P3", arrivalTime: 0, burstTime: 7),
                new("P4", arrivalTime: 0, burstTime: 3),
            };
            Report.Print("SJF (non-preemptive)", sjfProcesses, Scheduler.RunSjf(sjfProcesses));
 
            // Dataset from the Priority Scheduling section's example
            // (lower number = higher priority). Expected:
            // Average Waiting Time = 8.20
            var priorityProcesses = new List<Process>
            {
                new("P1", arrivalTime: 0, burstTime: 10, priority: 3),
                new("P2", arrivalTime: 0, burstTime: 1, priority: 1),
                new("P3", arrivalTime: 0, burstTime: 2, priority: 4),
                new("P4", arrivalTime: 0, burstTime: 1, priority: 5),
                new("P5", arrivalTime: 0, burstTime: 5, priority: 2),
            };
            Report.Print("Priority (non-preemptive)", priorityProcesses, Scheduler.RunPriority(priorityProcesses));
 
            // Dataset from Problem 6 (Round Robin, quantum = 4, Task 1). Expected:
            // Average Waiting Time = 5.67, Average Turnaround Time = 15.67
            var rrProcesses = new List<Process>
            {
                new("P1", arrivalTime: 0, burstTime: 24),
                new("P2", arrivalTime: 0, burstTime: 3),
                new("P3", arrivalTime: 0, burstTime: 3),
            };
            Report.Print("Round Robin (quantum = 4)", rrProcesses, Scheduler.RunRoundRobin(rrProcesses, quantum: 4));
        }
    }
}