A couple of things will make this project tricky. First, processes will have to communicate with your OS via
domain sockets.
These sockets provide a simple and quite general interprocess communication method (more general than the pipes you used in the previous assignment), but require you to learn how to use a new form of IPC. Second, your OS will have to be
multi-threaded.
Specifically, whenever your OS receives a request (such as a "pipe create" or "pipe open", etc.), instead of doing whatever work is needed to fulfill the request immediately, it will instead hand-off the request to one of a pool of threads inside the OS. One of those threads will then service the request, and send the result back to the requesting process.
Fortunately, you will get a little help with some of the code. In particular, we will give you example code that communicates via domain sockets, and some skeleton code to help programs access your OS. We will also describe what basic structure your OS and processes should take on. Finally, we will give some example code that starts a thread to do some work, and point you to some useful synchronization primitives to use. More details on all of these things are found below.
This project is hard, so start early!
A lot of coding is involved, as well as careful design work to make sure your structure will be satisfactory.
Your OS will provide a very limited set of pipe-like services. A process, by linking with the LibOS library, will be able to call routines to create, open, read, write, and close pipes. These pipes are much more restricted than the typical Unix pipe, in that you cannot write arbitrary-sized messages to a pipe. Instead, a write writes a fixed-sized message to the pipe (the size is a pre-determined constant BUFFER_SIZE), and reads retrieve a single fixed-sized message that has been written to the pipe. Otherwise, many of the semantics are similar to the traditional pipe.
Let's go through a simple example of how this will all work. Let's say a program called
main.c
wants to use the pipe functionality provided by the OS. What you would do in main.c is this: first, you would include the LibOS header file, called
LibOS.h.
As you can see, we are providing this header file, and you should not be adding to it. Then, you would compile main and link it with the LibOS library (this process is described further below). Assuming that the OS is already running (after all, it is a separate process), you would run the main program. When this program calls into the LibOS library (let's say to open a pipe with Pipe_Open()), the library will contact the OS process (via domain sockets) and tell the OS what the request was. The OS will then figure out how to service the request, and reply to the library, which will somehow convey the results of the call (success or failure) to the main program. More details on all of these steps are found below.
In the general case, you will probably run at least three processes: the OS itself, a program that reads from a pipe, and a program that writes to a pipe. Of course, while always running a single OS, you should be able to run many programs that are concurrently reading and writing to different pipes.
prompt> ./os filename poolSize
As for LibOS, you are just creating a library, which should be a file called
libOS.so.
This library has a pre-defined set of interfaces, as defined below. Libraries don't have a main routine, so you will have to create your own test programs in order to test if your library (and your OS) are working correctly. Thus, for testing, you might write two programs: pipe_reader.c (which opens a pipe and reads from it) and pipe_writer.c (which opens a pipe and writes to it). Both of these will have to be linked with the LibOS, as described below.
Important Note:
for testing, we will be linking our own programs with your library. Thus, it is
very important
that you don't change any of the interfaces specified in for the LibOS!
int Pipe_Open(char *name, pipe_t mode);
Pipe_Open takes two arguments, the name of the pipe to be opened, and the mode, which is an enumerated type found in
LibOS.h.
There are two modes that an application can specify:
PIPE_READER,
and
PIPE_WRITER.
Pipe_Open should
block
until a reader and writer have both opened it; in other words, when the reader has opened it (but no writer yet), the call to Pipe_Open should not return until the writer has also opened the pipe. Upon success, Pipe_Open() should return a file descriptor that the application can use in subsequent read and write calls. Upon any failure, -1 should be returned, and
osError
set appropriately. For any pipe, there can be at most one reader and one writer. Thus, if a second reader or writer tries to open the given pipe, -1 should be returned and
osError
set to
E_PIPE_FULL.
If the name is NULL or that pipe doesn't exist or the mode is not appropriate, -1 should be returned and
osError
set to
E_INVALID_ARGS.
int Pipe_Read(int pipe, char *buffer);
Pipe_Read takes two arguments: the first is a descriptor (as returned by Pipe_Open), the second is a pointer to a buffer where data read from the pipe will be placed. When the user calls Pipe_Read, the routine will
block
until data is available, at which point a single buffer of size BUFFER_SIZE will be copied into buffer and the size of the read (in our case, always BUFFER_SIZE) will be returned to the user. Once the pipe is closed by the writer, the reader should still be able to read any data that has been placed in the pipe, but once that data is gone, Pipe_Read should return 0 to indicate that the pipe has been shutdown. If a bad descriptor is passed to Pipe_Read, -1 should be returned and
osError
set to
E_INVALID_ARGS.
int Pipe_Write(int pipe, char *buffer);
Pipe_Write is quite similar to Pipe_Read: it takes two arguments, a descriptor and a buffer that points to the data to be written to the pipe. If the data is successfully written to the pipe, Pipe_Write will return the number of bytes written (again, this should be BUFFER_SIZE, because we are only dealing with fixed-sized buffers). Pipe_Write also can block in the OS, though, if the buffers are all full and the reader has not yet read the data from the pipe. In this case, Pipe_Write should not return until the data has been written to the pipe. If the reader closes the pipe, any subsequent writes should return 0. If a bad descriptor is passed to Pipe_Write, -1 should be returned and
osError
set to
E_INVALID_ARGS.
int Pipe_Close(int pipe);
Pipe_Close takes a descriptor and closes the pipe associated with that descriptor. Pipe_Close should block until the OS has informed the library that the pipe is indeed closed. Closing the pipe has certain side-effects on subsequent reads or writes by the other pipe member, as described in the read and write sections above. If the close is successful, 0 should be returned. If a bad descriptor is passed to Pipe_Close, -1 should be returned and
osError
set to
E_INVALID_ARGS.
int OS_Init(char *filename);
This routine is called once by any program that wishes to use the LibOS services, and it takes a single argument. This argument is the name of the file that the OS is bound to, and should be used by the LibOS to send messages to the OS in the Pipe_XXX() routines. Internally, OS_Init() should probably call Domain_Open() and Domain_FillSockAddr(filename) to prepare for upcoming communication that will occur within the Pipe_XXX() routines. Upon success, this should return 0, but if for some reason this initialization fails, the routine should return -1 (no setting of
osError
is necessary here).
Note 1:
If the Pipe_XXX() routines are called
before
OS_Init() has been called, that is an error; the pipe routines should check that OS_Init() has been called, and if not, return -1 and set
osError
to
E_INIT.
Note 2:
For all calls, if the library can't communicate successfully with the OS, -1 should be returned and
osError
should be set to
E_COMM_FAILURE.
int socket = Domain_Open(tmpnam(NULL)); // library might use this code
if (socket < 0) { // signal error }
struct sockaddr_un addr;
Domain_FillSockAddr(&addr, "/tmp/os.fifo");
char buffer[512];
int rc = Domain_Write(socket, &addr, buffer, 512);
if (rc != 512) { // there was some trouble! }
char buffer[512];
struct sockaddr_un receiveAddr;
int rc = Domain_Read(socket, &receiveAddr, buffer, 512);
if (rc != 512) { // didn't read as much as expected (but only an error if < 0) }
To learn more about the code, read the code - it is pretty simple. To learn more about what Domain_Read and Domain_Write may return, read the
sendto()
and
recvfrom()
man pages, which are the OS routines used to implement Domain_Read and Domain_Write.
For example, the following struct could be used to pack the necessary information to perform a Pipe_Create():
typedef struct __MsgCreate_t {
int type; // used to set the type of this message} MsgCreate_t;
char name[MAX_STR];
int numBuffers;
The typical operation of the OS is as follows. The main thread (the one that starts up all the other threads) will start running inside of the main() routine. It should parse arguments, initialize data structures, and then create the thread pool. At that point, the main thread should enter a loop where it just waits for messages from LibOS's.
When a message is received, the OS should pass the message (or some description of the work that needs to be done to complete the request) to one of the threads. This should be done through some sort of
shared data structure.
You might recognize the
producer-consumer
relationship here, in that the main thread that reads messages from the network is a producer of work for the thread pool, which consists of a bunch of consumers.
One of the threads in the thread pool (and exactly one) will get the request, and try to execute the needed functionality. For example, if the message was a Pipe_Create() message, the thread would update whatever global data structures are in the OS that keeps track of whatever pipes are currently active. When done, the thread should go ahead and send a response to the LibOS, of course packaging up the response in a way that the library will be able to interpret.
Remember that multiple threads may be accessing those shared data structures at the same time. Thus, you will need to use
synchronization primitives
to correctly implement access to those structures. The synchronization primitives that are available to you are as follows:
pthread_cond_signal and pthread_cond_wait
Unfortunately, locks are not quite good enough: you need to be able to wait for a condition to come true. For this, you will need to use pthread_cond_wait and pthread_cond_signal. These should be used in combination with a lock in order to implement the necessary waiting that a Pipe_Read() or Pipe_Write() may incur because the pipe is empty or full, respectively. Read the man pages for more details.
A note about Deadlock: Note that when a request to perform a Pipe_Read or Pipe_Write comes into the OS and gets handed off to a thread, it may block waiting for the pipe to become full or empty (more specifically, if a thread executing a read request finds the pipe empty, it should block, and if a write finds a pipe full, it also should block). Consider a system that has 2 threads, 2 readers, and 2 writers (this of course means there are 2 pipes open). If both pipes are full and 2 writers come into the system, both writers will have to block waiting for a reader to remove a message. However, this will never happen because the two threads in the system are blocked - one was assigned to each writer. This system would be in a state of deadlock.
Your program is to accept an optional command line argument that indicates whether or not the above situation should be dealt with. In other words, if the -d option is enabled, all the pipes are full, and all the requests to the OS are for a write , your program should not deadlock (the same is true if all the pipes are empty and all the requests are for a read). However, if the -d option is NOT enabled, your program should deadlock in the above situation. It is suggested that you not worry about preventing this deadlock right away. If you have time, implement this -d option (no deadlock option) when everything else is done correctly.
You also need to learn how to compile a multi-threaded application. We'll tell you how to do that too.
All of these things should be done within your makefile, to make your life easy!
Let's say you have implemented your library in a single file, called LibOS.c. This is how you should compile that within your makefile:
gcc -c LibOS.c -g -Wall -fpic
Note that any other files that are going to be linked into the LibOS (e.g., Domain.c) should be compiled in the same way.
Now that you have LibOS.o, you'll want to make a shared library out of it. The way you do that is with the following line:
gcc -o libOS.so LibOS.o -shared
gcc -o libOS.so LibOS.o Domain.o -shared -lnsl -lsocket
gcc -o main main.c -L. -R. -lOS
gcc -c os.cc -D_REENTRANT -g -Wall
Finally, to link your OS all together, do something like this:
gcc -o os os.o Domain.o -lnsl -lsocket -lpthread
The following is a breakdown of the grading for this project:
Requirement | Points |
handing in all the appropriate files | 5 |
compilation of non-trivial program | 15 |
correct parsing of command line | 5 |
sending messages using domain sockets | 10 |
implementing LibOS | 15 |
multithreading of OS | 15 |
correct implementation of pipes in OS - with possible deadlock | 25 |
preventing deadlock with the -d option | 10 |
You will be required to demo this project for the TA to receive your grade. So be prepared and able to show that all of these various components work.
~cs537-2/public/section2/(username)/p2
where (username) is your login. Because you are working in groups of two, please go ahead and put copies of your code in both partners directories.
Obviously, you should submit all source files that are needed to compile your program (just the ones you wrote, not standard headers that will be found on any machine). You should also include a README file and a Makefile for this project. Your README file should contain information about how to run your program, any known bugs it may have, and any other information that is important to your project. Your makefile should successfully compile the program and create the
os executable and the LibOS library.
Also, be sure to comment your code well so that it can be easily browsed to see what is going on. Code that is excessively difficult to read may be penalized.