Lecture 15 – Implementing processes and messages (Digital Systems)

From Spivey's Corner
Jump to: navigation, search

Context switching as implemented in the previous lecture gives us a platform on which we can implement a process scheduler with no further magic: the scheduler is simply a program that receives calls to send, receive and other, similar, functions and decides the order in which processes should run. In this lecture, we look at the data structures and the algorithms that are used to make these decisions.

Process table[edit]

Phōs keeps information about each process in a table (source).

#define NPROCS 16

static struct proc {
    int p_pid;                 /* Process id */
    char p_name[16];           /* Name for debugging */
    unsigned p_state;          /* SENDING, RECEIVING, etc. */
    unsigned *p_sp;            /* Saved stack pointer */
    void *p_stack;             /* Stack area */
    unsigned p_stksize;        /* Stack size (bytes) */
    int p_priority;            /* Priority: 0 is highest */
    
    struct proc *p_waiting;    /* Processes waiting to send */
    int p_pending;             /* Whether HARDWARE message pending */
    int p_accept;              /* Processes who may send: ANY or pid */
    message *p_message;        /* Pointer to message buffer */

    struct proc *p_next;       /* Next process in ready or send queue */
} ptable[NPROCS];

/* Possible p_state values */
#define DEAD 0
#define READY 1
#define SENDING 2
#define RECEIVING 3
#define BOTH 4
#define IDLING 5

The C syntax for declarations is a bit mad, but the gist of this is that ptable is an array of NPROCS = 16 records, each containing certain fixed fields. Two of the fields, p_waiting and p_next are pointers to other records in the process table, and by means of them we can construct linked lists of process records, without having to use malloc or its equivalent to allocate storage dynamically: that's good, because dynamic storage allocation is difficult to do well in limited memory, and potentially too slow to incorporate in the heart of our operating system.

