CS360 Lecture notes -- Thread #1

  • James S. Plank
  • Directory: /home/plank/cs360/notes/Thread-1-Basics
  • Lecture notes: http://web.eecs.utk.edu/~jplank/plank/classes/cs360/360/notes/Thread-1-Basics/lecture.html
    Last revised: Wed Apr 20 16:00:19 EDT 2022
    Threads programming is a paradigm of programming that is very powerful and natural. In a nutshell, threads let you have multiple processing units, called ``threads'' that cooperate via shared memory.

    Why threads?

    There are many reasons to program with threads. In the context of this class, there are two important ones:
    1. They allow you to deal with asynchronous events synchronously and efficiently.
    2. They allow you to get parallel performance on a shared-memory multiprocessor.
    You'll find threads to be a big help in writing an operating system. Therefore, by learning threads here, you get a leg up on your OS class (that is, if they make you write an operating system....).

    What are threads?

    What are threads? Threads are often called "lightweight processes". Whereas a typical process in Unix consists of CPU state (i.e. registers), memory (code, globals, heap and stack), and OS info (such as open files, a process ID, etc), in a thread system there is a larger entity, called a "task", a "pod", or sometimes a "heavyweight process."

    The task consists of a memory (code, globals, heap), OS info, and threads. Each thread is a unit of execution, which consists of a stack and CPU state (i.e. registers). Multiple threads resemble multiple processes, except that multiple threads within a task use the same code, globals and heap. Thus, while two processes in Unix can only communicate through the operating system (e.g. through files, pipes, or sockets), two threads in a task can communicate through memory.

    When you program with threads, you assume that they execute simultaneously. In other words, it should appear to you as if each thread is executing on its own CPU, and that all the threads share the same memory.

    There are various primitives that a thread system must provide. Let's start with two basic ones. In this initial discussion, I am talking about a generic thread system. We'll talk about specific ones (such as POSIX) later.

    At this point, let me remark on the difference between threads and processes, because I'm sure you're thinking about their similarities:

    Posix threads

    On most Unix machines, there is a thread system that you can use. It is called ``Posix threads.'' To make use of Posix threads in your program, you need to have the following include directive:
    #include <pthread.h>
    
    And you have to link libpthread.a to your object files. (i.e. if your program is in main.c, you need to do the following to make your thread executable):
    UNIX> gcc -c main.c
    UNIX> gcc -o main main.o -lpthread
    
    You can use pthreads with g++ too. There's a lot of junk in the pthread library. You can read about it in the various man pages. Start with ``man pthread''. The two basic primitives defined above are the following in Posix threads:
         int pthread_create(pthread_t *new_thread_ID,
                            const pthread_attr_t *attr,
                            void * (*start_func)(void *), 
                            void *arg);
    
         int pthread_join(pthread_t target_thread, 
                          void **status);
    
    This isn't too bad, and not too far off from my generic description above. Instead of returning a pointer to a thread control block, pthread_create() has you pass the address of one, and it fills it in. Don't worry about the attr argument -- just use NULL. Then func is the function, and arg is the argument to the function, which is a (void *). When pthread_create returns, the TCB is in *new_thread_ID, and the new thread is running func(arg).

    pthread_join() has you specify a thread, and give a pointer to a (void *). When the specified thread exits, the pthread_join() call will return, and *status will be the return or exit value of a thread.

    In all the Posix threads calls, an integer is returned. If zero, everything went ok. Otherwise, an error has occurred. As with system calls, it is always good to check the return values of these calls to see if there has been an error.

    How does a thread exit? By calling return or pthread_exit().

    Ok, so check out the following program (in src/hw.c):

    /* Hello world printed in a new thread. */
    
    #include <pthread.h>
    #include <stdio.h>
    #include <stdlib.h>
    
    /* This is the procedure called by the thread.  I'm ignoring the argument
       (which is specified to be NULL in the pthread_create() calls. */
    
    void *printme()
    {
      printf("Hello world\n");
      return NULL;
    }
    
    int main()
    {
      pthread_t tcb;
      void *status;
    
      /* Create one thread, which prints Hello World. */
    
      if (pthread_create(&tcb, NULL, printme, NULL) != 0) {
        perror("pthread_create");
        exit(1);
      }
      
      /* Wait for that thread to exit, and then exit. */
    
      if (pthread_join(tcb, &status) != 0) { 
        perror("pthread_join"); 
        exit(1); 
      }
    
      return 0;
    }
    

    Try copying hw.c to your home area, compiling it, and running it. It should print out ``Hello world''.

    UNIX> make bin/hw
    gcc -o bin/hw src/hw.c -lpthread
    UNIX> bin/hw
    Hello world
    UNIX> 
    

    Forking multiple threads

    Here's a program, in src/print4.c:

    /* print4.c -- forks off four threads that print their ids */
    
    #include <pthread.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <unistd.h>
    
    /* Printme's argument is a (void *), which we know is actually an 
       (int *), pointing to the integer id of the thread, which is set
        up in main(). */
    
    void *printme(void *ip)
    {
      int *i;
    
      i = (int *) ip;
      printf("Hi.  I'm thread %d\n", *i);
      return NULL;
    }
    
    int main()
    {
      int i, ids[4];
      pthread_t tids[4];
      void *retval;
    
      /* Fork off four "printme" threads, setting the argument to
         be a pointer to the thread's integer id. */
    
      for (i = 0; i < 4; i++) {
        ids[i] = i;
        if (pthread_create(tids+i, NULL, printme, ids+i) != 0) {
          perror("pthread_create");
          exit(0);
        }
      }
    
      /* Join with the four threads. */
    
      for (i = 0; i < 4; i++) {
        printf("Trying to join with thread %d\n", i);
        if (pthread_join(tids[i], &retval) != 0) { perror("join"); exit(1); }
        printf("Joined with thread %d\n", i);
      }
      return 0;
    }
    

    This forks off 4 threads that print out ``Hi. I'm thread n'', where n goes from zero to three. The main thread calls pthread_join() so that it waits for all four threads to exit before it exits. This should give you a good idea of how multiple threads can co-exist in the same process.

    Here's the output of a call to print4.c:

    UNIX> make bin/print4
    gcc -o bin/print4 src/print4.c -lpthread
    UNIX> bin/print4
    Trying to join with tid 0
    Hi.  I'm thread 0
    Hi.  I'm thread 1
    Hi.  I'm thread 2
    Hi.  I'm thread 3
    Joined with tid 0
    Trying to join with tid 1
    Joined with tid 1
    Trying to join with tid 2
    Joined with tid 2
    Trying to join with tid 3
    Joined with tid 3
    UNIX> 
    
    Here's what happened: The main() program got control after forking off the four threads. It called pthread_join() for thread zero and blocked. Then thread zero got control, printed its line, and exited. Next came threads one, two and three. When they finished, the main() thread got control again and since thread zero was done, its pthread_join() call returned. Then it made the pthread_join() calls for threads one, two and three, all of which returned since these threads were done. When main() returned, all the threads are done, and the program exited.

    Now, that's not the only possible output of the program. In particular, here are three more runs of the program, which all have different outputs:

    UNIX> bin/print4                 # Run 1
    Hi.  I'm thread 0
    Hi.  I'm thread 1
    Hi.  I'm thread 2
    Trying to join with tid 0
    Joined with tid 0
    Trying to join with tid 1
    Joined with tid 1
    Trying to join with tid 2
    Joined with tid 2
    Trying to join with tid 3
    Hi.  I'm thread 3
    Joined with tid 3
    UNIX> bin/print4                 # Run 2
    Hi.  I'm thread 0
    Hi.  I'm thread 2
    Trying to join with tid 0
    Joined with tid 0
    Trying to join with tid 1
    Hi.  I'm thread 1
    Joined with tid 1
    Trying to join with tid 2
    Joined with tid 2
    Trying to join with tid 3
    Hi.  I'm thread 3
    Joined with tid 3
    UNIX> bin/print4                 # Run 3
    Hi.  I'm thread 2
    Hi.  I'm thread 3
    Hi.  I'm thread 0
    Hi.  I'm thread 1
    Trying to join with tid 0
    Joined with tid 0
    Trying to join with tid 1
    Joined with tid 1
    Trying to join with tid 2
    Joined with tid 2
    Trying to join with tid 3
    Joined with tid 3
    UNIX> 
    
    You'll note that these are quite different. Each of them is the result of the threads getting scheduled in different orders. Let's think about this more. In particular, think about what's going on after the main thread calls pthread_create() for the first time. At that point, there are two threads -- the main thread and thread 0. Either of them can run, and it's up to the operating system to select one. If the operating system is managing two processors, it may choose to run each thread simultaneously on a different processor.

    If thread 0 gets control first, you'll see the output "Hi. I'm thread 0" first. If the main thread gets control first, then it will call pthread_create() for thread 1, and you'll have three threads that can all run. For each output above, you can derive an ordering of the threads that generates the output. For example in that last output, the main thread creates thread 2 before the first line of output is printed, and that line is printed by thread 2.

    This is simultaneously what makes threaded programs great and difficult. They are great because they allow multiple threads to run at the same time (either on different processors, or on one processor, scheduled by the operating system). They are difficult for the same reason. One of the challenges of this type of programming is allowing for the threads to do as much as they can simultaneously without getting yourself in trouble.


    exit() vs pthread_exit()

    In pthreads there are two things you should know about thread/program termination. The first is that pthread_exit() makes a thread exit, but keeps the task alive, while exit() terminates the task. If all threads (and the main() program should be considered a thread) have terminated, then the task terminates. So, look at src/p4a.c.

    This is the exact same as src/print4.c, except all threads, including the main() thread, exit with pthread_exit(). You'll see that the output is the same as print4.

    Now, look at src/p4b.c. Here, we put a pthread_exit() call in main() before making the join calls. The output is:

    Hi.  I'm thread 0
    Hi.  I'm thread 1
    Hi.  I'm thread 2
    Hi.  I'm thread 3
    
    You'll note that none of the "Joining" lines were printed out because the main thread had exited. However, the other threads ran just fine, and the program terminated when all the threads had exited.

    The second thing you need to know is that when a forked thread returns from its initial calling procedure (e.g. printme in print4.c), then that is the same as calling pthread_exit(). However, if the main() thread returns, then that is the same as calling exit(), and the task dies. Take a look at src/p4c.c. This is the same as src/print4.c, except for two changes:

    1. The threads sleep for a second beefore printing.
    2. The main thread doesn't call pthread_join(), and instead returns from main() after forking the threads.
    When we run it, we get no output:
    UNIX> bin/p4c
    UNIX> 
    
    The reason for this is that when the main thread returns, the other four threads have been created, and are either sleeping, or have not executed yet. When the main thread returns, the task is terminated, killing the threads. Thus, they do not print anything.

    If you change the return statement in the main() routine to pthread_exit() (as in src/p4a.c), then it does not kill the process, and the four threads run to completion.

    Finally, look at src/p4d.c. Here, the threads call exit() instead of pthread_exit(). You'll note that the output is:

    UNIX> bin/p4d
    Hi.  I'm thread 0
    Trying to join with thread 0
    UNIX> 
    
    This is because the task is terminated by thread 0's exit() call.

    Preemption versus non-preemption

    Now, take a look at src/iloop.c. Here, four threads are forked off, and then the main() thread goes into an infinite loop. When you execute it, what do you think you'll see? I can think of two answers. One is that you'll see nothing -- the main() thread spins forever, and the other threads don't run. The second answer is that the main() thread will run, but the other threads will also get the CPU at some point and run to completion.

    The underlying issue here is called preemption. If your thread system is preemptive, then although the main thread gets most of the CPU, the thread system interrupts it at certain points (i.e. it preempts the main() thread), and runs the other threads.

    POSIX thread systems under the Solaris operating system (pre-Linux) used to be non-preemptive. Under Linux, they are preemptive. So, in our labs (which are Linux boxes), bin/iloop runs as follows:

    UNIX> bin/iloop
    Hi.  I'm thread 0
    Hi.  I'm thread 1
    Hi.  I'm thread 2
    Hi.  I'm thread 3
    

    Most machines these days (even including the Pi) have multiple CPU's attached to a single memory. These systems are by nature preemptive, since different threads will actually execute on different CPU's. However, whether or not a thread system is preemptive is an attribute that you must discern when you are programming for a thread system.

    A non-preemptive thread system on a system with a single CPU (called a "uniprocessor") may seem useless, but in actuality it is extremely useful.


    Pthread_detach()

    Threads consume resources. In particular, each thread executes on its own stack, which requires memory. When a thread exits, the thread system has to decide whether to release its resources or not. It makes that decision in one of two ways:

    1. If another thread calls pthread_join() on the thread, then upon completion of the pthread_join() call, the thread's resources are released.

    2. If any thread (typically the thread itself) calls pthread_detach() on the thread, then no pthread_join() call is required. The thread's resources will be released instantly when the thread exits. More often than not, it is what you want.

    To call pthread_detach() on yourself, you call

    pthread_detach(pthread_self());
    


    My first race condition

    Since all of the threads exist in the same process, there's no limitation on what memory any thread may access. If a memory address is legal for one thread, it's legal for all of them, and if it is illegal of one thread, then it's illegal for all of them.

    I'm sure you didn't think about it, but think about the pointer passed to the threads in the print4 program. That pointer points to memory on the main thread's stack, which means that every thread is reading from the main thread's stack.

    I was careful in that program to give each thread its own pointer as an argument. Here was the code that set the pointer and called pthread_create():

        ids[i] = i;
        if (pthread_create(tids+i, NULL, printme, ids+i) != 0) {
    

    Suppose instead, I simply pass each thread a pointer to i. At a first glance, that seems easier, doesn't it? The code in src/p4e.c is identical to src/p4b.c, except it passes a pointer to i instead of a pointer to the id array. The main thread simply calls pthread_exit() when it's done creating the threads:

      for (i = 0; i < 4; i++) {
        if (pthread_create(tids+i, NULL, printme, &i) != 0) {   // Here's the change.
          perror("pthread_create");
          exit(0);
        }
      }
    
      pthread_exit(NULL);
    }
    

    When we run it, you'll see that all the threads think they are thread 4:

    UNIX> bin/p4e
    Hi.  I'm thread 4
    Hi.  I'm thread 4
    Hi.  I'm thread 4
    Hi.  I'm thread 4
    UNIX> 
    
    Why does this happen? Well, by the time the four threads run, the main thread has already finished its for loop, and i's value is 4. Since they are all pointing to i,

    Is this the only output that can occur? No, but it's the most likely. Read the next lecture on race conditions, and ask yourself what other outputs can happen?