Table of Contents
Study Guide 2: Threads, I/O
Threads
Threads are single unique execution contexts with their own set of registers and stack. Threads are sometimes referred to as “lightweight processes”.
Thread Control Block
Components that are shared between threads in the same process (e.g. heap, global variables) do not need to be persisted by each individual thread. However, each thread still needs to persist its registers and stack in the thread control block (TCB).
Syscalls
POSIX defines a pthread
library which consists of syscalls for threads similar to the process syscalls.
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg)
pthread_create
starts a new thread in the calling process. The thread starts execution by invokingstart
routine that takes inarg
as its sole argument.In order to pass more than one value as an argument to the thread function, one must create a dedicated struct holding those values and pass that along as the last argument.
void pthread_exit(void *retval)
pthread_exit
terminates the calling thread and stores a value inretval
that is available to another thread in the same process that joins. Similar to how a return from main will implicitly callexit
for processes, a return from start routine will implicitly callpthread_exit
.
int pthread_yield(void)
pthread_yield
causes the calling thread relinquishes the CPU and is put at the end of the run queue.
int pthread_join(pthread t thread, void **retval)
pthread_join
causes the calling thread waits forthread
to terminate. For a non-NULL
retval
, the exit status of thread (i.e. the one frompthread_exit
) is made available inretval
.
pthreads Party
What are the possible outputs of the following program? How can you change the program to make it print “HELPER” before “MAIN” guaranteed?
// pthread_order.c void *helper(void *arg) { printf("HELPER"); return NULL; } int main() { pthread_t thread; pthread_create(&thread, NULL, &helper, NULL); pthread_yield(); printf("MAIN"); return 0; }
Solution
The output of the program can be
MAINHELPER
,HELPERMAIN
, orMAIN
. Thepthread_yield
does not guarantee that helper will be run first. While the main thread will relinquish the CPU, there is no guarantee that the thread running the helper function finishes executing the function before the CPU is given back to the main thread. In fact, there isn’t even a guarantee that the scheduler will choose to run thehelper
thread after themain
thread relinquishes the CPU - it is possible that even though the main thread calledpthread_yield
, the scheduler still chooses to schedule the main thread over the helper thread as the next running thread. It might even happen that the main thread runs to completion before the helper thread ever runs the print statement. When the main thread returns, the process will return as well since it exits from the main function. This will kill the process and thereby its associated threads.A simple fix is to replace the
pthread_yield
with apthread_join
. With this change, the main thread will wait until the helper thread has returned, meaningHELPER
will always be printed beforeMAIN
.
What does the following program print?
// pthread stack.c void *helper(void *arg) { int *num = (int*) arg; *num = 2; return NULL; } int main() { int i = 0; pthread_t thread; pthread_create(&thread, NULL, &helper, &i); pthread_join(thread, NULL); printf("i is %d", i); return 0; }
Solution
Since both threads are in the same process, they share the same address space. Even though each thread will have a separate stack, a shared address space means threads in the same process can access each others’ stacks. As a result, helper thread will modify
i
in the main thread, giving an output ofi is 2
.
What does the following program print?
// pthread_heap.c void *helper(void *arg) { char *message = (char *) arg; strcpy(message, "I am the helper"); return NULL; } int main() { char *message = malloc(100); strcpy(message, "I am the main"); pthread_t thread; pthread_create(&thread, NULL, &helper, message); printf("%s", message); pthread_join(thread, NULL); return 0; }
Solution
The program can print either
I am the main
orI am the helper
.pthread_join
is called after theprintf
, meaning the order of execution is not guaranteed by theprintf
statement.
I/O
The operating system must be able to interface with a wide variety of input/output (I/O) devices including but not limited to keyboard, mouse, disk, USB port, WiFi. While there are many different types of devices, POSIX unifies all of them behind a single common interface.
Design Philosophy
POSIX has a set of design philosophies that define the standard for UNIX-based operating systems.
Uniformity
All I/O is regularized behind a single common interface under the idea that “everything is a file” This means all I/O (e.g. file operations, interprocess communications) use the same set of syscalls: open
, close
, read
, write
.
Open Before Use
Before any I/O operation, the “device” must be opened before using. This allows the operating system to perform various checks (e.g. access control permissions) and bookkeeping of metadata. For instance, a device might only be allowed to be opened by one application.
Byte Oriented
All data from devices are addressed in bytes even for devices with different block sizes.
Kernel Buffered Read/Writes
Data from devices are stored in a kernel buffer and returned to the application on request. Similarly, outgoing data is stored in a kernel buffer until the device is ready to be written to. Kernel buffering allows for same interface between devices with different read block sizes and different write speeds.
Explicit Close
An application must explicitly call close when it’s done with the device. Similar to open, this allows the operating system to perform bookkeeping and garbage collection.
Low-Level API
The Low-level API operates directly with file descriptors which are indices into a file descriptor table (FDT). Each process has its own FDT stored in kernel memory, where each entry points to a file description which represents an open file or device and keeps track of metadata (e.g. offset position). This means that the same file descriptor can correspond to different file descriptions across different processes.
Specifically, file descriptors 0
, 1
, and 2
are initialized to standard input (stdin
), standard output (stdout
), and standard error (stderr
), respectively. However, they can be redefined (see dup
and dup2
). Select methods from the low-level API are listed below. All low-level methods are syscalls, meaning they occur entirely in kernel space.
int open(const char* pathname, int flags)
Opens a file specified by
pathname
with access control permissionsflags
. Returns a file descriptor or-1
on an error.
int close(int fd)
Closes a file descriptor, so that it no longer refers to any file and may be reused. If
fd
is the last file descriptor referring to the file description, the resources associated with the open file description are freed. Returns0
on success or-1
on an error.
ssize t read(int fd, void *buf, size t count)
Attempts to read up to
count
bytes from file descriptorfd
into the buffer starting atbuf
. Returns the number of bytes read on success or-1
on an error.
ssize t write(int fd, const void *buf, size t count)
Attempts to write
count
bytes to file descriptorfd
from the buffer starting atbuf
. Returns number of bytes written on success or-1
on an error.
off t lseek(int fd, off t offset, int whence)
Repositions the file offset of the open file description associated with the file descriptor
fd
to the argumentoffset
according to the directivewhence
.
int dup(int oldfd)
Allocates the next available file descriptor to point to the same file description as
oldfd
.
int dup2(int oldfd, newfd)
Same as
dup
but with a specific file descriptornewfd
.
A particular note about read and write is that these methods are not guaranteed to fully succeed, meaning read and write might read and write less bytes than specified. However, this isn’t considered an error.
High-Level API
The High-level API operates on streams of data, which are unformatted sequences of bytes with a position. Streams can encompass any data type from raw binaries to text. An open stream is represented by a pointer to FILE
, which contain information pertaining to an open stream such as the file descriptor. For example, FILE *stdin
, FILE *stdout
, and FILE* stderr
respectively correspond to file descriptors 0
, 1
, and 2
.
High-level API buffers data in user memory in larger chunks (e.g. 1024
bytes). This is different from the kernel buffered philosophy since not every invocation of the high-level API requires a syscall. For instance, a fread
with size = 100
will still fetch the data in a larger chunk (e.g. 1024
bytes), meaning there will not be a syscall when fread
is called a second time with size = 100
.
Select methods from the high-level API are listed below.
FILE *fopen(const char *pathname, const char *mode)
Opens the file associated with
pathname
and associates a stream with it. Returns aFILE *
orNULL
on an error.
int fclose(FILE *stream)
Flushes the stream pointed to by
stream
and closes the underlying file descriptor. Returns0
on success orEOF
on an error.
int fread(void *ptr, size t size, size t nmemb, FILE *stream)
Reads
nmemb
items of data, eachsize
bytes long, from the stream pointed to bystream
, storing them at the location given byptr
. Return the number of items read.
int fwrite(void *ptr, size t size, size t nmemb, FILE *stream)
Writes
nmemb
items of data, eachsize
bytes long, to the stream pointed to bystream
, obtaining them from the location given byptr
. Returns the number of items written.
int fflush(FILE *stream)
Forces a write of all data in the user space buffer to stream.
int fprintf(FILE *stream, const char *format, ...)
Prints to
stream
according to format.
int fscanf(FILE *stream, const char *format, ...)
Scans from
stream
according to format.
Contrary to read and write, fread
and fwrite
guarantee that they will read and write the specified amount. If fread
and fwrite
return an amount less than the specified amount, it is considered an error.
In general, high-level APIs provide a more expressive way of interacting with devices. For instance, fprintf
allows printing with a specific format whereas write
can only write raw sequences of bytes. There are also different API methods defined for character-oriented devices (e.g. fputc
) and block-oriented devices (e.g.
fread
).
Interprocess Communication
In theory, processes can communicate using files, where one process writes to a file and another reads from a file. However, this is a painfully slow process due to the overhead of disk speeds. Interprocess communication (IPC) provide mechanisms to communicate between processes to manage shared data. Oftentimes, this communication will be facilitated using memory instead of disk.
Pipes are one-way communication channels between processes on the same physical machine. A pipe can be thought of as a single queue where you can read from one end and write to another. When opened, each end of the queue corresponds to its own file descriptor. You can use the int pipe(int pipedfd[2])
syscall to create a pipe.
Sockets are two-way communication channels between processes. These processes can be on the same or different machines. Sockets can be thought of as using two queues, one in each direction. For a connection to to be established, the server needs to be able to accept a request connection from the client. The steps to do this are
- Create server socket using
socket
syscall. - Bind the server socket to a specific address using
bind
syscall. - Start listening for connections using
listen
syscall.
Once these steps succeed, the server can accept new connections using the accept
syscall. When the client wants to make a connection request, it sends a 5-Tuple that uniquely identifies a connection. In the 5-Tuple are:
- Source IP Address
- Destination IP Address
- Source Port Number
- Destination Port Number
- Protocol (e.g. TCP)
Concept Check
What’s the difference between
fopen
andopen
?Solution
fopen
is a high-level API, whileopen
is a low-level API.fopen
will return aFILE*
type, whileopen
will return an integer (a file descriptor).
What will the test.txt file look like after the following program is run? You may assume
read
andwrite
fully succeed (i.e. read/write the specified number of bytes).For reference,
SEEK_SET
will set the offset to offset bytes (relative to the beginning of the file), whileSEEK_CUR
will set the offset relative to the current location. Seeking past the end of a file will set the bytes in the file to 0.// lseek_test.c int main() { char buffer[200]; memset(buffer, 'a', 200); int fd = open("test.txt", O_CREAT|O_RDWR); write(fd, buffer, 200); lseek(fd, 0, SEEK_SET); read(fd, buffer, 100); lseek(fd, 500, SEEK_CUR); write(fd, buffer, 100); }
Solution
The first write gives us 200 bytes of
'a'
. Then we seek to the offset0
(i.e. beginning of the file) and then read 100 bytes. This will set the current offset to100
. We seek again to offset600
(500 bytes on top of existing 100) then write 100 more bytes of'a'
. Since we seek past the end of the file, the 200 to 599th bytes will be set to0
. An important distinction is that this is not the character'0'
but rather the literal byte0
.
File Descriptor Fun
Consider a method that copies n bytes from src file to dest file. You may assume both files are already created, and src is at most 100 bytes long. This method has been written in two different ways, one using the high-level API and low-level API.
// copy.c
void copy_high(const char* src, const char* dest) {
char buffer[100];
FILE* rf = fopen(src, "r");
int buf_size = fread(buffer, 1, sizeof(buffer), rf);
fclose(rf);
FILE* wf = fopen(dest, "w");
fwrite(buffer, 1, buf_size, wf);
fclose(wf);
}
void copy_low(const char* src, const char* dest) {
char buffer[100];
int rfd = open(src, O_RDONLY);
int buf_size = 0;
while ((bytes_read = read(rfd, &buffer[buf_size], sizeof(buffer) - buf_size)) > 0)
buf_size += bytes_read;
close(rfd);
int bytes_written = 0;
int wfd = open(dest, O_WRONLY);
while (bytes_written < buf_size)
bytes_written += write(wfd, &buffer[bytes_written], buf_size - bytes_written);
close(wfd);
}
What is the purpose of the while loops in copy low?
Solution
Contrary to
fread
andfwrite
,read
andwrite
are not guaranteed to read and write the specified size. As a result, we need the while loop to make sure the desired number of bytes are read and written.
What does the following program print to standard output?
// dup_pi.c int main(int argc, char **argv) { int fd; if ((fd = open("out.txt", O_CREAT|O_TRUNC|O_WRONLY, 0644)) < 0) exit(1); printf("The last digit of pi is ..."); fflush(stdout); dup2(fd, 1); printf("five"); exit(0); }
Solution
Standard out will only print
The last digit of pi is ...
since we replaced the file descriptor,
1
was remapped to correspond to the file description corresponding to the openout.txt
.five
will end up inout.txt
.
Echo Server
Consider a server implementation that echoes back the bytes that the client sent. Each connection needs to be handled in its own thread. Threads should be allowed to handle connections concurrently. For simplicity, assume read and write do not return short.
// echo_server.c
#define BUF_SIZE 1024
struct addrinfo* setup_address(char* port) {
struct addrinfo* server;
struct addrinfo hints;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;
int rv = getaddrinfo(NULL, port, &hints, &server);
if (rv != 0) {
printf("getaddrinfo failed: %s\n", gai_strerror(rv));
return NULL;
}
return server;
}
void* serve_client(void* client_socket_arg) {
int client_socket = (int) client_socket_arg;
char buf[BUF_SIZE];
ssize_t n;
while ((n = read(client_socket, buf, BUF_SIZE)) > 0) {
buf[n] = '\0';
printf("Client Sent: %s\n", buf);
if (write(client_socket, buf, n) == -1) {
____________________;
____________________;
}
}
____________________;
____________________;
}
int main(int argc, char** argv) {
if (argc < 2) {
printf("Usage: %s <port>\n", argv[0]);
return 1;
}
struct addrinfo* server = setup_address(argv[1]);
if (server == NULL)
return 1;
int server_socket = ______(server->ai_family, server->ai_socktype,
server->ai_protocol);
if (server_socket == -1)
return 1;
if (_____(server_socket, server->ai_addr, server->ai_addrlen) == -1)
return 1;
if (_____(server_socket, 1) == -1)
return 1;
while (1) {
int connection_socket = accept(server_socket, NULL, NULL);
if (connection_socket == -1)
pthread_exit(NULL);
pthread_t handler_thread;
int err = pthread_create(____________________);
if (err != 0)
____________________;
pthread_detach(handler_thread);
}
}
What are the first three steps that a server needs to perform to be able to accept new connections? Specify the specific syscalls that need to be used.
Solution
The server needs to create a socket using the
socket
syscall, bind it to an address usingbind
syscall, and start listening for new connections usinglisten
syscall.
What function should each thread start at (i.e. start routine argument in
pthread_create
)?Solution
serve_client
. It has the basic outline of reading what the client sent and writing it back.
What should the server do when it’s finished with a client according to POSIX design philosophies?
Solution
An explicit
close
of the client socket is required.
What are the dangers of this approach compared to making a new process for each connection?
Solution
Each connection is handled in the same process, meaning a malicious connection could attack the server and make the server and other clients vulnerable. If every connection was handled in a new process, the server and other clients would still be safe.
Fill in the blanks to complete the implementation.
Solution
void* serve_client(void* client_socket_arg) { int client_socket = (int) client_socket_arg; char buf[BUF_SIZE]; ssize_t n; while ((n = read(client_socket, buf, BUF_SIZE)) > 0) { buf[n] = '\0'; printf("Client Sent: %s\n", buf); if (write(client_socket, buf, n) == -1) { close(client_socket); pthread_exit(NULL); } } close(client_socket); pthread_exit(NULL); } int main(int argc, char** argv) { if (argc < 2) { printf("Usage: %s <port>\n", argv[0]); return 1; } struct addrinfo* server = setup_address(argv[1]); if (server == NULL) return 1; int server_socket = socket(server->ai_family, server->ai_socktype, server->ai_protocol); if (server_socket == -1) return 1; if (bind(server_socket, server->ai_addr, server->ai_addrlen) == -1) return 1; if (listen(server_socket, 1) == -1) return 1; while (1) { int connection_socket = accept(server_socket, NULL, NULL); if (connection_socket == -1) pthread_exit(NULL); pthread_t handler_thread; int err = pthread_create(&handler_thread, NULL, serve_client, connection_socket); if (err != 0) pthread_exit(NULL); pthread_detach(handler_thread); } }