You are on page 1of 17

Memory Management

Allocating Main Memory


We first consider how to manage main (core) memory (also called random-access memory (RAM)). In general, a memory manager provides two operations:

Address allocate(int size); void deallocate(Address block); The procedure allocate receives a request for a contiguous block of size bytes of memory and returns a pointer to such a block. The procedure deallocate releases the indicated block, returning it to the free pool for reuse. Sometimes a third procedure is also provided, Address reallocate(Address block, int new_size); which takes an allocated block and changes its size, either returning part of it to the free pool or extending it to a larger block. It may not always be possible to grow the block without copying it to a new location, so reallocate returns the new address of the block. Memory allocators are used in a variety of situations. In Unix, each process has a data segment. There is a system call to make the data segment bigger, but no system call to make it smaller. Also, the system call is quite expensive. Therefore, there are library procedures (called malloc, free, and realloc) to manage this space. Only when malloc or realloc runs out of space is it necessary to make the system call. The C++ operators new and delete are just dressed-up versions of malloc and free. The Java operator new also uses malloc, and the Java runtime system calls free when an object is no found to be inaccessible during garbage collection (described below). The operating system also uses a memory allocator to manage space used for OS data structures and given to user processes for their own use. As we saw before, there are several reasons why we might want multiple processes, such as serving multiple interactive users or controlling multiple devices. There is also a selfish reason why the OS wants to have multiple processes in memory at the same time: to keep the CPU busy. Suppose there are n processes in memory (this is called the level of multiprogramming) and each process is blocked (waiting for I/O) a fraction p of the time. In the best case, when they take turns being blocked, the CPU will be 100% busy provided n(1-p) >= 1. For example, if each process is ready 20% of the time, p = 0.8 and the CPU could be kept completely busy with five processes. Of course, real processes aren't so cooperative. In the worst case, they could all decide to block at the same time, in which case, the CPU utilization (fraction of the time the CPU is busy) would be only 1 - p (20% in our example). If each processes decides randomly and independently when to block, the chance that all n processes are blocked at the same time is only pn, so CPU utilization is 1 - pn. Continuing our example in which n = 5 and p = 0.8, the expected utilization would be 1 - .85 = 1 - .32768 = 0.67232. In other words, the CPU would be busy about 67% of the time on the average.

Compaction and Garbage Collection What do you do when you run out of memory? Any of these methods can fail because all the memory is allocated, or because there is too much fragmentation. Malloc, which is being used to allocate the data segment of a Unix process, just gives up and calls the (expensive) OS call to expand the data segment. A memory manager allocating real physical memory doesn't have that luxury. The allocation attempt simply fails. There are two ways of delaying this catastrophe, compaction and garbage collection. Compaction attacks the problem of fragmentation by moving all the allocated blocks to one end of memory, thus combining all the holes. Aside from the obvious cost of all that copying, there is an important limitation to compaction: Any pointers to a block need to be updated when the block is moved. Unless it is possible to find all such pointers, compaction is not possible. Pointers can stored in the allocated blocks themselves as well as other places in the client of the memory manager. In some situations, pointers can point not only to the start of blocks but also into their bodies. For example, if a block contains executable code, a branch instruction might be a pointer to another location in the same block. Compaction is performed in three phases. First, the new location of each block is calculated to determine the distance the block will be moved. Then each pointer is updated by adding to it the amount that the block it is pointing (in)to will be moved. Finally, the data is actually moved. There are various clever tricks possible to combine these operations. Garbage collection finds blocks of memory that are inaccessible and returns them to the free list. As with compaction, garbage collection normally assumes we find all pointers to blocks, both within the blocks themselves and from the outside. If that is not possible, we can still do conservative garbage collection in which every word in memory that contains a value that appears to be a pointer is treated as a pointer. The conservative approach may fail to collect blocks that are garbage, but it will never mistakenly collect accessible blocks. There are three main approaches to garbage collection: reference counting, mark-andsweep, and generational algorithms. Reference counting keeps in each block a count of the number of pointers to the block. When the count drops to zero, the block may be freed. This approach is only practical in situations where there is some higher level software to keep track of the counts (it's much too hard to do by hand), and even then, it will not detect cyclic structures of garbage: Consider a cycle of blocks, each of which is only pointed to by its predecessor in the cycle. Each block has a reference count of 1, but the entire cycle is garbage.

