All A Level 18 65 min read 10 question quiz 22 exam marks

Scheduling, Concurrency and Deadlock

How an operating system shares one processor between many programs, and what goes wrong when they share data. Simulate first come first served, shortest job first, shortest remaining time, round robin and multi-level queues, then reproduce a race condition and a deadlock deterministically, and finish with real threads and processes.

Sharing one processor: scheduling

A processor can run only one thing at a time per core, yet a computer seems to run dozens of programs at once. The operating system's scheduler makes that happen. A running program is a process. The scheduler keeps the processes that are ready in a ready queue, picks one, lets it run for a while, and then picks another. Switching between them is a context switch: the state of the old process (its registers and program counter) is saved, and the state of the new one is loaded.

A scheduling algorithm tries to balance several goals, and they pull in different directions:

  • throughput: finish as many processes as possible
  • fairness: give every process a share, and let none starve
  • response time: how soon a process starts running after it is ready
  • turnaround time: from arrival to completion
  • waiting time: the time spent in the ready queue, which is turnaround time minus the time actually running

A scheduler is pre-emptive if it can take the processor away from a running process. A non-pre-emptive one lets a process run until it finishes or waits.

Four algorithms

Take four processes, each with an arrival time and a burst time (how long it needs the processor):

ProcessArrivesBurst
A08
B14
C29
D35
  • First come first served (FCFS). Run them in order of arrival. Simple and fair in its way, but one long job at the front delays every short one behind it (the convoy effect).
  • Shortest job first (SJF). When the processor is free, run the waiting process with the smallest burst. It gives the best average waiting time for a non-pre-emptive scheduler, but needs to know how long each job will take, and long jobs may be starved.
  • Shortest remaining time (SRT). A pre-emptive SJF: whenever a process arrives that needs less time than the one running has left, switch to it.
Python
procs = [("A", 0, 8), ("B", 1, 4), ("C", 2, 9), ("D", 3, 5)]      # name, arrival, burst

def fcfs(procs):
    t, timeline = 0, []
    for name, arrival, burst in sorted(procs, key=lambda p: p[1]):
        while t < arrival:
            timeline.append(".")                  # the processor is idle
            t += 1
        timeline.extend([name] * burst)
        t += burst
    return timeline

def sjf(procs):
    todo, t, timeline = list(procs), 0, []
    while todo:
        ready = [p for p in todo if p[1] <= t]
        if not ready:
            timeline.append(".")
            t += 1
            continue
        job = min(ready, key=lambda p: p[2])      # the shortest of those waiting
        todo.remove(job)
        timeline.extend([job[0]] * job[2])
        t += job[2]
    return timeline

def srt(procs):
    left = {name: burst for name, arrival, burst in procs}
    t, timeline = 0, []
    while any(left.values()):
        ready = [(left[n], n) for n, arrival, burst in procs if arrival <= t and left[n] > 0]
        if not ready:
            timeline.append(".")
            t += 1
            continue
        _, name = min(ready)                      # the least time still to go
        left[name] -= 1
        timeline.append(name)
        t += 1
    return timeline

def report(title, timeline, procs):
    waits, turns, responses = [], [], []
    for name, arrival, burst in procs:
        finish = max(i for i, who in enumerate(timeline) if who == name) + 1
        start = timeline.index(name)
        turns.append(finish - arrival)
        waits.append(finish - arrival - burst)
        responses.append(start - arrival)
    count = len(procs)
    print(title.ljust(14), "".join(timeline))
    print(" " * 14, "average wait", round(sum(waits) / count, 2), "| turnaround", round(sum(turns) / count, 2),
          "| response", round(sum(responses) / count, 2))

report("first come", fcfs(procs), procs)
report("shortest job", sjf(procs), procs)
report("shortest left", srt(procs), procs)

Each letter in the line is one unit of time, so the line is a Gantt chart of who had the processor. Notice that SRT has the lowest waiting time of the three, and that FCFS has the worst: process D waited behind everything.

Round robin and multi-level queues

Round robin is built for sharing. Each process gets a fixed slice of time called the quantum. If it is not finished when the quantum ends, it is pre-empted and joins the back of the queue. Nobody waits long to start, so it suits interactive systems.

