CSC369 A2: Preemptive Threads

Hello, if you have any need, please feel free to consult us, this is my wechat: wx91due

A2: Preemptive Threads

• Due Feb 23 by 11:59p.m.
• Points 90
• Available after Feb 3 at 12a.m.

Concurrency - A Preemptive User-Level Threads Package

In the previous assignment, you implemented a cooperative, user-level thread package in which thread_yield causes control to be passed from one thread to the next one. This assignment has five goals:
1. Implement preemptive threading, in which simulated "timer interrupts" cause the system to switch from one thread to another.
2. Implement the thread_sleep and thread_wakeup scheduling functions. These functions will enable you to implement blocking mutual exclusion and synchronization.
3. Use the thread_sleep and thread_wakeup functions to implement thread_wait , which will block a thread until the target thread has finished executing.
4. Implement blocking locks for mutual exclusion and condition variables for synchronization.
5. Detect deadlock by checking for circular wait conditions.

You will be working with your queue.c and thread.c code that you implemented in Tutorial 1 and Assignment 1. Please be aware that full completion of assignment 1 is required before starting assignment 2. If you did not receive full marks for assignment 1, please immediately see the course instructor or a TA to help you complete your assignment 1.

Background: Timer Signals

User-level code cannot use hardware timer interrupts directly. Instead, POSIX operating systemsprovide a software mechanism called signals that can be used to simulate "interrupts" at the userlevel. These signals interrupt your program and give you a chance to handle that interrupt. For example, when you hit Ctrl-C to kill a program, that causes the OS to send the SIGINT signal toyour program. Most programs don't handle this signal, so by default, the OS kills the process. However, if you wanted to save the state of your program before it was killed (e.g., a text editor couldsave any unsaved files), you can register a handler with the OS for SIGINT. Then, when the user hitsCtrl-C, the OS calls your handler; in your handler, you could write out the state of your program andthen exit.

More generally, signals are a form of asynchronous, inter-process communication mechanism. A signal can be sent from one process to another, or from a process to itself. We will use the latter method to have the process that invokes your user-level scheduler, i.e., your thread library functions, deliver timer signals to itself.

The operating system delivers a signal to a target (recipient) process by interrupting the normal flowof execution of the process. Execution can be interrupted after any instruction. If a process hasregistered a signal handler, that handler function is invoked when the signal is delivered. After thesignal handler finishes executing, the normal flow of execution of the process is resumed. Notice thesimilarities between signals and hardware interrupts.

Because signals can be delivered after any instruction, signal handling is prone to race conditions. For example, if you increment a counter (counter = counter + 1), either during normal execution or inyour signal handler code, the increment operation may not work correctly because it is not atomic.The signal may be delivered between the instructions implementing the increment operation. To avoidthis problem, you should disable signal delivery while the counter is being updated. Note that this is essentially the old OS strategy of disabling interrupts to protect critical sections on a uniprocessor.