Mark-and-sweep works in two passes: First we mark all non-garbage blocks by doing a depth-first search starting with each pointer from outside:

void mark(Address b) { mark block b; for (each pointer p in block b) { if (the block pointed to by p is not marked) mark(p); } }

The second pass sweeps through all blocks and returns the unmarked ones to the free list. The sweep pass usually also does compaction, as described above. There are two problems with mark-and-sweep. First, the amount of work in the mark pass is proportional to the amount of non-garbage. Thus if memory is nearly full, it will do a lot of work with very little payoff. Second, the mark phase does a lot of jumping around in memory, which is bad for virtual memory systems, as we will soon see. The third approach to garbage collection is called generational collection. Memory is divided into spaces. When a space is chosen for garbage collection, all subsequent references to objects in that space cause the object to be copied to a new space. After a while, the old space either becomes empty and can be returned to the free list all at once, or at least it becomes so sparse that a mark-and-sweep garbage collection on it will be cheap. As an empirical fact, objects tend to be either short-lived or long-lived. In other words, an object that has survived for a while is likely to live a lot longer. By carefully choosing where to move objects when they are referenced, we can arrange to have some spaces filled only with long-lived objects, which are very unlikely to become garbage. We garbage-collect these spaces seldom if ever. Swapping When all else fails, allocate simply fails. In the case of an application program, it may be adequate to simply print an error message and exit. An OS must be able recover more gracefully. We motivated memory management by the desire to have many processes in memory at once. In a batch system, if the OS cannot allocate memory to start a new job, it can recover by simply delaying starting the job. If there is a queue of jobs waiting to be created, the OS might want to go down the list, looking for a smaller job that can be created right away. This approach maximizes utilization of memory, but can starve large

jobs. The situation is analogous to short-term CPU scheduling, in which SJF gives optimal CPU utilization but can starve long bursts. The same trick works here: aging. As a job waits longer and longer, increase its priority, until its priority is so high that the OS refuses to skip over it looking for a more recently arrived but smaller job. An alternative way of avoiding starvation is to use a memory-allocation scheme with fixed partitions (holes are not split or combined). Assuming no job is bigger than the biggest partition, there will be no starvation, provided that each time a partition is freed, we start the first job in line that is smaller than that partition. However, we have another choice analogous to the difference between first-fit and best fit. Of course we want to use the best hole for each job (the smallest free partition that is at least as big as the job), but suppose the next job in line is small and all the small partitions are currently in use. We might want to delay starting that job and look through the arrival queue for a job that better uses the partitions currently available. This policy re-introduces the possibility of starvation, which we can combat by aging, as above. If a disk is available, we can also swap blocked jobs out to disk. When a job finishes, we first swap back jobs from disk before allowing new jobs to start. When a job is blocked (either because it wants to do I/O or because our short-term scheduling algorithm says to switch to another job), we have a choice of leaving it in memory or swapping it out. One way of looking at this scheme is that it increases the multiprogramming level (the number of jobs in memory) at the cost of making it (much) more expensive to switch jobs. A variant of the MLFQ (multi-level feedback queues) CPU scheduling algorithm is particularly attractive for this situation. The queues are numbered from 0 up to some maximum. When a job becomes ready, it enters queue zero. The CPU scheduler always runs a job from the lowest-numbered non-empty queue (i.e., the priority is the negative of the queue number). It runs a job from queue i for a maximum of i quanta. If the job does not block or complete within that time limit, it is added to the next higher queue. This algorithm behaves like RR with short quanta in that short bursts get high priority, but does not incur the overhead of frequent swaps between jobs with long bursts. The number of swaps is limited to the logarithm of the burst size.

Process