Python
procs = [("A", 0, 8), ("B", 1, 4), ("C", 2, 9), ("D", 3, 5)]

def round_robin(procs, quantum):
    left = {name: burst for name, arrival, burst in procs}
    arrivals = sorted(procs, key=lambda p: p[1])
    queue, t, timeline, i = [], 0, [], 0
    while any(left.values()):
        while i < len(arrivals) and arrivals[i][1] <= t:
            queue.append(arrivals[i][0])
            i += 1
        if not queue:
            timeline.append(".")
            t += 1
            continue
        name = queue.pop(0)
        for _ in range(min(quantum, left[name])):
            timeline.append(name)
            t += 1
            left[name] -= 1
            while i < len(arrivals) and arrivals[i][1] <= t:
                queue.append(arrivals[i][0])
                i += 1
        if left[name] > 0:
            queue.append(name)                  # not finished: back of the queue
    return timeline

def report(title, timeline, procs):
    waits, responses = [], []
    for name, arrival, burst in procs:
        finish = max(i for i, who in enumerate(timeline) if who == name) + 1
        waits.append(finish - arrival - burst)
        responses.append(timeline.index(name) - arrival)
    count = len(procs)
    print(title.ljust(14), "".join(timeline))
    print(" " * 14, "average wait", round(sum(waits) / count, 2), "| response", round(sum(responses) / count, 2))

for quantum in (1, 3, 8):
    report("quantum " + str(quantum), round_robin(procs, quantum), procs)

Round robin has a worse average waiting time than FCFS here, but a far better response time: every process starts within a few units. The quantum is a trade-off. If it is too long, round robin behaves like FCFS (look at the quantum of 8). If it is too short, the processor wastes more and more time on context switches.

A multi-level feedback queue (MLFQ) has several queues of different priorities, so it can serve both kinds of job:

  1. A new process enters the top queue, which has the shortest quantum.
  2. The scheduler always runs a process from the highest queue that is not empty.
  3. A process that uses its whole quantum is demoted to the queue below, which has a longer quantum.
  4. A process that gives up the processor early, for example to wait for the keyboard, stays where it is.
  5. To stop low-priority processes starving, a process that waits too long is moved up again (this is called aging).

Short and interactive jobs finish in the top queues, while long calculations sink to the bottom and run in big slices.

Which is best?

None of them. FCFS is simple, SJF and SRT minimise waiting but need to know the future and can starve long jobs, round robin gives fair sharing and good response, and a multi-level queue tries to combine them. Choose by what the system is for.

Three scheduling algorithms run on the same three jobsThree jobs arrive together: A needs 5 time units of processor time, B needs 3 and C needs 2. Each scheduling algorithm is drawn as a chart of who has the processor over time. First come first served runs A for 0 to 5, B for 5 to 8 and C for 8 to 10. The long job A makes the others wait, the average wait is 4.3 and the last job does not start until time 8. Shortest job first runs C for 0 to 2, B for 2 to 5 and A for 5 to 10. It has the lowest average wait, 2.3, but it must know how long each job is in advance. Round robin with a time slice of 2 runs A, B, C, A, B, A for slices of 2, 2, 2, 2, 1 and 1. It is fair, every job has started by time 4, but the average wait is 5.0.Three jobs arrive together:A needs 5B needs 3C needs 2time units of CPUFirst come first servedABC05810avg wait 4.3all started by 8long job blocks others1Shortest job firstCBA02510avg wait 2.3all started by 5must know the lengths2Round robin, slice of 2ABCABA02468910avg wait 5.0all started by 4fair share of the CPU3Wait = time spent waiting for the CPU before the job finishes.
  1. First come first served: jobs run in arrival order, so the long job A holds up B and C.
  2. Shortest job first: the lowest average wait, but it needs to know the lengths and can starve long jobs.
  3. Round robin: each job gets a short slice in turn, so everyone starts quickly and shares the CPU fairly.
There is no best algorithm. Each trades fairness, response time and average waiting time.

Tasks that take turns