Please read a short introduction to signals(http://en.wikipedia.org/wiki/Unix_signal) to understand how they work in more detail. Make sure to read the "Risks" section or else you may not be able to answer some questions below.

Now go over the code in the files interrupt.h and interrupt.c . (This is the same code we provide for Tutorial 5). You do not need to understand all the code, but it will be helpful to know how these functions should be used. We will use the terms "interrupts" and "signals" interchangeably below. 

void interrupt_init(bool verbose):

This function installs a timer signal handler in the calling program using the ssiiggaaccttiioonn (http:// linux.die.net/man/2/sigaction) system call. When a timer signal fires, the function interrupt_handler in the interrupt.c file is invoked. With the verbose flag, a message is printed when the handler function runs.
void interrupt_end(void):

This function uninstalls the timer signal handler.

bool interrupt_set(bool enable):

This function enables timer signals when enable is 1, and disables (or blocks) them when enabled is 0. We call the current enabled or disabled state of the signal the signal state. This function also returns whether the signals were previously enabled or not (i.e., the previous signal state). Notice that the operating system ensures that these two operations (reading previous state, and updating it) are performed atomically when the sigprocmask (http://linux.die.net/man/2/sigprocmask) system call is issued. Your code should use this function to disable signals when running any code that is a critical section (http://en.wikipedia.org/wiki/Critical_section) (i.e., code that accesses data that is shared by multiple threads).

Why does this function return the previous signal state? The reason is that it allows "nesting" calls to this function. The typical usage of this function is as follows:

fn() {
/* disable signals, store the previous signal state in "enabled" */
int enabled = interrupts_set(false);
/* critical section */
interrupts_set(enabled);
}

The first call to interrupt_set disables signals. The second call restores the signal state to its previous state, i.e., the signal state before the first call to interrupt_set , rather than unconditionally enabling signals. This is useful because the caller of the function fn may be expecting signals to remain disabled after the function fn finishes executing. For example:

fn_caller() {
int enabled = interrupts_set(false);
/* begin critical section */
fn();
/* code expects signals are still disabled */
...
/* end critical section */
interrupts_set(enabled);
}

Notice how signal disabling and enabling are performed in "stack" order, so that the signal state remains disabled after fn returns.

The functions interrupt_on and interrupt_off are simple wrappers for the interrupt_set function.

bool interrupt_enabled():

This function returns whether signals are enabled or disabled currently. You can use this function to check (i.e., assert) whether your assumptions about the signal state are correct. Note that this function will always return false is the interrupt handler is not installed.
void interrupt_quiet():
This function turns off printing signal handler messages.

To help you understand how this code works, you should work through Tutorial 5 (https:// q.utoronto.ca/courses/380124/assignments/1438310) .

Setup

You will be doing this assignment within the A2 directory of your MarkUs repository.
  • Log in to MarkUs and go to CSC369. Select A2, and then click the button to add the starter files for this assignment. Some of these will be the same base files as for Assignment 1, with the interrupt.[ch] files and new test programs, and an updated Makefile.
  • Clone your MarkUs repo, or use git pull to update your existing checkout. The files for this assignment will be in the userid/A2 subdirectory.
  • Directly copy your fcfs.c solutions from your A1 directory to your A2 directory. It does not need to be modified for A2.
  • Some changes were made to thread.c and thread.h . Hence, you will need to migrate a portion of your A1 code into these two files:
    • thread.c : Copy over everything you did for A1. Notice that in the A2 section, condition variable ( cv_* ) related definitions have been added.
    • thread.h : Copy over the definition of struct thread .
  • You will need to modify queue.c for task 5. However, for now, you can directly copy it from your A1 directory to your A2 directory.
  • Lastly, add the updated files to your repo ( git add * ), commit locally, and push the changes back upstream to MarkUs.

Task 1: Preemptive Threading

Now you are ready to implement preemptive threading using the timer signals described above. However, before you start writing any code, make sure that you can answer the following questions:
1. What is the name of the signal that you will be using to implement preemptive threading?
2. Which system call is used by the process to deliver signals to itself?
3. How often is this signal delivered?
4. When this signal is delivered, which function in thread.c is invoked? What would this function do when it is invoked in a program that uses the thread library?

5. Is the signal state enabled or disabled when the function in thread.c above is invoked? If the signal is enabled, could it cause problems? If the signal is disabled, what code will enable them? Hint: look for sa_mask in interrupt.c .

6. What does unintr_printf do? Why is it needed? Will you need other similar functions? Reread a short introduction to signals (http://en.wikipedia.org/wiki/Unix_signal) to find out.

Signals can be sent to the process at any time, even when a thread is in the middle of a thread_yield , thread_create , or thread_exit call. It is a very bad idea to allow multiple threads to access shared variables (such as your ready queue) at the same time. You should therefore ensure mutual exclusion (http://en.wikipedia.org/wiki/Mutual_exclusion) , i.e., only one thread can be in a critical section (accessing the shared variables) in your thread library at a time.

A simple way to ensure mutual exclusion is to disable signals when you enter procedures of the thread library and restore the signal state when you leave.

Hint: think carefully about the invariants you want to maintain in your thread functions about when signals are enabled and when they are disabled. Make sure to use the interrupts_enabled function to check your assumptions.

Note that as a result of thread context switches, the thread that disables signals may not be the one enables them. In particular, recall that setcontext restores the register state (https://q.utoronto.ca/ courses/380124/assignments/1438328) saved by getcontext . The signal state is saved when getcontext is called and restored by setcontext (look at the save_interrupt function in the understand_interrupt.c file in Tutorial 5). As a result, if you would like your code to be running with a specific signal state (i.e., disabled or enabled) when
setcontext is called, make sure that getcontext is called with the same signal state. Maintain the right invariants, and you'll have no trouble dealing with context switches.

It will be helpful to go over the manual pages (http://linux.die.net/man/2/setcontext) of the context save and restore calls again.

Implement preemptive threading by adding the necessary initialization, signal disabling and signal enabling code in your thread library in thread..c .

After you implement preemptive threading, you can test your code by running the test/preemptive program.

$ test/preemptive
...
9: thread 31 passes potato at time = 59.927460
9: thread 32 passes potato at time = 59.947266
9: thread 33 passes potato at time = 59.986412
cleaning hot potato
preemptive test done
$

Task 2: Sleep and Wakeup

Having implemented preemptive threading, you will now extend your threading library by adding the thread_sleep and thread_wakeup functions. These functions facilitate thread synchronization and mutual exclusion by allowing threads to suspend execution until a specific event occurs.

In real operating systems, similar mechanisms are used to suspend threads waiting for I/O operations with slow devices (e.g., disks, networks). Your implementation will ensure that threads waiting for conditions such as a mutex becoming available or a network packet arriving can be put to sleep and later awakened efficiently.

Function Specifications

Tid thread_sleep(fifo_queue_t *queue): Suspends the calling thread and schedules another thread to run. The calling thread is placed in the specified wait queue and remains blocked until awakened.

• On success, return the tid of the thread that was switched to, i.e., the thread picked by the scheduler.
• On failure, return one of the following error codes:
◦THREAD_INVALID:
The queue is
NULL.
◦ THREAD_NONE:
No other runnable threads exist. If the calling thread were to sleep in this case, your program would have undefined behaviour, ranging from a hang to a crash. ◦
THREAD_DEADLOCK : Sleeping on this queue would cause a deadlock (to be handled in Task 5).
When implementing thread_sleep , it is helpful to remember that each thread can only be in one queue at a time (ready queue or any one wait queue). Also think about the similarities and differences between this function and thread_yield and thread_exit . Make sure that thread_sleep suspends (blocks) the current thread rather than spinning (running) in a tight loop. This would defeat the purpose of invoking thread_sleep because the thread would still be using the CPU. int thread_wakeup(fifo_queue_t *queue, int all):

Wakes up one or more threads from the specified wait queue, moving them to the ready queue. If all is 0, wake up a single thread in FIFO order. If all is 1, wake up all threads in the wait queue . Any other value of all will result in undefined behaviours.

• On success, return the number of threads that were woken up (can be 0).
• On failure, i.e., the queue is invalid, return 0.
Since both of these functions are supposed to be internal library functions, i.e., not callable by the library user, you are required to crash the program with an assertion error IF interrupt is not disabled when either of these functions are called. In other word, the caller of thread_sleep or thread_wakeup must disable interrupt prior to invocation.

All the thought that you put into ensuring that thread preemption works correctly previously will apply to these functions as well. In particular, these functions access shared data structures (which ones?), so be sure to enforce mutual exclusion.

Implement the
thread__sleep and thread__wakeup functions in your thread library in thread.c .
After you implement the sleep and wakeup functions, you can test your code by running the test/ wakeup program with an argument, e.g.:
$ test/wakeup 0 # all=0
starting wakeup test, all=0
initial thread returns from sleep(NULL)
initial thread returns from sleep(NONE)
wakeup test done
$ test/wakeup 1 # all=1
...
Killing a Sleeping Thread
Recall that in the previous assignment, you implemented thread_kill(tid) , which prevents the target thread (identified by tid ) from running its assigned function any further. When this thread is scheduled to run again for the last time, it is supposed to callthread_exit to terminate its execution.

So far, you only have to handle the case wherethread_kill is invoked on a runnable thread. 

For this assignment, if thread_kill is called on a sleeping thread, the thread must be immediately removed from its wait queue and transitioned to a runnable state. This ensures that the thread can again be scheduled, so that eventually, it would be able to properly exit.

Be warned that tasks 1 and 2 must be completed before starting any of the subsequent tasks!

Task 3: Waiting for Threads to Exit

Now that you have implemented the thread_sleep and thread_wakeup functions for suspending and waking up threads, you can use them to implement blocking synchronization primitives in your threads library. You should start by implementing the thread_wait function, which will block or suspend a thread until a target thread terminates (or exits). Once the target thread exits, the thread that invokes thread_wait should continue operation. As an example, this synchronization mechanism can be used to ensure that a program (using a master thread) exits only after all its worker threads have completed their operations.
You should now remove the placeholder implementation from A1 and start from scratch.

The thread_wait function is summarized below:

int thread_wait(Tid tid, int *exit_code):
This function suspends the calling thread until the target thread, i.e., whose identifier is tid , terminates. A thread terminates when it invokes thread_exit . Upon success, this function returns tid . If exit_code is not NULL, the exit status of the target thread, i.e., the value it passed to thread_exit , will be copied into the memory location pointed to by exit_code
. Upon failure, the calling thread continues running. and returns the following possible constants:
• THREAD_INVALID
◦ Identifier tid is not a feasible thread id, i.e., the value is out of bound.
◦ No thread with the identifier tid could be found.
◦ The identifier tid refers to the calling thread.
◦ The target thread has exited AND it has already been waited on, i.e., it is being reaped by other thread(s).
• THREAD_DEADLOCK and
THREAD_NONE :
◦ The current thread fails to block for one of the above reasons (You may ignore this until task 5)
You will need to associate a wait queue with each thread. When a thread invokes thread_wait , it should sleep on the wait queue of the target thread. When the target thread invokes exit, it should wake up the threads in its wait queue.
There are a couple of technical details that you need to consider:
• More than one caller of thread_wait(tid,...) can succeed for a target thread tid , as long the target thread has not exited. However, once the target thread becomes a zombie and its wait queue is non-empty, all calls to thread_wait(tid,...) should fail until the tid is reused by a subsequently created thread.
• If a thread with id tid exits, i.e., calls thread_exit , before it is waited for, i.e., its wait queue is empty, a subsequent call to thread_wait(tid, &tid_status) must still be able to retrieve the exit status that thread tid passed to thread_exit
. In this case, exactly one caller to thread_wait(tid,...) for that tid can succeed. The thread tid should be destroyed after exactly one other thread calls thread_wait on it.
• If a thread with id tid is killed by another thread via thread_kill, set its exit code to
THREAD_KILLED , then make it runnable again so that it may invoke thread_exit later. Note that you may have already implemented this for A1.
Implement thread__wait in your thread library and update any other relevant functions. After you implement this functionality, you can test your code by running the test/wait, test/wait_many , and test/wait_kill . For example:
$ test/wait_kill
starting wait_kill test
wait_kill test done
Task 4: Mutual Exclusion and Synchronization
The next task is to implement mutual exclusion in your threads library. Recall that these primitives form the basis for managing concurrency, which is a core concern for operating systems, so your library would not really be complete without them. Implement all functions mentioned below in your thread library in thread..c .
Lock Data Type

For mutual exclusion, you will implement blocking (sleeping) locks. Your code should associate a wait  with the lock so that threads that need to acquire the lock can wait in this queue. In addition, you need a way to track the condition variables that are associated with this lock (see next section), so that we can detect premature freeing of the lock.

The API for the lock functions are described below:
struct lock *lock_create(): Create a blocking lock. Initially, the lock should be available.
void lock_destroy(struct lock *lock): Destroy the lock. Be sure to check that the lock is available when it is being destroyed, and also that no condition variables are associated with this lock.
int lock_acquire(struct lock *lock): Acquire the lock. Threads should be suspended until they can acquire the lock, after which this function should return. Return 0 on success. Otherwise, return the error code provided by thread_sleep
(see Task 5 regarding deadlock detection).
void lock_release(struct lock *lock): Release the lock. Be sure to check that the lock had been acquired by the calling thread, before it is released. Wake up at least one thread that is waiting to acquire the lock.
Hint: the lock_acquire , lock_release functions access shared data structures (which ones?), so be sure to enforce mutual exclusion.
After you implement these functions, you can test your code by running the test/lock and test/ crash program.
Condition Variables
For synchronization, you will implement condition variables. Each condition variable should include an associated wait queue, allowing threads to wait until the specified condition is met. In addition, the condition variable must be linked to a lock, so that your code can verify that the lock is held before invoking cv_wait , to prevents incorrect usage by users.

The API for the condition variable functions are described below: struct cv *cv_create(struct lock *lock): Create a condition variable and associate it with lock .

void cv_destroy(struct cv *cv):
Destroy the condition variable. Remember to check that no threads are waiting on it. Additionally, make sure to remove its association with the lock specified during cv_create .
int cv_wait(struct cv *cv):

Suspend the calling thread on the condition variable cv . Be sure to check that the calling thread had acquired lock when this call is made. You will need to release the lock before waiting, and reacquire it before returning from this function. Return 0 on success. Otherwise, return the error code provided by

thread_sleep
(see Task 5 regarding deadlock detection). If cv_wait

returns an error, the associated lock will no longer be held.

void cv_signal(struct cv *cv)

Wake up one thread that is waiting on the condition variable cv .

void cv_broadcast(struct cv *cv):

Wake up all threads that are waiting on the condition variable cv .

Hint: all five functions above access shared data structures (which ones?), so be sure to enforce mutual exclusion.

After you implement these functions, you can test your code by running the test/signal , test/ broadcast , and test/crash program.

Task 5: Deadlock Detection

Deadlock occurs when a set of threads are blocked indefinitely, waiting for resources held by each other. In your threading library, we would like to prevent threads from becoming permanently blocked by gracefully returning an error code ( THREAD_DEADLOCK ) when deadlock would have happened as a result of making an API call, i.e., thread_wait , lock_acquire , and cv_wait .

In this assignment, deadlock detection can be implemented by checking for cyclic dependencies in the wait queues. Specifically, when a thread attempts to sleep on a queue, you should verify whether doing so would create a circular wait condition.

Your first step should be to complete the implementation for these two functions, so that an owner can be associated with each queue object. Note that the two functions below work with void pointers. This is intentional so that you have the freedom to decide how an owner should be represented.

However, we also have an suggested approach for each function.

void * queue_get_owner(fifo_queue_t * queue):

Return the owner thread of a given queue. You are suggested to implement this by returning a pointer to a TCB, i.e., the actual return type should be

struct thread *.

void queue_set_owner(fifo_queue_t * queue, void * owner):

Set the owner thread of a given queue. You are suggested to implement this by passing in a double pointer to a TCB. In other word, you should pass in the address of a pointer to a TCB, e.g., struct thread * owner = ...; queue_set_owner(q, &owner);

You will need to update struct _fifo_queue to complete the above functions.

Next, you need to find locations in your code to insert queue_set_owner , so that each wait queue has an associated owner. It is possible that some wait queues will not always have an owner, for example, a lock only has an owner when it is held by a thread. Otherwise, it has no owner.

Think time: who would be the owner of a condition variable? Does a condition variable always have an owner?

Last, add deadlock detection logic to thread_sleep(queue) , so that it returns THREAD_DEADLOCK if circular waiting is detected. The basic approach is to start from queue , and get its owner using queue_get_owner . If the owner exists, check whether it is blocked inside a queue, and get the owner of that queue, etc. Eventually, you will come across an owner thread that is either runnable, or it is the current thread. In the latter case, a deadlock will occur if we allow it to go to sleep.

Once you completed the above steps, check any functions that call thread_sleep to make sure you propagate the error codes correctly, e.g., so that thread_wait is also able to return THREAD_DEADLOCK . Finally, you can test your code by running the test/deadlock program.

Hints and Advice

Start early, we mean it!

You are encouraged to reuse your own code that you might have developed in the first assignment or in previous courses for common data structures and operations such as queues, sorting, etc. Make sure to document the sources of anything you did not write specifically for this course.

You may not use code that subsumes the heart of this project, e.g., you should not base your solution on wrappers of or code taken from the POSIX thread library. If in doubt, ask.

This assignment does not require you to write a lot of code, but it does demand careful thought about the code you write. Before diving into coding, take the time to plan and fully understand what you need to implement. If you think through the problem thoroughly from start to finish, this assignment will not be too difficult. However, if you try to hack your way through without a clear plan, you may end up spending many frustrating hours debugging and reworking your solution. The main challenge of this assignment lies in conceptualizing the solution. Once you overcome that hurdle, you’ll likely find the implementation itself to be surprisingly straightforward!

All the general hints and advice (https://q.utoronto.ca/courses/380124/pages/a2-faq) from Assignment 1 apply here as well.

Frequently Asked Questions

We have provided answers to various Frequently Asked Questions (https://q.utoronto.ca/ courses/380124/pages/a3-faq) about the assignment. Make sure to go over them. We have provided answers to many questions that students have asked in previous years, so you will save time by going over these answers as you start working on the assignment.

Testing Your Code

You can test your entire code by running the programs in the test folder. One week before the duedate, the MarkUs autotester will become available. There will be no hidden test cases and you will receive exactly what you see after running the public test for the assignment.

Fortest/crash and test/deadlock, each test case forks a new process so that failing an earlier testdoes not result in failing the entire test suite. However, this introduces complexity when it comes todebugging a specific test case, since GDB is not able to debug multiple processes, e.g., you can't debug both the parent and child processes at the same time. Hence, these two tests also take a command line argument -- the test number. When you specify a test number, only that test case is run, and no forking happens, so that you can debug it without issue. For example:

$ gdb test/crash
GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1
...
(gdb) run 5
Starting program: test/crash 5
crash: thread.c:456: cv_wait: Assertion `cv' failed.
Program received signal SIGABRT, Aborted.
...
(gdb) f 7
#7 0x0000555555559bc8 in cv_wait (cv=0x000000000000) at thread.c:456
456 assert(cv);

Using Git and Submitting Your Assignment

You should only modify the following files in this assignment.

thread.c thread.h queue.c

Note that you are allowed to make changes to other files for your own testing, however, the only fileswe will use during marking are the ones listed above.

You can find the files you have modified by running the git status command.

You can commit your modified files to your local repository as follows:

git add fcfs.c queue.c thread.c thread.h

git commit -m "Committing changes for Assignment 2"

Note that fcfs.c does not need to be modified for A2, but you do need to copy it over from your A1 repository and commit/push it.

We suggest committing your changes frequently by rerunning the commands above (with differentmeaningful messages to the commit command), so that you can go back to see the changes youhave made over time, if needed.

Once you have tested your code, and committed it (check that by running git status ), you can push the assignment to MarkUs.

git push

We suggest pushing your code back to MarkUs frequently, as you complete each part of the assignment.

Grace tokens are used automatically based on the time the last push to MarkUs within the grace period.

发表评论

电子邮件地址不会被公开。 必填项已用*标注