Once a process is started, it is either runnable or blocked. It can become blocked by doing something that explicitly blocks itself (such as wait()) or by doing something that implicitly blocks it (such as a read() request). In some systems, it is also possible for one process to block another (e.g., Thread.suspend() in Java A runnable process is either ready or running. There can only be as many running processes as there are CPUs. One of the responsibilities of the operating system, called short-term scheduling is to switch processes between ready and running state. Two other possible states are new and terminated. In a batch system, a newly submitted job might be left in new state until the operating system decides there are enough available resources to run the job without overloading the system. The decision of when to move a job from new to ready is called long-term scheduling. A process may stay in terminated state after finishing so that the OS can clean up after it (print out its output, etc.) Many systems also allow one process to inquire about the state of another process, or to wait for another process to complete. For example, in Unix, the wait() command blocks the current process until at least one of its children has terminated. In Java, the method Thread.join() blocks the caller until the indicated thread has terminated (returned from its run method). To implement these functions, the OS has to keep a copy of the terminated process around. In Unix, such a process is called a zombie. Some systems require every process to have a parent. What happens when a the parent dies before the child? One possibility is cascading termination. Unix uses a different model. An orphan process is adopted by a special process called init that is created at system startup and only goes away at system shutdown.

Scheduling Algorithms
Throughput. The amount of useful work accomplished per unit time. This depends, of course, on what constitutes useful work. One common measure of throughput is jobs/minute (or second, or hour, depending on the kinds of job). Utilization. For each device, the utilization of a device is the fraction of time the device is busy. A good scheduling algorithm keeps all the devices (CPU's, disk drives, etc.) busy most of the time. Both of these measures depend not only on the scheduling algorithm, but also on the offered load. If load is very light--jobs arrive only infrequently--both throughput and utilization will be low. However, with a good scheduling algorithm, throughput should increase linearly with load until the available hardware is saturated and throughput levels off. Turnaround. The length of time between when the job arrives in the system and when it finally finishes. Response Time. The length of time between when the job arrives in the system and when it starts to produce output. For interactive jobs, response time might be more important than turnaround. Waiting Time. The amount of time the job is ready (runnable but not running). This is a better measure of scheduling quality than turnaround, since the scheduler has no control of the amount of time the process spends computing or blocked waiting for I/O. Penalty Ratio. Elapsed time divided by the sum of the CPU and I/O demands of the the job. This is a still better measure of how well the scheduler is doing. It measures how many times worse the turnaround is than it would be in an ideal system. If the job never had to wait for another job, could allocate each I/O device as soon as it wants it, and experienced no overhead for other operating system functions, it would have a penalty ratio of 1.0. If it takes twice as long to complete as it would in the perfect system, it has a penalty ratio of 2.0.

First-Come-First-Served
The simplest possible scheduling discipline is called First-come, first-served (FCFS). The ready list is a simple queue (first-in/first-out). The scheduler simply runs the first job on the queue until it blocks, then it runs the new first job, and so on. When a job becomes ready, it is simply added to the end of the queue. Here's an example, which we will use to illustrate all the scheduling disciplines. Burst A B C Arrival Time 0 1 3 Burst Length 3 5 2

E 12 5 (All times are in milliseconds). The following Gantt chart shows the schedule that results from FCFS scheduling.

The main advantage of FCFS is that it is easy to write and understand, but it has some severe problems. If one process gets into an infinite loop, it will run forever and shut out all the others. Even if we assume that processes don't have infinite loops (or take special precautions to catch such processes), FCFS tends to excessively favor long bursts. Let's compute the waiting time and penalty ratios for these jobs. Start Time 0 3 8 10 15 Finish Time 3 8 10 15 20 Waiting Time 0 2 5 1 3 Penalty Ratio 1.0 1.4 3.5 1.2 1.6

Burst A B C D E

Average 2.2 1.74 As you can see, the shortest burst (C) has the worst penalty ratio. The situation can be much worse if a short burst arrives after a very long one. For example, suppose a burst of length 100 arrives at time 0 and a burst of length 1 arrives immediately after it, at time 1. The first burst doesn't have to wait at all, so its penalty ratio is 1.0 (perfect), but the second burst waits 99 milliseconds, for a penalty ratio of 100. Favoring long bursts means favoring CPU-bound processes (which have very long CPU bursts between I/O operations). In general, we would like to favor I/O-bound processes, since if we give the CPU to an I/O-bound process, it will quickly finish its burst, start doing some I/O, and get out of the ready list. Consider what happens if we have one CPU-bound process and several I/O-bound processes. Suppose we start out on the right foot and run the I/O-bound processes first. They will all quickly finish their bursts and go start their I/O operations, leaving us to run the CPU-bound job. After a while, they will finish their I/O and queue up behind the CPU-bound job, leaving all the I/O devices idle. When the CPU-bound job finishes its burst, it will start an I/O operation, allowing us to run the other jobs. As before, they will quickly finish their bursts and start to do I/O. Now we have the CPU sitting idle, while all the processes are doing I/O. Since the CPU hog started its I/O first, it will likely finish first, grabbing the CPU and making all the other processes wait. The system will continue this way, alternating between periods when the CPU is busy and all the I/O devices

are idle with periods when the CPU is idle and all the processes are doing I/O. We have destroyed one of the main motivations for having processes in the first place: to allow overlap between computation with I/O. This phenomenon is called the convoy effect. In summary, although FCFS is simple, it performs poorly in terms of global performance measures, such as CPU utilization and throughput. It also gives lousy response to interactive jobs (which tend to be I/O bound). The one good thing about FCFS is that there is no starvation: Every burst does get served, if it waits long enough.

Shortest Job First


A much better policy is called shortest-job-first (SJF). Whenever the CPU has to choose a burst to run, it chooses the shortest one. (The algorithm really should be called shortest burst first, but the name SJF is traditional). This policy certainly gets around all the problems with FCFS mentioned above. In fact, we can prove the SJF is optimal with respect to average waiting time. That is, any other policy whatsoever will have worse average waiting time. By decreasing average waiting time, we also improve processor utilization and throughput. Here's the proof that SJF is optimal. Suppose we have a set of bursts ready to run and we run them in some order other than SJF. Then there must be some burst that is run before shorter burst, say b1 is run before b2, but b1 > b2. If we reversed the order, we would increase the waiting time of b1 by b2, but decrease the waiting time of b2 by b1. Since b1 > b2, we have a net decrease in total, and hence average, waiting time. Continuing in this manner to move shorter bursts ahead of longer ones, we eventually end up with the bursts sorted in increasing order of size (think of this as a bubble sort!). Here's our previous example with SJF scheduling Start Time 0 5 3 10 15 Finish Time 3 10 5 15 20 Waiting Time 0 4 0 1 3 1.6 Penalty Ratio 1.0 1.8 1.0 1.2 1.6 1.32

Burst A B C D E Average Here's the Gantt chart:

As described, SJF is a non-preemptive policy. There is also a preemptive version of the SJF, which is sometimes called shortest-remaining-time-first (SRTF). Whenever a new job enters the ready queue, the algorithm reconsiders which job to run. If the new arrival has a burst shorter than the remaining portion of the current burst, the scheduler moves the current job back to the ready queue (to the appropriate position considering the remaining time in its burst) and runs the new arrival instead. With SJF or SRTF, starvation is possible. A very long burst may never get run, because shorter bursts keep arriving in the ready queue. We will return to this problem later. There's only one problem with SJF (or SRTF): We don't know how long a burst is going to be until we run it! Luckily, we can make a pretty good guess. Processes tend to be creatures of habit, so if one burst of a process is long, there's a good chance the next burst will be long as well. Thus we might guess that each burst will be the same length as the previous burst of the same process. However, that strategy won't work so well if a process has an occasional oddball burst that unusually long or short burst. Not only will we get that burst wrong, we will guess wrong on the next burst, which is more typical for the process. A better idea is to make each guess the average of the length of the immediately preceding burst and the guess we used before that burst: guess = (guess + previousBurst)/2. This strategy takes into account the entire past history of a process in guessing the next burst length, but it quickly adapts to changes in the behavior of the process, since the weight of each burst in computing the guess drops off exponentially with the time since that burst. If we call the most recent burst length b1, the one before that b2, etc., then the next guess is b1/2 + b2/4 + b4/8 + b8/16 + ....

Round - Robin
Another scheme for preventing long bursts from getting too much priority is a preemptive strategy called round-robin (RR). RR keeps all the bursts in a queue and runs the first one, like FCFS. But after a length of time q (called a quantum), if the current burst hasn't completed, it is moved to the tail of the queue and the next burst is started. Here are Gantt charts of our example with round-robin and quantum sizes of 4 and 1.

With q = 4, we get an average waiting time of 3.2 and an average penalty ratio of 1.88 (work it out yourself!). With q = 1, the averages increase to 3.6 and 1.98, respectively, but the variation in penalty ratio descreases. With q = 4 the penalty ratios range from 1.0 to 3.0, whereas with q = 1, the range is only 1.6 to 2.5. The limit, as q approaches zero, is called processor sharing (PS). PS causes the CPU to be shared equally among all the ready processes. In the steady state of PS, when no bursts enter or leave the ready list, each burst sees a penalty ratio of exactly n, the length of the ready queue. In this particular example, burst A arrives at time 0 and for one millisecond, it has the CPU to itself, so when B arrives at time 1, A has used up 1 ms of its demand and has 2 ms of CPU demand remaining. From time 1 to 3, A and B share the CPU equally. Thus each of them gets 1 ms CPU time, leaving A with 1 ms remaining and B with 4 ms remaining. After C arrives at time 3, there are three bursts sharing the CPU, so it takes 3 ms -- until time 6 -- for A to finish. Continuing in a similar manner, you will find that for this example, PS gives exactly the same results as RR with q = 1. Of course PS is only of theoretical interest. There is a substantial overhead in switching from one process to another. If the quantum is too small, the CPU will spend most its time switching between processes and practically none of it actually running them!

Priority Scheduling
There are a whole family of scheduling algorithms that use priorities. The basic idea is always to run the highest priority burst. Priority algorithms can be preemptive or nonpreemptive (if a burst arrives that has higher priority than the currently running burst, does do we switch to it immediately, or do we wait until the current burst finishes?). Priorities can be assigned externally to processes based on their importance. They can also be assigned (and changed) dynamically. For example, priorities can be used to prevent starvation: If we raise the priority of a burst the longer it has been in the ready queue, eventually it will have the highest priority of all ready burst and be guaranteed a chance to finish. One interesting use of priority is sometimes called multi-level feedback queues (MLFQ). We maintain a sequence of FIFO queues, numbered starting at zero. New bursts are added to the tail of queue 0. We always run the burst at the head of the lowest numbered non-empty queue. If

it doesn't complete in complete within a specified time limit, it is moved to the tail of the next higher queue. Each queue has its own time limit: one unit in queue 0, two units in queue 1, four units in queue 2, eight units in queue 3, etc. This scheme combines many of the best features of the other algorithms: It favors short bursts, since they will be completed while they are still in low-numbered (high priority) queues. Long bursts, on the other hand, will be run with comparatively few expensive process switches. This idea can be generalized. Each queue can have its own scheduling discipline, and you can use any criterion you like to move bursts from queue to queue. There's no end to the number of algorithms you can dream up.

Deadlock
The formal definition of deadlock:
A set of processes is deadlocked if each process in the set is waiting for an event that only a process in the set can cause. We can show deadlock graphically by building the waits-for graph. Draw each process as a little circle, and draw an arrow from P to Q if P is waiting for Q. The picture is called a graph, the little circles are called nodes, and the arrows connecting them are called arcs. We can find out whether there is a deadlock as follows:

for (;;) { find a node n with no arcs coming out of it; if (no such node can be found) break; erase n and all arcs coming into it; } if (any nodes are left) there is a deadlock; This algorithm simulates a best-case scenario: Every runnable process runs and causes all events that are expected from it, and no process waits for any new events. A node with no outgoing arcs represents a process that isn't waiting for anything, so is runnable. It causes all events other processes are waiting for (if any), thereby erasing all incoming arcs. Then, since it will never wait for anything, it cannot be part of a deadlock, and we can erase it. Any processes that are left at the end of the algorithm are deadlocked, and will wait forever. The graph that's left must contain a cycle (a path starting and ending at the same node and following the arcs). It may also contain processes that are not part of the cycle but are waiting for processes in the cycle, or for processes waiting for them, etc. The algorithm will

never erase any of the nodes in a cycle, since each one will always have an outgoing arc pointing to the next node in the cycle. The simplest cycle is an arc from a node to itself. This represents a process that is waiting for itself, and usually represents a simple programming bug:

Semaphore s = 0; ... s.down(); s.up(); If no other process can do s.up(), this process is deadlocked with itself. Usually, processes block waiting for (serially reusable) resources. The events they are waiting for are release of resources. In this case, we can put some more detail into the graph. Add little boxes representing resources. Draw an arc from a process to a resource if the process is waiting for the resource, and an arc from the resource to the process if the process holds the resource. The same algorithm as before will tell whether there is a deadlock. As before, deadlock is associated with cycles: If there is no cycle in the original graph, there is no deadlock, and the algorithm will erase everything. If there is a cycle, the algorithm will never erase any part of it, and the final graph will contain only cycles and nodes that have paths from them to cycles. Deadlock Recovery Once you've discovered that there is a deadlock, what do you do about it? One thing to do is simply re-boot. A less drastic approach is to yank back a resource from a process to break a cycle. As we saw, if there are no cycles, there is no deadlock. If the resource is not preemptable, snatching it back from a process may do irreparable harm to the process. It may be necessary to kill the process, under the principle that at least that's better than crashing the whole system. Sometimes, we can do better. For example, if we checkpoint a process from time to time, we can roll it back to the latest checkpoint, hopefully to a time before it grabbed the resource in question. Database systems use checkpoints, as well as a a technique called logging, allowing them to run processes backwards, undoing everything they have done. It works like this: Each time the process performs an action, it writes a log record containing enough information to undo the action. For example, if the action is to assign a

value to a variable, the log record contains the previous value of the record. When a database discovers a deadlock, it picks a victim and rolls it back. Rolling back processes involved in deadlocks can lead to a form of starvation, if we always choose the same victim. We can avoid this problem by always choosing the youngest process in a cycle. After being rolled back enough times, a process will grow old enough that it never gets chosen as the victim--at worst by the time it is the oldest process in the system. If deadlock recovery involves killing a process altogether and restarting it, it is important to mark the starting time of the reincarnated process as being that of its original version, so that it will look older that new processes started since then. When should you check for deadlock? There is no one best answer to this question; it depends on the situation. The most eager approach is to check whenever we do something that might create a deadlock. Since a process cannot create a deadlock when releasing resources, we only have to check on allocation requests. If the OS always grants requests as soon as possible, a successful request also cannot create a deadlock. Thus the we only have to check for a deadlock when a process becomes blocked because it made a request that cannot be immediately granted. However, even that may be too frequent. As we saw, the deadlock-detection algorithm can be quite expensive if there are a lot of processes and resources, and if deadlock is rare, we can waste a lot of time checking for deadlock every time a request has to be blocked. What's the cost of delaying detection of deadlock? One possible cost is poor CPU utilization. In an extreme case, if all processes are involved in a deadlock, the CPU will be completely idle. Even if there are some processes that are not deadlocked, they may all be blocked for other reasons (e.g. waiting for I/O). Thus if CPU utilization drops, that might be a sign that it's time to check for deadlock. Besides, if the CPU isn't being used for other things, you might as well use it to check for deadlock! On the other hand, there might be a deadlock, but enough non-deadlocked processes to keep the system busy. Things look fine from the point of view of the OS, but from the selfish point of view of the deadlocked processes, things are definitely not fine. If the processes may represent interactive users, who can't understand why they are getting no response. Worse still, they may represent time-critical processes (missile defense, factory control, hospital intensive care monitoring, etc.) where something disastrous can happen if the deadlock is not detected and corrected quickly. Thus another reason to check for

deadlock is that a process has been blocked on a resource request too long. The definition of too long can vary widely from process to process. It depends both on how long the process can reasonably expect to wait for the request, and how urgent the response is. If an overnight run deadlocks at 11pm and nobody is going to look at its output until 9am the next day, it doesn't matter whether the deadlock is detected at 11:01pm or 8:59am. If all the processes in a system are sufficiently similar, it may be adequate simply to check for deadlock at periodic intervals (e.g., one every 5 minutes in a batch system; once every millisecond in a real-time control system). Deadlock Prevention There are four necessary condition for deadlock. 1. Mutual Exclusion. Resources are not sharable. 2. Non-preemption. Once a resource is given to a process, it cannot be revoked until the process voluntarily gives it up. 3. Hold/Wait. It is possible for a process that is holding resources to request more. 4. Cycles. It is possible for there to be a cyclic pattern of requests. It is important to understand that all four conditions are necessary for deadlock to occur. Thus we can prevent deadlock by removing any one of them. There's not much hope of getting rid of condition (1)--some resources are inherently nonsharable--but attacking (2) can be thought of as a weak form of attack on (1). By borrowing back a resource when another process needs to use it, we can make it appear that the two processes are sharing it. Unfortunately, not all resources can be preempted at an acceptable cost. We can attack condition (3) either by forcing a process to allocate all the resources it will ever need at startup time, or by making it release all of its resources before allocating any more. The first approach fails if a process needs to do some computing before it knows what resources it needs, and even it is practical, it may be very inefficient, since a process that grabs resources long before it really needs them may prevent other processes from proceeding. The second approach (making a process release resources before allocating more) is in effect a form of preemption and may be impractical for the same reason preemption is impractical. Deadlock Avoidance

The final approach we will look at is called deadlock avoidance. In this approach, the OS may delay granting a resource request, even when the resources are available, because doing so will put the system in an unsafe state where deadlock may occur later. The bestknown deadlock avoidance algorithm is called the Banker's Algorithm, invented by the famous E. W. Dijkstra. This algorithm can be thought of as yet another relaxation of the the no-hold-wait restriction. Processes do not have to allocate all their resources at the start, but they have to declare an upper bound on the amount of resources they will need. In effect, each process gets a line of credit that is can drawn on when it needs it (hence the name of the algorithm). When the OS gets a request, it mentally grants the request, meaning that it updates its data structures to indicate it has granted the request, but does not immediately let the requesting process proceed. First it checks to see whether the resulting state is safe. If not, it undoes the allocation and keeps the requester waiting. To check whether the state is safe, it assumes the worst case: that all running processes immediately request all the remaining resources that their credit lines allow. It then checks for deadlock using the algorithm above. If deadlock occurs in this situation, the state is unsafe, and the resource allocation request that lead to it must be delayed. An Example A system has three classes of resource: A, B, and C. Initially, there are 8 units of A and 7 units each of resources B and C. In other words, the array U above has the value { 8, 7, 7 }. There are five processes that have declared their maximum demands, and have been allocated some resources as follows: Process 1 2 3 4 5 Maximum Demand CurrentAllocation A 4 0 4 1 7 B 3 4 2 6 3 C 6 4 2 3 2 A 1 0 1 0 2 B 1 2 1 0 1 C 0 1 1 2 0

(The table CurrentAllocation is the array curAlloc in the Java program.) To run the Bankers Algorithm, we need to know the amount of remaining credit available for each process (credLine[p][r]), and the amount resources left in the bank after the

allocations (available[r]). The credit line for a process and resource type is computed by subtracting the current allocation for that process and resource from the corresponding maximum demand. Remaining Credit A 3 0 3 1 5 B 2 2 1 6 2 C 6 3 1 1 2

Process 1 2 3 4 5

The value available[r] is calculated by subtracting from U[r] the sum of the rth column of curAlloc: available = { 4, 2, 3 }. If process 4 were to request two units of resource C, the request would be rejected as an error because process 4 initially declared that it would never need more than 3 units of C and it has already been granted 2. A request of five units of resource A by process 5 would be delayed, even though it falls within his credit limit, because 4 of the original 8 units of resource A have already been allocated, leaving only 4 units remaining. Suppose process 1 were to request 1 unit each of resources B and C. To see whether this request is safe, we grant the request by subtracting it from process 1's remaining credit and adding it to his current allocation, yielding Current Allocation Remaining Credit A 1 0 1 0 2 the B 2 2 1 0 1 C 1 1 1 2 0 A 3 0 3 1 5 from the B 1 2 1 6 2 C 5 3 1 1 2 amount available, yeilding

Process 1 2 3 4 5

We also have to subtract available = { 4, 1, 2 }.

allocation

To see whether the resulting state is safe, we treat the Remaining Credit array as a Request array and check for deadlock. We note that the amounts in available are not enough to satisfy the request of process 1 because it wants 5 more units of C and we have only 2. Similarly, we cannot satisfy 2, 4, or 5 because we have only one unit remaining of B and they all want more than that. However, we do have enough to grant 3's request. Therefore, we assume that we will give process 3 its request, and it will finish and return those resources, along with the remaining resources previously allocated to it, and we will increase our available holdings to { 5, 2, 3 }. Now we can satisfy the request of either 2 or 5. Suppose we choose 2 (it doesn't matter which process we choose first). After 2 finishes we will have { 5, 4, 4 } and after 5 finishes, our available will increase to { 7, 5, 4 }. However, at this point, we do not have enough to satisfy the request of either of the remaining processes 1 or 4, so we conclude that the system is deadlocked, so the original request was unsafe. If the original request (1 unit each of B and C) came from process 2 rather than 1, however, the state would be found to be safe (try it yourself!) and so it would be granted immediately.

You might also like