Concurrency means several tasks are in progress at the same time, whether or not any two are being executed at the same instant. On one core the processor switches rapidly between them. Parallelism means tasks really are executing at the same instant, on separate cores. A concurrent program may run in parallel, but does not need to.

You can model concurrency in Python without any special tools, using generators. A function that contains yield can pause in the middle and be resumed exactly where it left off. A yield is a point where the task offers to give up the processor, and a small scheduler decides who runs next.

Python
def task(name, steps):
    for step in range(1, steps + 1):
        print(name, "does step", step)
        yield                          # pause here: someone else can have a turn

def scheduler(tasks):
    queue = list(tasks)
    while queue:
        current = queue.pop(0)
        try:
            next(current)              # run it until its next yield
            queue.append(current)      # not finished: back of the queue
        except StopIteration:
            pass                       # finished: leave the queue

scheduler([task("A", 3), task("B", 2), task("C", 4)])

The output interleaves A, B and C. This is exactly round robin with a quantum of one step. Real schedulers use a timer interrupt to take the processor away, instead of waiting for a yield, but the effect on the programs is the same: the task is paused in the middle, and later carries on.

This model is useful because it is deterministic. You can choose exactly which task runs when, and so reproduce a bug that on a real system would appear once in a million runs.

Race conditions and locks

Tasks that share data can interfere. Suppose two tasks each deposit money into one account. Each does three things: read the balance, calculate the new balance, write it back. If task B reads the balance between task A's read and write, A's deposit is lost. The result depends on timing, which is a race condition.

Model the two steps that matter (read, then write) with a yield between them, and try every possible interleaving of the two tasks:

Python
from itertools import permutations

def deposit(shared, amount):
    seen = shared["balance"]              # read
    yield                                 # the scheduler may switch tasks here
    shared["balance"] = seen + amount     # write

def run(order):
    shared = {"balance": 100}
    tasks = {"A": deposit(shared, 50), "B": deposit(shared, 25)}
    for who in order:
        try:
            next(tasks[who])
        except StopIteration:
            pass
    return shared["balance"]

outcomes = {}
for order in sorted(set(permutations("AABB"))):
    outcomes["".join(order)] = run(order)

for order, balance in outcomes.items():
    print(order, "->", balance)
print("possible final balances:", sorted(set(outcomes.values())))

The right answer is 100 + 50 + 25 = 175. It is only produced when A finishes completely before B starts, or the other way round. In the other four orderings one deposit overwrites the other, giving 125 or 150. The program is sometimes right, which is the worst kind of wrong.

The section of code that must not be interleaved is a critical section. The solution is mutual exclusion: only one task may be inside it at a time. A lock (or mutex) does this. A task must acquire the lock before entering, and others wait until it is released.

Python
from itertools import permutations

def safe_deposit(shared, amount):
    while shared["locked"]:
        yield                              # wait for the lock to be free
    shared["locked"] = True                # acquire the lock
    seen = shared["balance"]
    yield                                  # even a switch here is now harmless
    shared["balance"] = seen + amount
    shared["locked"] = False               # release the lock

def run(order):
    shared = {"balance": 100, "locked": False}
    tasks = {"A": safe_deposit(shared, 50), "B": safe_deposit(shared, 25)}
    position = 0
    while tasks:
        who = order[position % len(order)]    # follow the plan, skipping tasks that have finished
        position += 1
        if who in tasks:
            try:
                next(tasks[who])
            except StopIteration:
                del tasks[who]
    return shared["balance"]

balances = {run(order) for order in set(permutations("AABB"))}
print("possible final balances:", balances)

Every order now gives 175. Locks have a cost: waiting tasks do nothing, and a lock that is never released stops everything behind it. A semaphore generalises a lock to a counter, allowing up to n tasks into a section at once, such as n connections to a database.

Always release

A lock acquired and never released blocks every other task for good. In real code use with lock:, which releases it even if an error occurs inside.

