Lab 4: User Threads
Due: November 13 @ 11:59 PM
In this lab you'll create a small library called Wacky User Threads
(wut
) that implements user-space threads.
Your implementation should use the concepts learned during the lectures, along
with some new system calls.
You'll create library called libwut
this lab, not an executable.
You'll be using Git to submit your work and save your progress.
Setup
Ensure you're in the ece344
directory within VSCode.
Make sure you have the latest skeleton code from us by running:
git pull upstream main
.
This will create a merge, which you should be able to do cleanly. If you don't know how to do this read Pro Git. Be sure to read chapter 3.1 and 3.2 fully. This is how software developers coordinate and work together in large projects. For this course, you should always merge to maintain proper history between yourself and the provided repository. You should never rebase in this course, and in general you should never rebase unless you have a good reason to. It will be important to keep your code up-to-date during this lab as the test cases may change with your help.
You can finally run: cd wut
to begin the lab.
Task
You're going to create a threading library similar to pthread
in
some respects.
Unlike pthread
, you will be creating user-level cooperative threads.
This means a thread will continue to execute until it exits, or yields.
You must run your threads in FIFO order (re-queuing them if they yield).
You may use all functions in ucontext.h
to do the heavy lifting of
initializing and swapping threads in your context switching. We have a demo
of ucontext
in Lecture 20.
Your version is going to be a C library with the following API:
void wut_init(void);
int wut_create(void (*run)(void));
int wut_id(void);
int wut_yield(void);
int wut_cancel(int id);
int wut_join(int id);
void wut_exit(int status);
The description of what each function should do is below:
void wut_init(void)
This will always be called once before a user makes any other call to your
library.
You need to set up the main thread executing wut_init
as thread 0.
You should initialize or setup anything else you need here.
Your library should keep track of the following: the currently running thread,
a FIFO queue of waiting (or ready) threads, and thread control blocks for all
threads. Your thread control blocks should be in a dynamically sized array,
you'll find reallocarray
helpful.
int wut_id(void)
You should return the id
of the currently executing thread.
int wut_create(void (*run)(void))
You will create a new thread in this function, that new thread should be
setup to start executing the function given by the run
argument.
Unlike pthread
, the run
function takes no arguments, and does not return
anything.
You should return a unique wut_id
that your library will use to refer
to this created process.
The IDs should be sequential, start with 0
, and should always
use the lowest ID available.
You should allocate a stack for the new thread, set its user context using
ucontext_t
(using makecontext
).
Each thread should have its own thread control block (tcb
) that you design.
We've provided you a new_stack()
function that returns a pointer
to a new stack of size SIGSTKSZ
.
You must use this function, mostly for your own sanity, it registers
the stack with valgrind
, so you don't get a lot of false positives.
If you ever need to use a stack size, use SIGSTKSZ
.
After initializing the thread control block, you should add it to a ready queue in FIFO order. You should not switch to this thread yet.
You need to make sure that when the thread finishes running its run
function
it implicitly calls wut_exit
with a status of 0
. That way every thread will exit the same way.
int wut_yield(void)
This function should yield to the next thread on the ready queue. The thread that called yield should be put at the end of the ready FIFO queue.
This function should return 0
to the caller if it successfully yielded.
Otherwise, it should return -1
to indicate an error.
Some errors include: no available threads to switch to.
int wut_cancel(int id)
This function should cancel a thread specified by id
and remove it
from the ready queue, so it will not execute again.
You should also free any memory associated with its stack and ucontext
.
The cancelled thread's status should be set to 128
, and you should
maintain the information in the thread control block.
This function should return 0
to the caller if it successfully
cancelled.
Otherwise, it should return -1
to indicate an error.
Some errors include: invalid thread to cancel, cannot cancel self.
int wut_join(int id)
This function should cause the calling thread to block until the thread
specified by id
terminates (either by exiting to getting cancelled).
Blocking means the current thread should stop running, and not be put into
the ready queue (it cannot execute yet).
Once the thread specified by id
terminates, the calling thread should be
re-added to the end of the ready queue.
The calling thread should free any memory associated with the thread specified
by id
including its stack and ucontext
.
A thread may only be waited on by one other thread.
You should detect and report an error if two threads attempt to join on the
same thread (only the first thread that calls join should succeed).
This function should return the status of the waited on thread to the caller.
Also, the waited on thread should have its thread control block removed and its
id
should be available to new threads.
Otherwise, it should return -1
to indicate an error.
Some errors include: invalid thread to wait on, cannot wait on self.
You should only successfully block the calling thread if the thread specified
by id
is in the waiting (or ready) queue.
You should not successfully join a thread that is waiting on another thread
already.
In this case, return -1
and continue execution.
If the thread specified by id
is already terminated, then the thread
calling wut_join
must continue execution and not be re-added to
the back of the ready queue.
You must of course return the status
of the terminated thread and clean
up all its resources.
void wut_exit(int status)
This causes the current thread to exit and set its status in the thread
control block to the value given by status
.
Similar to cancel, this thread should be removed from the ready queue, so it
will not execute again.
You would not want to free the stack at this point (why?).
If this is the final thread in the process, the process should exit with
and exit code of 0
.
Internally the status should only be values between 0
and 255
inclusive.
You must only store the lower byte of the status
argument.
If you are unfamiliar with lower level operations in C, you can use:
status &= 0xFF;
and afterwards status
will be between 0
and 255
.
(Note: this is what occurs when you exit
from a process as well).
This means that successful calls to wut_join
will also return a
value between 0
and 255
.
Errors
You need to check for and properly handle errors.
For fatal errors, you should exit with the errno
of the first fatal
error.
However, your implementation should not generate errors
Progression
There are 13 tests that you should be able to pass in order, as follows:
main-thread-is-0
first-thread-is-1
main-thread-yields
first-thread-exits-explicitly
first-thread-exits-implicitly
first-thread-runs
main-thread-joins
first-thread-cancelled
thread-in-thread
two-threads
reuse-thread-0
error-thread-join-self
error-thread-yield-none
To pass the first test (main-thread-is-0
) you only need to write a partial
implementation of wut_init
and wut_id
. Let's look at the source code
for this test, it's located a wut/tests/main-thread-is-0.c
.
/* Header file for the testing infrastructure, it creates an array of 10240
integers in shared memory. The purpose of this is to check that your
program sets the correct values, even if it crashes. The tests run the
`test` function in a new child process, and after that process terminates
the test runs the `check` function. */
#include "test.h"
/* This is the header file with all the functions you're creating for your
threading library. */
#include "wut.h"
/* This `test` function should run the code for the test itself. The test should
only write to an element in `shared_memory` once (only the last write gets
checked). You can either write the return values of library calls, or an
`int` variable you have your threads change. This example just calls
`wut_init` to initialize your library, all tests should begin with this
and only call it once. After initialize, it calls `wut_id` and writes the
result to array index 0 in shared memory. */
void test(void) {
wut_init();
shared_memory[0] = wut_id();
}
/* The `check` function runs after the child process that executes `test` exits.
Here we just call `expect` with the `shared_memory` element we want to check,
then the value we expect, then a message to print if they do not match. For
this test we just expect the return value of `wut_id` is 0 if we do not
create any other threads. After calling `wut_init` the id of the main thread
should be 0. */
void check(void) {
expect(
shared_memory[0], 0, "wut_id of the main thread is wrong"
);
}
After this you should be able to read the rest of the tests in the tests
directory and make sure you understand the expected values. There is a special
expected value that indicates the shared_memory
element never changed, and
that's TEST_MAGIC
.
Let's look at a test submitted by a student in the previous offering of the
course. In the comments I use numbers in parentheses to indicate the order of
execution, for example: /* (1) First step */
, then /* (2) Second step */
.
Please find the code below:
#include "test.h"
#include "wut.h"
int x = 0;
void null_run(void) {
/* (12) Thread 0 starts executing and immediately returns, causing it to
implicitly exit. The only other thread to run is thread 1, which
now returns from its join. */
return;
}
void t1_run(void) {
/* (4) Thread 1 cancels thread 0, thread 0 is now terminated and no thread
is attempting to join thread anymore. */
shared_memory[4] = wut_cancel(0);
/* (5) Thread 1 joins thread 0, which is terminated, so join returns
immediately and thread 1 continues to execute. The status of
thread 0 should be 128 because it got cancelled. */
shared_memory[5] = wut_join(0);
/* (6) Thread 1 joins thread 2, which is in the ready queue. Thread 1 blocks
and thread 2 starts executing `t2_run`. */
/* (13) Thread 1 continues execution and writes the return value of
`wut_join` to index 6 of shared memory, it should get a status of
0 because thread 2 terminated normally. */
shared_memory[6] = wut_join(2);
/* (14) Thread 1 increments the global variable `x` from 1 to 2. */
++x;
/* (15) Write the value of `x` to index 8 of shared memory, it should be 2.
*/
shared_memory[8] = x;
/* (16) Thread 1 implicitly exits, we have no other threads and the process
terminates. The join at time (3) never returns, since the original
main thread got cancelled, and when we check `shared_memory` at
index 3, we use the special value to indicate that the write to
shared memory never happened. */
}
void t2_run(void) {
/* (7) Thread 2 starts executing, we have no other threads in the ready
queue. It increments the global variable `x` from 0 to 1. */
++x;
/* (8) Write the value of `x` to index 7 of shared memory, it should be 1.
*/
shared_memory[7] = x;
/* (9) Create a new thread, the lowest available thread id should be 0 since
it got joined at (5). The current running thread is still 2, and the
ready queue should be = [0]. */
int id4 = wut_create(null_run);
/* (10) Write the return value of `wut_create` to index 9 of shared memory,
it should be 0. */
shared_memory[9] = id4;
/* (11) Thread 2 should implicitly exit, this should cause thread 1 to
unblock and get added to the ready queue. The queue should now be:
[0, 1]. Since thread 2 exited, we should run thread 0 next and our
ready queue is [1]. */
}
void test(void) {
wut_init();
/* (1) We only have the main (initial) thread running */
shared_memory[0] = wut_id();
shared_memory[1] = wut_create(t1_run);
shared_memory[2] = wut_create(t2_run);
/* (2) Thread 0 is running, and the ready queue should be = [1, 2] */
int id2 = shared_memory[2];
/* (3) Thread 0 joins thread 2. Thread 0 blocks. Thread 1 starts running
`t1_run`, and our ready queue is = [2] */
shared_memory[3] = wut_join(id2);
}
void check(void) {
expect(
shared_memory[0], 0, "wut_id of the main thread is wrong"
);
expect(
shared_memory[1], 1, "wut_id of the second thread is wrong"
);
expect(
shared_memory[2], 2, "wut_id of the third thread is wrong"
);
expect(
shared_memory[3], TEST_MAGIC, "wut_join should never return because"
" id 0 is cancelled"
);
expect(
shared_memory[4], 0, "second wut_join should return 0"
);
expect(
shared_memory[5], 128, "third wut_join should return the status of"
" cancelled thread"
);
expect(
shared_memory[6], 0, "fourth wut_join should should return 0"
);
expect(
shared_memory[7], 1, "x should increment by 1"
);
expect(
shared_memory[8], 2, "x should increment again"
);
expect(
shared_memory[9], 0, "new thread should reuse id 0"
);
}
Building
First, make sure you're in the wut
directory if you're not already.
After, run the following commands:
meson setup build
meson compile -C build
Whenever you make changes, you can run the compile command again. You should only need to run setup once.
Testing
You cannot execute your library directly, however you can run the test programs
manually.
Please find the files in tests/*.c
.
You should be able to read and understand what they're doing with your library.
You'll find the executables in build/tests/*
.
You may also choose to run the test suite provided with the command:
meson test --print-errorlogs -C build
The first 10 tests are arranged in the order you should do them.
Grading
Run the ./grade.py
script in the directory.
This will rebuild your program, run the tests, and give you a grade out of
100 based on your test results.
Note that these test cases may not be complete, more may be added before the
due date, or there may be hidden test cases.
These labs are new, so we may need to change.
Tips
You'll want to read the documentation on the ucontext
family of C functions.
Some header files you'll need to use are provided for you in the skeleton code.
You may include additional parts of the standard library.
It's highly recommended to at least use the following functions:
getcontext
makecontext
swapcontext
setcontext
exit
You may also find sys/queue.h
helpful, especially the TAILQ
family of functions that implement a useful linked list.
There's some other headers and functions you may find useful during development
provided for you.
Submission
Simply push your code using git push origin main
(or simply
git push
) to submit it.
You need to create your own commits to push, you can use as many
as you'd like.
You'll need to use the git add
and git commit
commands.
Push as many commits as you want, your latest commit that modifies
the lab files counts as your submission.
For submission time we will only look at the timestamp on our server.
We will never use your commit times (or file access times) as proof of
submission, only when you push your code to the course Git server.
Common Issues
You do need to be careful about data races, we do have concurrency. Most of the very difficult to debug cases involve data races.
Segfault in swapcontext
When you create your thread control block, you should not have ucontext_t
as a field. You should have ucontext_t *
instead. If you have ucontext_t
the address you give to swapcontext
may not be valid if you have to increase
the size of your dynamically allocated TCB array (reallocarray
may move
the array). Therefore, you should malloc
the ucontext_t
separately so
the address of the ucontext_t
does not change, even if the array moves.
Valgrind Doesn't Work
There's a problem with older version of Valgrind using Clang. Try:
rm -rf build
CC=gcc meson setup build
meson compile -C build
After this you should be able to continue as normal and Valgrind should work.