Each process has

  • an integer process id (actually equal to the index of its slot in the table).
  • a string p_name, copied from the string given when the process was created. This is used in a function that dumps the state of all processes (a counterpart to the ps command of unix). Also the name of the current process could be printed in case of a panic, just before the Seven Stars of Death appear.
  • an integer p_state that has one of the values listed later.
    • DEAD indicates a process that has exited. It is also the value present in slots of the table that have never been used.
    • READY indicates a process that is ready to run. The process is either the current process, or the ready queue waiting to become current.
    • SENDING indicates that the process is waiting to send to another; the process will appear in the sending queue of the destination.
    • RECEIVING indicates that the process is waiting to receive a message; the p_accept field will specify who may send to it.
    • BOTH indicates a call to sendrec, combining the functions of send and receive.
    • IDLING indicates the idle process, which runs when no other process is ready.
  • three pieces of information about the stack of the process, which contains all its state: the current stack pointer p_sp, the base of the stack area p_stack, and its size p_size. The base and size of the stack are used in the process dump, which attempts to find out how much of the allocated space has actually been used. The stack pointer is of more vital importance, because it is needed in order to resume the process after a system call.
  • a small integer priority for the process. Possible values are from 0 to 3, with 0 (the most urgent) reserved for processes that are connected to interrupts, 1 and 2 (with symbolic names P_HIGH and P_LOW allowed for normal processes, and 3 reserved for the idle process, which runs only when no other process is ready.
  • some fields that are used to save information about processes that are waiting to send or receive.
    • if p_waiting is not null, it points to a list of processes waiting to send to this one.
    • p_pending is non-zero if an interrupt message is waiting to be delivered to this process.
    • if the process is in state RECEIVING, then p_accept determines which processes may send to it: either the id of a specific process, or the special value ANY.
    • if the process is in state SENDING or RECEIVING (or BOTH), then p_message is a pointer to the message buffer passed in the call to send or receive (or sendrec@). This buffer may be in the stack space of the process.
  • a pointer p_next that can be used to link the process into a queue, either waiting to run, or waiting to send a message.

Ready queue[edit]

The ready queue

There are three queue structures, corresponding to priorities 0, 1, and 2. There's no need for a queue structure for priority level 3, because that level always contains exactly one process, the idle process, which is always ready to run (source).

Each queue structure has two pointers: one to the head of the queue and another to the tail. If the queue is empty, then both of these pointers are null, and it the queue contains one process, then both head and tail point to it. In each case, it's quick to add a process to the end of the queue, or to remove a process from the front.

In order to find the ready process with the highest priority, we need to look at each queue structure in turn, but that doesn't take long if there are only a few different priority levels. With a more elaborate system of priorities, a more carefully designed data structure might be needed; but the number of processes might have to grow quite large before clever data structures gained any advantage, even if the running time of operations on them was asymptotically better.

All the processes that are on a ready queue are in the READY state.

Processes queueing to send

There are other queues for processes that are waiting to send a message and are in the SENDING state. In fact, each process has two pointers in its record: one called p_next for linking it into a queue, and another called p_waiting that points to the head of a queue of processes waiting to send to it. Because these queues are usually short, we don't bother with a pointer to the tail of the queue, but just find it when we want by chaining along the list.

In the picture, there is a READY process that is busy doing something, and three processes are waiting to send it a message when it has finished. One of these processes itself has a third process waiting to send to it: for that to happen, the first process must accept the message from the second process, allowing the second process to proceed, and the second process must then call receive to allow the third process to deliver its message.

In the diagram, there is also a process that is in RECEIVING state, waiting to receive a message. It is not linked to any sender, but potential senders know its process id, and can get in touch when they are ready to send. In addition, there is a process that is in RECEIVING state but nevertheless has another process waiting to send to it. That process must have asked to receive a message from a specific sender, and the process waiting is not the right one. The receiving process will have to hear first from the sender it has selected, and then call receive again to accept a message from the waiting process.

As the diagram shows, any process that is not DEAD (or the idle process), and is not currently waiting to receive from ANY, can have a queue of other processes waiting to send. Each waiting process is in SENDING state (so is not on the ready queue), and can be on the queue of only one process – the one it is hoping to send to. Therefore, we can manage with only one p_next link for each process, and we don't have to allocate storage dynamically to cope with any eventuality that might arise.

Not shown here[edit]

There are a couple of possibilities not shown in these diagrams: they add only details to the story, so do not need to be included in a first telling.

As well as send and receive, there is an operation sendrec(dst, msg) that combines both, equivalent to send(dst, msg) followed by receive(dst, msg). This single operation neatly captures the idiom of sending a request to a server and waiting for a reply. Besides the brevity that it offers, it is also a bit more efficient. Supporting sendrec means adding another process state, called BOTH for historical reasons. A process in state BOTH is waiting to send to some destination, and will be on the destination's queue. When it has succeeded in sending, its state becomes RECEIVING, just as a process in state SENDING will enter state READY at the same moment.

As well as receiving a message from a process that is waiting in its queue, a driver process that calls receive can get an INTERRUPT message from the fictitious process HARDWARE. Such messages are not reflected in the queue of senders but by setting the p_pending flag for the process. If an interrupt is pending, then it jumps the queue and is delivered before any message from a waiting process.

Send and receive[edit]

When a process calls send, there are two possibilities.

  • If the destination process is in state RECEIVING, and it is prepared to accept a message from the sender, then the message can be copied immediately, and both the sender and the receiver are now ready to run.
  • If the destination process is not waiting for a message from the sender, then the sender must be suspended and join the destination's queue (at the end, it being a queue). Since the sender cannot continue, there is a call to choose_proc to find another process to run.

When a process calls receive, three possibilities must be considered.

  • If the process is connected to an interrupt, and an interrupt is pending, then an INTERRUPT message should be delivered, and the process continues in order to service it.
  • If not, and some other process is waiting to send to this one, then the message can be delivered immediately. If the process is hoping to hear from a specific sender, we must search the queue to see if that process is waiting; otherwise, the first process on the queue is taken. After the message is copier, both sender and receiver are ready to run. The need to search the queue here means that we must accept that some operations on sender queues will take time proportional to the length of the queue, and partially justifies traversing the queue also to add a process to the end.
  • If there is not acceptable process waiting, then the caller must be suspended in state RECEVIING.

Both send (implemented by mini_send) and receive (implemented by mini_receive) are system calls that are handled by the operating system after it as been entered by the system call mechanism. System calls and all interrupts have the same priority in Phōs, so we can be sure that the operations on queues needed to implement the system calls will not be interrupted: this is much simpler than generally allowing interrupts to system calls and disabling interrupts when necessary, but it means that interrupts will occasionally be disabled for a relatively long time.

System calls arrive in the operating system via the interrupt mechanism at the function system_call (source), which decodes the arguments of the call by delving into the exception frame.

Delving into more detail, we can look at a function mini_send (source) that implements the send operation and is invoked from the system call interface.

static void mini_send(int dst, message *msg) {
    int src = procnum(current);
    struct proc *pdst = &ptable[dst];

The function gets the destination in the form of a process number: the process table is entirely private to the scheduler, and pointers to process records are never passed to the client program. The implementation of send begins by finding the process id snd of the current process, which is sending the message, and also for brevity a pointer pdst to the process record for the destination.

What happens next depends on whether the destination process is waiting to receive a message from src.

    if (pdst->p_state == RECEIVING
        && (pdst->p_accept == ANY || pdst->p_accept == src)) {
        *(pdst->p_message) = *msg;
        pdst->p_message->m_sender = src;
        make_ready(pdst);

If so, then we can immediately copy across the message with a structure assignment, and fill in src as the m_sender field of the message. This is a matter of convenience for us, but in Minix it is part of the security system, ensuring against forgery. Finally we call make_ready() to change the state of dst to READY and add it to a queue according to its priority.

Otherwise, the sender must wait until the receiver can accept the message. Its status becomes SENDING, and the p_message field of the process record points to the place where the message is found. The p_next pointer of the process is null, because it will join the end of the destination's queue.

    } else {
        current->p_state = SENDING;
        current->p_message = msg;
        current->p_next = NULL;

Now we must add the process to the queue. It's a special case if the queue is currently empty; otherwise we use a loop to find the last process in the queue, and overwrite its p_next field to point to the current process.

        if (pdst->p_waiting == NULL)
            pdst->p_waiting = current;
        else {
            struct proc *r = pdst->p_waiting;
            while (r->p_next != NULL)
                r = r->p_next;
            r->p_next = current;
        }

When the current process has been added to the queue, we must choose a different process to run, and we do this by calling choose_proc(); this is guaranteed to reset current to some runnable process, even if it is only the idle process.

        choose_proc();
    }
}

Implementing receive() and other operating system functions requires similar programming with the linked lists that represent the process queues.

Lecture 16

A register sp that holds the address of the most recent occupied word of the subroutine stack. On ARM, as on most recent processors, the subroutine stack grows downwards, so that the sp holds the lowest address of any occupied work on the stack.