A race condition: two deposits to the same balance, with and without a lockTwo tasks, A and B, each deposit money into one account whose balance starts at 100. A deposits 50 and B deposits 25, so the right answer is 175. Without a lock the tasks overlap: A reads 100, then B reads 100, then A writes 150, then B writes 125. B has overwritten A's deposit and the balance is 125, so a deposit has been lost. With a lock, B waits until A has finished: A reads 100 and writes 150, then B reads 150 and writes 175. The balance is correct.no lock: the two tasks overlap1task Atask Bbalancereads100reads100writes 150150writes 125125wrong: A's deposit is lostwith a lock: B waits until A has finished2task Atask Bbalancereads100writes 150150reads150writes 175175correct: 175
  1. Without a lock A and B both read 100 before either has written, so each works from the same old value.
  2. With a lock only one task is inside the critical section at a time, so B reads the balance after A has written it.
The result depends on the timing, which is why the fault appears only sometimes. A lock makes the read and the write one indivisible step.

Deadlock

Locks fix one problem and can cause another. Suppose task 1 needs lock A and then lock B, and task 2 needs B and then A. If task 1 takes A and task 2 takes B, each is now waiting for a lock the other holds. Neither can go on, and neither will ever let go. This is deadlock.

A deadlock needs four conditions at once (the Coffman conditions):

  1. Mutual exclusion. A resource can be used by only one task at a time.
  2. Hold and wait. A task holds one resource while waiting for another.
  3. No pre-emption. A resource cannot be forcibly taken from the task holding it.
  4. Circular wait. A ring of tasks, each waiting for a resource held by the next.

Here is a tiny simulation. Each task is a generator that yields requests to acquire or release a lock, and the scheduler grants them if it can. If a whole round passes with no progress, the tasks are deadlocked. The scheduler then builds the wait-for graph and looks for a cycle.

Python
def worker(first, second):
    yield ("acquire", first)
    yield ("acquire", second)
    yield ("release", second)
    yield ("release", first)

def simulate(tasks):
    held, waiting, requests = {}, {}, {}
    live = dict(tasks)
    for name, gen in live.items():
        requests[name] = next(gen)
    log = []
    while live:
        progress = False
        for name in list(live):
            kind, lock = requests[name]
            if kind == "acquire" and lock in held:
                waiting[name] = lock             # blocked: someone else holds it
                continue
            waiting.pop(name, None)
            if kind == "acquire":
                held[lock] = name
            else:
                del held[lock]
            log.append(name + " " + kind + " " + lock)
            progress = True
            try:
                requests[name] = next(live[name])
            except StopIteration:
                del live[name]
        if not progress:
            return log, waiting, held             # nobody can move
    return log, {}, {}

def find_cycle(waiting, held):
    graph = {task: held[lock] for task, lock in waiting.items()}     # waits for whoever holds its lock
    for start in graph:
        seen, node = [], start
        while node in graph and node not in seen:
            seen.append(node)
            node = graph[node]
        if node in seen:
            return seen[seen.index(node):]
    return None

print("opposite order:")
log, waiting, held = simulate({"T1": worker("A", "B"), "T2": worker("B", "A")})
print("  ", log)
print("   waiting:", waiting, "| held:", held)
print("   cycle:", find_cycle(waiting, held))

print("same order:")
log, waiting, held = simulate({"T1": worker("A", "B"), "T2": worker("A", "B")})
print("  ", log)
print("   waiting:", waiting)

In the first run T1 holds A and wants B, while T2 holds B and wants A: a cycle, so deadlock. In the second, both ask for the locks in the same order. T2 simply waits for T1 to finish, so a circular wait is impossible.

Dealing with deadlock

ApproachHow
PreventionMake one of the four conditions impossible. The usual way: every task acquires locks in the same global order.
AvoidanceOnly grant a request if the system will still be in a safe state afterwards.
Detection and recoveryAllow deadlocks, find them (a cycle in the wait-for graph), then end one task or take a resource back. Databases do this.
Ignore itMany operating systems leave it to the programmer, on the grounds that it is rare.

A related problem is starvation: a task that never gets a turn, because others keep being chosen first. The ageing in a multi-level queue is there to prevent it.

