Processes and threads
Goals
- to allow our server to serve multiple clients, concurrently
- to understand Unix processes - and how to fork them
- to acknowledge an alternative to processes: threads
Activity: we will look at an example on thread safety in today’s activity.
Serving multiple clients
Recall our client-server example, in which we explored simple client and server programs. The client was able to connect to a server, and the server was able to serve clients.
The catch? Any client connecting to our server will command our server’s full attention until the client exits. Our server can be blocked by any program from anywhere in the Internet that simply opens a connection to the server. No other clients can be served!
We need to enable our server to serve multiple clients simultaneously, at whatever pace each client is prepared to move. The solution: ‘fork’ a copy of the server for each new client!
A forking server
Take a look at the new inserver-fork.c under the client-server example. The client is exactly the same. The server is just a bit different.
In this new server, right after accepting a new client we arrange for the server process to ‘fork’ into two identical processes: the only difference between parent and child process is in the return value from the fork()
system call.
// 4. Start accepting connections, and allow one connection at a time
listen(list_sock, LISTEN_BACKLOG);
while (true) {
// accept connection and receive communication socket (file descriptor)
comm_sock = accept(list_sock, 0, 0);
if (comm_sock == -1)
perror("accept");
else {
// start a new process to handle the new client
if (fork()) {
// parent process
close(comm_sock);
} else {
// child process
close(list_sock);
printf("Connection started\n");
...
close(comm_sock);
printf("Connection ended\n");
exit(0);
}
}
...
}
close(list_sock);
The action is right in the middle, where our server process calls fork()
.
Upon return from that call, there are two processes running this same code, each with a totally independent copy of the memory, and with all the same file descriptors open.
Each checks the value returned from fork()
.
The parent process (the original server) receives a non-zero return value. It immediately closes the brand-new socket connected to the new client, and moves on.
The child process (a new copy of the server) receives a zero return value.
It immediately closes the older listening socket, for which it has no use, and proceeds to handle the client exactly as we had before.
When finished, it calls exit(0)
, ending its life as a process.
Processes
A process is a running program.
You write a program, compile it, and save the result in an executable file.
That file just sits there until somebody runs the program.
When the program is run, e.g., from the shell command line, Unix creates a new process with a copy of that program’s code and allows it to run.
The fork()
function call clones a running process, causing Unix to create a new process with a copy of all of the existing process’s state; the new process does not start from scratch.
The ps
command prints a list of our current processes. -u
option allows us to print processes owned by a specific user.
$ ls
inclient* inclient.o inserver.c inserver.o
inclient.c inserver* inserver-fork.c Makefile
$ ps -u xia -o pid,ppid,state,comm
PID PPID S COMMAND
27312 1 S systemd
27313 27312 S (sd-pam)
27317 27310 D sshd
27318 27317 S bash
27965 27963 S sshd
27966 27965 S bash
28393 27318 R ps
$
Each line shows the process identifier (pid), which is an integer incremented for each new process on the system; the parent process identifier (ppid), which identifies the process that forked that process, the process status (see below), and the command running in that process.
Now I’ll run ./inserver-fork
in different window, and check ps
again.
To focus our attention, I’ll grep for server
:
$ ps -u xia -o pid,ppid,state,comm | grep server
28420 27966 S inserver-fork
$
Processes are typically either Running (R), Sleeping(S), Waiting for I/O(D), Stopped(T), or defunct (Z) (a.k.a., a Zombie). Everything we see above is Sleeping (S), waiting on some input.
Next I’ll run ./inclient
with suitable parameters; it finishes quickly.
After it’s done, let’s ps
:
$ ps -u xia -o pid,ppid,state,comm | grep server
28420 27966 S inserver-fork
28448 28420 Z inserver-fork <defunct>
$
Huh? now there are two httpserver
processes, the second a child of the first, but that’s surprising because I thought the child should have exited.
It did indeed exit (and die) – but its parent has not yet noticed.
The child is now a zombie (status Z
), taking up space in the system process table even though it is no longer running.
It stays there until the parent calls wait
, or until the parent dies.
If we don’t do something about it, a lot of zombies can accummulate.
Eventually, the entire system grinds to a halt, because the system’s internal process table is full.
(Don’t do this to your classmates!)
Reaping zombies
A responsible program will clean up after itself.
Notice before we create the socket, we install a handler for the SIGCHLD signal, where we set the handler to SIG_IGN
to discard child exit information and thus no zombie processes will be left.
// reap zombie processes automatically
signal(SIGCHLD, SIG_IGN);
Threads
One unfortunate side-effect of forking a copy of the parent server process is that the parent and child have no means to communicate after they’ve forked. Any data structures created by the parent are duplicated in the child. Any changes the child makes to those data structures are made on the child’s copy, and not seen by the parent.
Perhaps that’s good. But if the goal of the server is to manage some kind of shared state - like a game server to which clients provide updates and from which the server updates the clients - the server really needs a way to handle multiple clients simultaneously and manage a shared data structure.
One solution: use threads instead of processes. The concept is similar - each thread has a separate flow of control through the server code - but different, because all the threads operate in the same copy of data memory. Thus, they all have access to the same data structures.
There are specific libraries that support the creation of new threads, and for means to coordinate thread access to shared data structures.
Why threads?
From YoLinux:
The POSIX thread libraries are a standards based thread API for C/C++. It allows one to spawn a new concurrent process flow. It is most effective on multi-processor or multi-core systems where the process flow can be scheduled to run on another processor thus gaining speed through parallel or distributed processing.
Threads require less overhead than “forking” or spawning a new process because the system does not initialize a new system virtual memory space and environment for the process.
While most effective on a multiprocessor or multi-core systems, gains are also found on uniprocessor systems which exploit latency in I/O and other system functions which may halt process execution. (i.e., one thread may execute while another is waiting for I/O or some other system latency.) Parallel programming technologies such as MPI and PVM are used in a distributed computing environment while threads are limited to a single computer system. All threads within a process share the same address space. A thread is spawned by defining a function and its arguments which will be processed in the thread. The purpose of using the POSIX thread library in your software is to execute software faster.
What’s POSIX?
It’s not a Dr. Seuss character !
POSIX is an acronym for “Portable Operating System Interface [for Unix]” which is the name of a set of related standards specified by the IEEE.
What’s a thread?
What is a thread? Well we have studied the forking of process to support concurrency. Threads are units of control that execute within the context of a single process representing multiple strands of independent execution.
What is the difference between forking processes and threads?
Well typically when a process is forked it executes as new independent process with its own PID and a copy of the code and resources of the parent process.
It is scheduled by the OS as a independent process.
A process has a single thread by default called main()
.
Threads running in a process get their own stack and run concurrently and have access to process state such as open files, global variables, etc.
You will have to use multiple processes or threads for the final project - your choice.
In this lecture, we will just look at a number of simple examples of code to illustrate how threads are created and how we can implement mutual exclusion using mutex
for shared resources.
These notes are not meant to be exhaustive - they are not.
For a in depth look at pthreads read the following tutorial - it may help answer questions that you may have not covered in the class: POSIX Threads Programming, Blaise Barney, Lawrence Livermore National Laboratory
Also, type man pthread
for information on syntax, etc.
Thread Creation
You have to write C code to create a thread. If you do it right, when it runs that C code will create a new thread of execution that runs at the same time! Here’s a very simple example (print_i.c).
/*
* print_i.c: simple demonstration of thread
*
* CS50, Summer 2021
*/
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
/********** global variable to share data *******/
int i;
/********** function prototype *******/
void* print_i(void *ptr);
/********** main *******/
int main() {
pthread_t t1;
i = 1;
int iret1 = pthread_create(&t1, NULL, print_i, NULL);
if (iret1 != 0) {
fprintf(stderr, "thread could not be created.\n");
exit(1);
}
while (1) {
sleep(2);
i = i + 1;
}
exit(0); //never reached.
}
// This function will run concurrently.
void* print_i(void *ptr) {
while (1) {
sleep(1);
printf("%d\n", i);
}
}
Unpredictability
You cannot always predict the exact sequence of execution of threads running in parallel. Lots of factors affect how often and long a thread actually gets to execute: the amount of real memory, number of processors and/or cores, and what the thread’s code is trying to do (e.g., I/O, computation, system call, etc.), among others.
In the following example, random.c, what gets output first?
We do not know what order the values will be written out.
If the main thread completes before the print_i()
thread has executed then it will die.
/*
* random.c: demonstration of the randomness in thread execution
*
* CS50, Summer 2021
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
/********** function prototype *******/
void* print_i(void *ptr);
/********** main *******/
int main()
{
pthread_t t1;
int iret1 = pthread_create(&t1, NULL, print_i, NULL);
if (iret1 != 0) {
fprintf(stderr, "thread creation failed, rc=%d.\n", iret1);
return (iret1);
}
printf("c\n");
return 0;
}
// This function will run concurrently.
void* print_i(void *ptr)
{
printf("a\n");
printf("b\n");
return 0;
}
Here’s the output from several runs:
$ ./random
c
$ ./random
c
$ ./random
c
$ ./random
c
a
b
$ ./random
c
a
b
$ ./random
c
Functions that are not thread safe
Most (but not all) of the libraries you get with gcc
are thread safe.
This means that regardless of how many threads might be executing the very same function’s code,
- the code still works as expected,
- the threads cannot interact or interfere with each other,
- if there is any data shared by all the threads, that data can only ever be accessed by one thread at a time
This is usually accomplished by ensuring all the variables used by the thread are stored on its stack (rather than global variables). If there is shared data, access to that shared data is typically serialized using some sort of mutual exclusion mechanism (more on this later).
Activity: In today’s class activity, we will look at an example.
Mutual Exclusion (mutex) locks
Mutual Exclusion (mutex) is a mechanism to help manage concurrency in programs. You can think of a mutex as a lock that controls when a process can enter specific sections of code. There are two functions:
-
pthread_mutex_lock(mutex)
:If the mutex is locked, this function will block until the lock is unlocked; then, it will lock the mutex. On return, the mutex is locked.
-
pthread_mutex_unlock(mutex)
:If the mutex is locked by some prior
pthread_mutex_lock()
function, this function will unlock the mutex; otherwise it does nothing.
In the following example, mutex.c, our goal is to ensure that at any time there is only one thread calling function print()
.
/*
* mutex.c: demonstration of mutex
*
* CS50, Summer 2021
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
/********** global variable *******/
pthread_mutex_t mutex1 = PTHREAD_MUTEX_INITIALIZER;
/********** function prototype *******/
void print(char* a, char* b);
void* print_i(void *ptr);
void* print_j(void *ptr);
/********** main *******/
int main() {
pthread_t t1, t2;
int iret1 = pthread_create(&t1, NULL, print_i, NULL);
int iret2 = pthread_create(&t2, NULL, print_j, NULL);
if (iret1 || iret2) {
fprintf(stderr,"pthread_create failed: iret1=%d, iret2=%d\n", iret1, iret2);
exit(1);
}
else {
sleep(8);
}
exit(0); //never reached.
}
// try uncommenting and commenting the mutext below
// and look at the output
void print(char* a, char* b) {
pthread_mutex_lock(&mutex1); // comment out
printf("1: %s\n", a);
sleep(1);
printf("2: %s\n", b);
pthread_mutex_unlock(&mutex1); // comment out
}
// These two functions will run concurrently.
void* print_i(void *ptr) {
print("I am", " in i");
return NULL;
}
void* print_j(void *ptr) {
print("I am", " in j");
return NULL;
}
Here’s the output, first with the mutex commented out, and then with the mutex uncommented.
# mutex commented out
$ mygcc -o mutex mutex.c -lpthread
$ ./mutex
1: I am
1: I am
2: in i
2: in j
$ vi mutex.c
# mutex uncommented
$ mygcc -o mutex mutex.c -lpthread
$ ./mutex
1: I am
2: in i
1: I am
2: in j
$
Deadlocks
Of course, you have to be careful with mutex - you could end up in a deadlock! See the deadlock.c example below.
/*
* deadlock.c: demonstration of deadlock
*
* CS50, Summer 2021
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
/********** global variables *******/
pthread_mutex_t mutex1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutex2 = PTHREAD_MUTEX_INITIALIZER;
/********** function prototype *******/
void* print_i(void *ptr);
void* print_j(void *ptr);
/********** main *******/
int main() {
pthread_t t1, t2;
int iret1 = pthread_create(&t1, NULL, print_i, NULL);
int iret2 = pthread_create(&t2, NULL, print_j, NULL);
if (iret1 || iret2) {
fprintf(stderr,"pthread_create failed: iret1=%d, iret2=%d\n", iret1, iret2);
exit(1);
}
else {
sleep(4);
}
exit(0); //never reached.
}
// These two functions will run concurrently.
void* print_i(void *ptr) {
pthread_mutex_lock(&mutex1);
//sleep(1); // comment and it works!
pthread_mutex_lock(&mutex2);
printf("I am in i\n");
pthread_mutex_unlock(&mutex2);
pthread_mutex_unlock(&mutex1);
return NULL;
}
void* print_j(void *ptr) {
pthread_mutex_lock(&mutex2);
//sleep(1); // comment and it works!
pthread_mutex_lock(&mutex1);
printf("I am in j\n");
pthread_mutex_unlock(&mutex1);
pthread_mutex_unlock(&mutex2);
return NULL;
}
Here’s the output with the sleep(1)
commented out, and then with the sleep(1)
uncommented.
# sleep(1)'s commented out
$ mygcc -o deadlock deadlock.c
$ ./deadlock
I am in i
I am in j
# sleep(1)'s uncommented
$ mygcc -o deadlock deadlock.c
$ ./deadlock
$