Start of milk problem
If we're using preemption, how long should we set the quantum?
For jobs that mostly use the CPU and don't do much I/O (I/O bound processes), we want to schedule them for long quanta, so that we can efficiently use the processor. Context switching is expensive (primarily because caches need to be warmed up).
For jobs that do lots of I/O, we want to schedule them for short quanta, because this leads to higher responsiveness.
We also might prefer to run newer and shorter jobs first, since they are more likely to interact with the user: delaying them leads to a bad user experience.
RR is simple, but treats I/O bound and CPU bound processes the same, leading to poor responsiveness and long waiting times.
SRTF is provably optimal on these fronts, but requires predicting the future, which is impossible to do in general.
An adaptive multi-level queue is a hybrid approach. We estimate the length of the next CPU burst of a process by its past behavior; and run CPU-bound tasks with long quanta and I/O-bound tasks with high priority.
To do this, we maintain a collection of queues ranging in priority from highest to lowest. Each job is run with a quantum. If it consumes its quantum, then it looks like a CPU-bound job, so we decrease its priority by moving it to a lower priority queue: our goal is to quickly service user-facing (I/O-bound) processes. If it does I/O before its quantum expires, it is switched to the waiting state; when it returns from the waiting state, we place it in a higher priority queue so that the input can be serviced quickly (an easy implementation is to always add it to the highest priority queue, another possibility is to move it up by one priority level).
We also briefly discussed real time scheduling. Real time schedulers allow processes to request scheduling guarantees, such as a CPU burst of 10 ms sometimes within the next 100ms. In order to provide these guarantees, the scheduler must perform admission control: it needs the ability to deny requests for resources, and to kill or deschedule processes that attempt to use more resources than requested.
We have discussed scheduling a single processor. There are a few extra considerations when building a scheduler for a multiprocessor or multicore machine.
processor affinity: Different processors have their own caches; it can be inefficient to move a process between many different processors. Good scheduling requires taking the details of the cache hierarchy into account.
interacting processes: If multiple processes (or threads) are waiting for each other, it can make sense to let them hold onto the processor, even though they are waiting, because you expect the other processes to return responses quickly. For example, if two threads are repeatedly acquiring and releasing a shared lock, it doesn't make sense to deschedule a process when it fails to grab the lock, because the other thread may be likely to release it soon (and the context switch would be expensive). Multicore schedulers often employ some sort of gang scheduling where multiple related threads (or processes) are scheduled on a collection of processors as a group.
scheduler data structures: implementing a queue of PCBs is straightforward on a single processor, but synchronizing access to it (as we will see in the next segment of the course) can be expensive. Multiprocessor schedulers are often designed to minimize shared data structures, for example by giving each processor a separate ready queue. Care must be taken to ensure that jobs assigned to different processors are allocated processor time fairly.
We spent the remainder of class working on the following problem. Suppose we wished to write code for two threads to ensure that after completing its code, a common resource will have been acquired once and only once.
For example, the threads may represent roommates who both wish to use some milk from a shared fridge. If the milk is gone, one of them should run to the store and purchase it, but we should avoid having both roommates purchase milk at the same time.
The tools at our disposal so far: threads share memory, so they can load and store values to shared variables. We can think of this as a shared notepad that the roommates can both read and write on.
Whenever solving synchronization problems, we must consider three criteria:
safety: the code does not violate the functional spec. In the milk example, the code would violate safety if one of the threads completes without milk having been bought, or if milk is bought twice. Safety is often summarized by saying "bad things don't happen."
liveness: the code does not prevent threads from making progress. In the milk example, both roommates must eventually be able to complete the milk acquisition and go on to make their omelettes. Liveness is often summarized by "good things do happen."
fairness: the code should not favor one participant over another. A solution to the milk problem would not be fair if, for example, only one roommate ever bought milk.
Shared state: (none) |
|
Thread one code:
1: while true: 2: do nothing |
Thread two code:
3: while true: 4: do nothing |
Shared state: (none) | |
Thread one code:
1: do nothing |
Thread two code:
2: do nothing |
Shared state:
has_milk = False |
|
Thread one code:
1: while not has_milk: 2: do nothing |
Thread two code:
3: buy_milk() 4: has_milk = True |
Perhaps the most obvious thing to try is the following:
Shared state:
has_milk = False |
|
Thread one code:
1: if not has_milk: 2: buy_milk() 3: has_milk = True |
Thread two code: (same)
4: if not has_milk: 5: buy_milk() 6: has_milk = True |
The milk has been bought twice, violating safety.
One idea that was proposed is a "lock variable" that prevents one thread from going out if the other thread is working at all:
Shared state:
has_milk = False someone_busy = False |
|
Thread one code:
1: while someone_busy: 2: do nothing 3: someone_busy = True 4: if not has_milk: 5: buy_milk() 6: has_milk = True 7: someone_busy = False |
Thread two code: (same)
11: while someone_busy: 12: do nothing 13: someone_busy = True 14: if not has_milk: 15: buy_milk() 16: has_milk = True 17: someone_busy = False |
The intent is that only one thread can be executing between lines 3 and 7 at a time, because the other threads will notice that there is already someone in the critical section and spin in the loop on lines 1 and 2.
Unfortunately this code is still not safe, because a context switch can occur after a thread finishes line 1 but before it executes line 3. Specifically:
Again, milk has been bought twice, violating safety.
A third proposal was to use an operating-system level lock to do the synchronization for us, perhaps by descheduling the other process:
Shared state:
has_milk = False |
|
Thread one code:
1: system_call_to_force_thread_2_to_wait() 2: if not has_milk: 3: buy_milk() 4: has_milk = True 5: system_call_to_wake_up_thread_2() |
Thread two code: (symmetric)
11: system_call_to_force_thread_1_to_wait() 12: if not has_milk: 13: buy_milk() 14: has_milk = True 15: system_call_to_wake_up_thread_1() |
However, since this is 4410, we can't just assume that our operating system magically works. If we think about how we would implement this, the system call handler for the system_call_to_force_thread_to_wait must solve a similar synchronization problem: access to the shared ready and waiting queues and TCBs needs to be carefully coordinated. This can be done on a single processor machine by disabling interrupts or programming the ready and waiting cues very carefully, but to solve the problem on a multiprocessor machine will require us to solve an equivalent problem to the original problem.
I've asked you to think about the milk problem, we'll give a working solution tomorrow.