Deadlock: each task holds one lock and waits for the otherTwo tasks, T1 and T2, and two locks, A and B. T1 holds lock A and wants lock B. T2 holds lock B and wants lock A. Drawn as a graph, the arrows form a circle: T1 wants B, B is held by T2, T2 wants A, and A is held by T1. Neither task can go on, and neither will ever let go. Deadlock needs four things to be true at once: mutual exclusion, hold and wait, no pre-emption, and circular wait. It can be prevented by breaking one of them, most simply by making every task acquire the locks in the same order, so that a circle cannot form.two tasks, two locks, and a circle of waitingT1 wants BB is heldby T2T2 wants AA is heldby T1T1T2lock Block A1no task can moveall four must be truemutual exclusionone holder at a timehold and waitkeeps one lock while waitingno pre-emptiona lock cannot be taken awaycircular waita ring of tasks waiting2prevent it: lock Aalways before lock B
  1. The wait for graph has a cycle: T1 wants B, T2 holds B and wants A, and T1 holds A. Nobody can move.
  2. Deadlock needs all four conditions. Break any one, for example circular wait by always locking A before B, and it cannot happen.
Databases detect deadlocks by looking for exactly this kind of cycle, then end one of the tasks to break it.

Threads, processes and parallel speed-up

A process is a running program with its own memory. A thread is a path of execution inside a process. Threads of one process share its memory, which makes them quick to create and easy to share data between, and also the source of every race condition above. Separate processes do not share memory, so they are safer and slower to communicate.

Python's real threading module runs in a browser tab only as a simulation, so the examples in this section need your own computer. The first uses a lock to protect a counter shared between four threads:

Python (on your computer)
import threading

total = 0
lock = threading.Lock()

def count(times):
    global total
    for _ in range(times):
        with lock:                    # only one thread at a time in this block
            total += 1

threads = [threading.Thread(target=count, args=(100000,)) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()                          # wait for each thread to finish

print(total)                          # always 400000 with the lock

A queue is the safe way to hand work from one thread to another. put waits if it is full and get waits if it is empty, so the producer and consumer never need a lock of their own:

Python (on your computer)
import queue
import threading

jobs = queue.Queue(maxsize=3)

def producer():
    for n in range(6):
        jobs.put(n)
    jobs.put(None)                    # a marker meaning: no more work

def consumer():
    while True:
        item = jobs.get()
        if item is None:
            break
        print("handled", item)

workers = [threading.Thread(target=producer), threading.Thread(target=consumer)]
for w in workers:
    w.start()
for w in workers:
    w.join()

For many similar jobs, a pool is tidier. The next program fetches five things that each take half a second to arrive. One after another they would take two and a half seconds. In a pool of threads, they all wait at once:

Python (on your computer)
import time
from concurrent.futures import ThreadPoolExecutor

def fetch(n):
    time.sleep(0.5)                   # stands for waiting on a network reply
    return n * n

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
    results = list(pool.map(fetch, range(5)))
print(results, round(time.perf_counter() - start, 1), "seconds")     # about 0.5

The same job with asyncio uses a single thread, and tasks that hand over control at each await. It is the same cooperative idea as the generators earlier, with the language doing the scheduling:

Python (on your computer)
import asyncio

async def fetch(n):
    await asyncio.sleep(0.5)
    return n * n

async def main():
    results = await asyncio.gather(*(fetch(n) for n in range(5)))
    print(results)

asyncio.run(main())

Threads do not speed up heavy calculation

The standard version of Python has a global interpreter lock (the GIL): only one thread executes Python code at a time. Threads still help when programs spend their time waiting for a network or a disk, because the lock is released while they wait. But for a long calculation, four threads take as long as one. The remedy is separate processes, each with its own interpreter:

Python (on your computer)
from multiprocessing import Pool

def count_primes(limit):
    count = 0
    for n in range(2, limit):
        if all(n % d for d in range(2, int(n ** 0.5) + 1)):
            count += 1
    return count

if __name__ == "__main__":
    with Pool(4) as pool:             # four processes, so up to four cores
        print(pool.map(count_primes, [60000] * 4))

How much faster can parallel processing be?

If a program is split between n processors, you might expect n times the speed. Amdahl's law says why not: part of any program cannot be split up (reading the input, combining the results). If a fraction p of the work can be run in parallel, the best possible speed-up with n processors is:

speed-up = 1 / ((1 - p) + p / n)

Python
def speedup(p, n):
    return 1 / ((1 - p) + p / n)

print("processors  90% parallel  50% parallel")
for n in (1, 2, 4, 8, 16, 64, 1000):
    print(str(n).ljust(11), str(round(speedup(0.9, n), 2)).ljust(13), round(speedup(0.5, n), 2))
print("the limit when 90% is parallel is", round(1 / (1 - 0.9)), "times, however many processors")

A program that is 90% parallel can never go more than ten times faster, even with a thousand processors. The part that cannot be shared sets the limit. That is why parallel hardware pays off only for software designed to use it.

Why more cores is not simply more speedWhy more cores is not simply more speed. A single core takes jobs from the queue one after another, so four jobs take four times as long as one. Four cores can take one job each at the same moment, and the queue clears in roughly a quarter of the time. That only works if the work can genuinely be split: when job three needs the result of job two it has to wait however many cores are sitting idle, which is why doubling the cores rarely doubles the speed. A graphics processor takes the idea much further, with thousands of tiny cores that all do the same kind of job at once, which suits work that splits perfectly, such as the pixels in a picture.One core: the jobs queue upCore 1Job 1Job 2Job 3Job 4timeFour cores: four jobs at onceCore 1Job 1Core 2Job 2Core 3Job 3Core 4Job 4done in a quarter of the time...but only if the work can be splitCore 1Job 1Core 2Job 2Core 3Job 3Job 3 needs Job 2the other cores sit idleGPUthousands of tiny coresall doing the same kind of jobPixels in a pictureFrames of a filmSteps that need the lastsplit well, or not at all1234
  1. One core takes the jobs one after another, in order.
  2. Four cores take four jobs at once, so the queue clears sooner.
  3. Only if the work can be split. Some jobs must wait for the one before.
  4. A GPU takes this much further: thousands of tiny cores, one kind of job.
The exam answer is the condition, not the claim: more cores help when the task can be divided.
Four tasks with dependencies, taking 8 units one at a time and 6 units concurrentlyThinking concurrently means finding the parts of a problem that can happen at the same time. Four tasks are shown. Load comes first. Clean 1 and Clean 2 both depend on Load but not on each other. Merge needs both of them. Done one at a time they take 3 plus 2 plus 2 plus 1, which is 8 units. Done concurrently, Clean 1 and Clean 2 run side by side on two lanes, so the total is 3 plus 2 plus 1, which is 6 units. Tasks that do not depend on each other can run on different cores or machines, but the cost is coordinating them and sharing their data, and the longest chain of dependent tasks sets the shortest possible time.What depends on what1LoadClean 1Clean 2MergeTime taken2one at a timeLoadClean 1Clean 2Merge8 unitsconcurrentlyLoadClean 1Clean 2Merge6 unitsUsing concurrency3Find independent tasks:they can run side by side, on different cores or machinesThe cost:coordinating the tasks and sharing their dataThe limit:the longest chain of dependent tasks sets the minimum time
  1. A dependency graph: Load first, then two cleaning tasks that do not depend on each other, then Merge.
  2. One at a time is 8 units. Running the two cleaning tasks side by side is 6 units.
  3. Concurrency needs independent tasks and brings coordination costs. The longest dependent chain limits the saving.
Concurrency speeds things up only where tasks are independent. The longest chain of dependencies sets the minimum time.

Key terms you must be able to define

Scheduler
The part of the operating system that decides which process uses the processor, and for how long.
Context switch
Saving the state of the running process and loading the state of the next one.
Pre-emption
The operating system taking the processor away from a running process before it has finished.
Round robin
A scheduling algorithm that gives each process in turn a fixed time slice, the quantum.
Shortest job first
A scheduling algorithm that runs the waiting process with the shortest burst time.
Multi-level feedback queue
A scheduler with several queues of different priorities, in which processes move between queues according to how they behave.
Turnaround time
The time from a process arriving to its completion.
Concurrency
Having several tasks in progress at once, by taking turns or by running at the same time.
Parallelism
Tasks actually executing at the same instant on separate processors or cores.
Race condition
A fault where the result depends on the exact timing of tasks that share data.
Critical section
A part of a program that accesses shared data and must not be run by two tasks at once.
Mutual exclusion (lock)
Allowing only one task at a time into a critical section, usually with a lock.
Deadlock
A state in which every task in a group is waiting for a resource held by another, so none can go on.
Starvation
A task never getting the processor or resource it needs because others are always chosen first.

Flashcards

Say the definition out loud before you flip the card. Retrieving it from memory is what builds recall, reading it again is not.

DefineScheduler
AnswerThe part of the operating system that decides which process uses the processor, and for how long.
DefineContext switch
AnswerSaving the state of the running process and loading the state of the next one.
DefinePre-emption
AnswerThe operating system taking the processor away from a running process before it has finished.
DefineRound robin
AnswerA scheduling algorithm that gives each process in turn a fixed time slice, the quantum.
DefineShortest job first
AnswerA scheduling algorithm that runs the waiting process with the shortest burst time.
DefineMulti-level feedback queue
AnswerA scheduler with several queues of different priorities, in which processes move between queues according to how they behave.
DefineTurnaround time
AnswerThe time from a process arriving to its completion.
DefineConcurrency
AnswerHaving several tasks in progress at once, by taking turns or by running at the same time.

Reach the top grade on this topic

Scheduling is examined as calculation and as comparison. Concurrency is examined as explanation, with an example.

  • In a calculation, draw the Gantt chart first, then read the waiting times from it
  • Waiting time = completion time - arrival time - burst time
  • Compare algorithms against a criterion: fairness, response, starvation, throughput
  • For a race condition, describe the interleaving that goes wrong, not just the phrase
  • For deadlock, name the circular wait and state one way to prevent it, such as acquiring resources in the same order

Mistakes that cost marks here

Avoid these
  • Saying round robin always gives the shortest waiting time. It gives fair sharing and good response time.
  • Mixing up concurrency (tasks in progress together) with parallelism (tasks executing at the same instant).
  • Describing deadlock as a loop that never ends. It is tasks each waiting for something another one holds.
  • Forgetting to release a lock, or releasing it in the wrong place so the critical section is not protected.
  • Expecting several Python threads to speed up a long calculation. Use processes for that.
Fun fact

The first computer to be taken down by a race condition may be the Therac-25, a radiation therapy machine in the 1980s. An operator who typed quickly could change the settings after the software had checked them but before it had acted, and several patients were given doses far larger than intended.

Knowledge check

0 of 10 answered
1What is a context switch?
2Which scheduling algorithm gives each process a fixed time slice in turn?
3What is a disadvantage of shortest job first scheduling?
4A process arrives at time 2 and needs 5 units. It finishes at time 14. What was its waiting time?
5What is the effect of making the quantum in round robin very large?
6What is a race condition?
7What does a lock provide?
8Which of these is one of the four conditions needed for deadlock?
9How can deadlock be prevented in a program that needs several locks?
10Amdahl's law says that 80% of a program can run in parallel. What is the greatest possible speed-up, however many processors there are?
Score 0 / 10

Exam-style questions

Five written questions in the style of the real paper. Write a full answer, then let the marker check it against the mark scheme.

22 marks
1Describe how a round robin scheduling algorithm works.3 marks
Describe

Mark scheme0 / 3
  • LEach process is given a fixed amount of processor time (a time slice or quantum)
  • LWhen the time is used up the process is pre-empted and put at the back of the queue
  • LThe next process in the queue then runs, and processes that finish leave the queue
Model answer

Each process in the ready queue is allowed to use the processor for a fixed amount of time called a time slice or quantum. When the time slice is used up, the process is pre-empted and placed at the back of the queue, and the process at the front of the queue is given the processor next. A process that finishes before the end of its time slice leaves the queue.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
2Three processes, P1, P2 and P3, all arrive at time 0. They need 5, 3 and 1 units of processor time respectively. Calculate the average waiting time when they are scheduled using (a) first come first served, in the order P1, P2, P3 and (b) shortest job first. State which gives the lower average.4 marks
Calculate

Mark scheme0 / 4
  • LFCFS: waiting times of 0, 5 and 8 for P1, P2 and P3
  • LFCFS average = 13 / 3 = 4.33
  • LSJF order P3, P2, P1 gives waiting times of 0, 1 and 4, with an average of 5 / 3 = 1.67
  • LShortest job first gives the lower average
Model answer

With first come first served, P1 runs from 0 to 5 so waits 0, P2 runs from 5 to 8 so waits 5, and P3 runs from 8 to 9 so waits 8. The average is (0 + 5 + 8) / 3 = 13 / 3 = 4.33 units. With shortest job first the order is P3, P2, P1. P3 waits 0, P2 waits 1 and P1 waits 4, so the average is (0 + 1 + 4) / 3 = 5 / 3 = 1.67 units. Shortest job first gives the lower average waiting time.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
3Explain what is meant by a race condition, and how it can be prevented.4 marks
Explain

Mark scheme0 / 4
  • LTwo (or more) processes or threads access shared data (the same variable)
  • LThe result depends on the order or timing in which they run (they interleave)
  • LFor example, one task reads a value, another changes it, then the first writes back, so an update is lost
  • LIt can be prevented by mutual exclusion: a lock (mutex or semaphore) so only one task is in the critical section at a time
Model answer

A race condition happens when two or more processes or threads access the same shared data and the result depends on the order or timing in which they run. For example, one thread may read a balance, then another thread updates it, and then the first writes back its result, so that the second update is lost. It can be prevented using mutual exclusion: the code that uses the shared data is made a critical section, protected by a lock (mutex or semaphore), so that only one thread can be in it at any time.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
4Two processes each need exclusive use of both a printer and a scanner before they can continue. Process 1 has been allocated the printer and Process 2 has been allocated the scanner. Explain why this situation is a deadlock, and describe one way in which deadlock could be prevented.5 marks
Explain

Mark scheme0 / 5
  • LProcess 1 is waiting for the scanner, which is held by Process 2
  • LProcess 2 is waiting for the printer, which is held by Process 1
  • LNeither can release what it holds until it has both, so neither can ever continue (circular wait)
  • LPrevention: require resources to be requested in the same fixed order
  • LOr: a process must request all of its resources at once (or release what it holds), so there is no hold and wait
Model answer

Process 1 holds the printer and is waiting for the scanner, and Process 2 holds the scanner and is waiting for the printer. Each is waiting for a resource that the other holds, and neither can release what it holds until it has both, so neither can ever continue. This circular wait is a deadlock. It could be prevented by making every process request resources in the same fixed order, for example the printer before the scanner, so that a circular wait cannot form. Alternatively a process could be required to request everything that it needs at the same time, so that it never holds one resource while waiting for another.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
5A photo editing program applies a filter to a very large image. Discuss the use of parallel processing to make it faster.6 marks
Discuss

Mark scheme0 / 6
  • LThe image can be divided into sections, each processed on a separate core (or processor) at the same time
  • LThe work on each section is independent, so the speed-up could be close to the number of cores
  • LThere is overhead in splitting up the image and combining the results
  • LSome parts cannot be run in parallel, such as loading and saving the file, which limits the speed-up (Amdahl's law)
  • LThe software must be written to use several cores, and the computer must have them
  • LA reasoned conclusion: worthwhile for a large image because the filter can be shared out, but gains tail off as more cores are added
Model answer

The image can be divided into sections, with each section processed by a separate core at the same time. Because the filter is applied to each pixel independently, the work can be shared out very evenly and the speed-up could be close to the number of cores. There is some overhead, though, in splitting up the image and combining the results, and some parts of the program, such as loading and saving the file, cannot be done in parallel, which limits the overall speed-up as described by Amdahl's law. The software must be written to use several cores, and the computer must have them. Overall, parallel processing is worthwhile for a very large image because the filter can be shared out easily, but the gains tail off as more cores are added, because the part that cannot be parallelised takes up a bigger share of the total time.

The marker looks for key phrases. If you said it another way and it is still right, give yourself the mark.
Machine mark 0 / 22