© The Author(s), under exclusive license to APress Media, LLC, part of Springer Nature 2022
M. KalinModern C Up and Runninghttps://doi.org/10.1007/978-1-4842-8676-0_7

7. Concurrency and Parallelism

Martin Kalin1  
(1)
Chicago, IL, USA
 

7.1 Overview

A concurrent program handles more than one task at a time. A familiar example is a web server that handles multiple client requests at the same time. Although concurrent programs can run even on a single-processor machine of bygone days, these programs should show a marked gain in performance by running on a multiprocessor machine: different tasks can be delegated to different processors. A parallel program in this sense is a concurrent program whose tasks can be handled literally at the same time because multiple processors are at hand.

The two traditional and still relevant approaches to concurrency are multiprocessing and multithreading . Applications such as web servers and database systems may mix the approaches and throw in acceleration techniques such as nonblocking I/O. Multiprocessing has a relatively long history and is still widespread. For example, early web servers supported concurrency through multiprocessing; but even state-of-the-art web servers such as Nginx are multiprocessing systems.

Recall that a process is a program in execution and that each process has its own address space. Two processes could share a memory location, but this requires setup: shared memory is not the default. Separate address spaces are appealing to the programmer, who does need to worry about memory-based race conditions when writing a multiprocessing program. A typical race condition arises when two or more operations, at least one of which is a write, could access the same memory location at the same time. Of interest now is that separate processes, by default, do not share access to a memory location, which is requisite for such a race condition.

What is the downside of multiprocessing? When the operating system preempts a not-yet-finished process , a process-level context switch occurs: the operating system gives the processor to another process for its execution. The preempted process must be scheduled again to complete its execution. A process-level context switch is expensive because the operating system may have to swap data structures such as page tables (virtual-to-physical address translators) between memory and disk; in any case, there is nontrivial bookkeeping to track the state of both the preempted and the newly executing process. It is hard to come up with an exact figure, but a process-level context switch takes about 5ms to 15ms (milliseconds), time that is not available for other tasks.

Recall too that a thread (short for thread of execution) is a sequence of executable instructions. Every process has at least one thread; a process with only one thread is single threaded , and a process with more than one thread is multithreaded . Operating systems schedule threads to processors; to schedule a process is, in effect, to schedule one of its threads. On a multiprocessor machine, multiple threads from the same process can execute at the very same time. A thread-level context switch —preempting one thread in a process for another in the same process—is not free, but the cost is very low: nanoseconds rather than milliseconds. Multithreading is efficient.

In a simplifying move, Linux systems turn process scheduling into thread scheduling by treating even a multithreaded process as if it were single threaded. A multithreaded process with N threads then requires N scheduling actions to cover the threads. Threads within a multithreaded process remain related in that they share resources such as memory address space . Accordingly, Linux threads are sometimes described as lightweight processes, with the lightweight underscoring the sharing of resources among the threads within a process.

What is the downside of multithreading? Threads within a process have the same address space; hence, multithreaded programs are susceptible to memory-based race conditions . On a multiprocessor machine, for instance, one thread might try to read memory location N at the very instant that another thread is trying to write N. The outcome is indeterminate. The burden of preventing race conditions falls on the programmer, not the operating system. Multithreaded programs, especially ones with variables shared among the threads, are a challenge even for the experienced programmer.

7.2 Multiprocessing Through Process Forking

The standard library functions provide options for multiprocessing , but the fork function is the most explicit. The first code example covers the basics of a fork call using unnamed pipes ; an earlier example (recall Listings 5-8 and 5-9) covered named pipes. A look at unnamed pipes from the command line serves as preparation.

At the command line, the vertical bar | represents an unnamed pipe: to the left is the pipe writer and to the right is the pipe reader . Each is a process. Here is a contrived example using the sleep and echo utilities available on Unix-like systems and through Cygwin:
% sleep 5 | echo "Hello, world!"

The greeting Hello, world! appears on the screen; then, after about five seconds, the command-line prompt returns, signaling that both the sleep and echo processes have exited. The pipe is closed automatically when the reader and writer terminate. There is multiprocessing here, but it does no useful work; instead, the example shows how the unnamed pipe works.

In normal usage, the writer process on the left writes bytes to the pipe, and the reader process on the right blocks until there are bytes to read. By closing the write end of a pipe before exiting, the writer process thereby generates an end-of-stream condition. The reader process closes the read end before exiting as well. Once the reader and the writer process exit, the pipe shuts down.

The preceding example is contrived because the sleep process does not write any bytes to the pipe and the echo process does not read any bytes from the pipe. Nonetheless, there is multiprocessing. The sleep process on the left does just that, and for five seconds. In the meanwhile, the echo process immediately writes its greeting to the screen because this process need not wait for bytes from the pipe. The echo process exits after printing its message. The sleep process then exits, the pipe goes away, and the command-line prompt reappears.

The first code example focuses on the basics of fork. The second example then uses the pipe library function in a multiprocessing example with an unnamed pipe .
#include <sys/types.h> /* just in case... */
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void main() {
  signal(SIGCHLD, SIG_IGN);    /* prevents zombie */
  int n = 777;                 /* both parent and child have a copy */
  pid_t pid = fork();
  if (-1 == pid) {             /* -1 signals an error */
    perror(NULL);
    exit(-1);
  }
  if (0 == pid) {           /** child **/
    n = n + 10;
    printf("%i ", n);      /** 787 ***/
  }
  else {                    /** parent **/
    n = n * 10;
    printf("%i ", n);      /** 7770 */
  }
}
Listing 7-1

Introducing the fork function

The basicFork program (see Listing 7-1) opens with a call to the signal function . This is a precaution to prevent zombie processes , as clarified in an upcoming section. The int variable n is declared and initialized to 777. If the subsequent call to the library function fork succeeds, both the child and the parent process get their own separate copy of variable n; hence, each process manages different variables with the same name.

The library function fork tries to create a new process. If the attempt succeeds, the newly created process becomes the child of the original process, which is now a parent. The fork function returns an integer value ; for portability, the recommended type is pid_t, where pid stands for process identifier. The tricky part of the fork call is that, if successful, it returns one value to the parent—but a different value to the child. A short digression into the process id explains.

Every process has a nonnegative integer value as its identifier (pid) . There is a library function getpid to retrieve the pid, and a related function getppid to retrieve the parent process identifier (ppid) . Every process except the first has a ppid, which is guaranteed to be the same as the parent’s pid.

If the fork call fails to spawn a child process , it returns -1 to signal the error. If fork succeeds, it returns
  • 0 to the child

  • The child’s pid to the parent

Once forked, the child process executes a copy of the very same code as the parent—the code that comes after the call to fork. Accordingly, a test is typically used (in this case, the if test) to distinguish between code intended for the child and code intended for the parent. In this example, the child executes the if block, printing 787; the parent executes the else block, printing 7770. The order in which the prints occur is indeterminate. If the program runs on a multiprocessor machine, this concurrent program can execute in a truly parallel fashion.

The second code example uses an unnamed pipe for interprocess communication. The parent again calls fork to spawn a child process, and the two processes then communicate through the pipe: the parent as the writer process and the child as the reader process. The discussion also explains zombie processes and how to reap them.
#include <sys/wait.h> /* wait */
#include <stdio.h>
#include <stdlib.h>   /* exit functions */
#include <unistd.h>   /* read, write, pipe */
#include <string.h>
#define ReadEnd  0
#define WriteEnd 1
void report_and_die() {
   perror(NULL);
   exit(-1);    /** failure **/
}
void main() {
  int pipeFDs[2]; /* two file descriptors */
  char buf;       /* 1-byte buffer */
  const char* msg = "This is the winter of our discontent "; /* bytes to write */
  if (pipe(pipeFDs) < 0) report_and_die();
  pid_t cpid = fork();              /* fork a child process */
  if (cpid < 0) report_and_die();   /* check for failure */
  if (0 == cpid) {    /*** child ***/     /* child process */
    close(pipeFDs[WriteEnd]);             /* child reads, doesn't write */
    while (read(pipeFDs[ReadEnd], &buf, 1) > 0)   /* read until end of byte stream */
      write(STDOUT_FILENO, &buf, sizeof(buf));    /* echo to the standard output */
    close(pipeFDs[ReadEnd]);   /* close the ReadEnd: all done */
    _exit(0);                  /* exit fast */
  }
  else {              /*** parent ***/
    close(pipeFDs[ReadEnd]);  /* parent writes, doesn't read */
    write(pipeFDs[WriteEnd], msg, strlen(msg));   /* write the bytes to the pipe */
    close(pipeFDs[WriteEnd]);     /* done writing: generate eof */
    wait(NULL);                   /* wait for child to exit */
    exit(0);                      /* exit normally */
  }
}
Listing 7-2

The basics of the fork function

The pipeUN program (see Listing 7-2) uses the fork function for multiprocessing and the pipe function for creating an unnamed pipe so that the processes can communicate. To begin, here is an overview of the library function pipe:
  • The pipe function takes an int array of two elements as its single argument: the first element (index 0) is the file descriptor for read operations, and the second element (index 1) is the file descriptor for write operations.

  • The function returns -1 to signal failure and 0 to signal success.

  • Note that the pipe function creates an unnamed pipe, whereas the mkfifo function creates a named pipe.

The fork function is used to create the reader process , although this spawned process could have been the writer. The process that does the forking is the parent, and the forked process is the child. The child process, an almost exact duplicate of the parent, is said to inherit from the parent. For example, a forked child process inherits open file descriptors from the parent. Recall that once forked, the child process executes the very same code as the parent process, unless an if test or the equivalent is used to divide the code that each process executes. A closer look at the example clarifies.

Here, for quick review and with added detail, are the values that the fork function can return:
  • A returned value of -1 indicates an error: the fork failed to spawn a child process. This could occur for various reasons, including a full process table. The process table is a data structure that the operating system maintains in tracking processes.

  • If the fork call succeeds, it returns different values to the child and the parent processes:
    • 0 is returned to the child.

    • The child’s process identifier (pid) is returned to the parent.

The pipeUN program uses an if else construct to distinguish between the parent and the child. Keep in mind that both processes execute this test:
if (0 == cpid) {    /*** child ***/

The else clause is thus for the parent to execute. Because the child process is the reader, it immediately closes the WriteEnd of the pipe; in a similar fashion, the parent process as the writer immediately closes the ReadEnd of the pipe. Both file descriptors are open because of the call to pipe. By closing one end of the pipe, each process exhibits the separation-of-concerns pattern .

The writer process then writes bytes to the pipe, and the reader process reads these bytes one at a time. When the writer process closes the pipe’s write end, an end-of-stream marker is sent to the reader, which responds by closing the pipe’s read end. At this point, the pipe closes down.

7.2.1 Safeguarding Against Zombie Processes

In the pipeUN program , the parent process writes a full string to the pipe and then waits for the child process to terminate with the call to library function wait ; the child reads the string byte by byte. The wait call is a precaution against creating a permanent zombie process: a zombie is a process that has terminated but which still has an entry in the process table. If zombies are not reaped from the process table, this table can fill—and thus prevent the forking of any other process. Although a forked child is largely independent of its parent process, the operating system does notify the parent when the child terminates. If a child terminates after its parent, and there is no safeguard against zombies, the child can remain a zombie.

In the pipeUN example, it is unpredictable whether the parent or the child will terminate first, and so the parent—the process being notified—makes the precautionary call to wait : if the child has already exited, the call has no effect; otherwise, the parent’s execution is suspended until the child terminates. The wait function expects one argument, the address of an int variable that stores the exit code of the process being waited on. In this example, the argument of NULL is used to keep things simple, but a parent process in general might implement different logic depending on the status code of a terminated child. There is also a waitpid function of three arguments, which allows for more granular control. The waitpid function is used in a forthcoming example.

The pipeUN program adopts another safeguard. The child calls library function _exit rather than exit: the former fast-tracks parent notification and so speeds up the reaping of a zombie entry. The parent process, by contrast, calls the regular exit function.

There are different ways to safeguard against zombies. The pipeUN program uses the wait approach to illustrate how independently executing processes still can be coordinated. A simpler approach, used in the basicFork program, is to make this call to signal at the start of the program:
signal(SIGCHLD, SIG_IGN); /* ignore signal about a child's termination */

The effect of this call is to automate the reaping of a zombie. Were this approach taken in the current example, the parent’s call to wait would not be needed to safeguard against a zombie.

7.3 The exec Family of Functions

In the forking of a child process, the multiprocessing is obvious in that the parent process, which calls fork, continues to execute as well; indeed, the parent and the child execute the same code unless program logic explicitly controls which process executes which code. The typical approach, illustrated in the code examples so far, is to use an if-test to separate the code intended for the parent from the code intended for the child.

The functions in the exec family, mentioned several times already but not yet analyzed, work differently. All of the functions in the family do essentially the same thing, but their argument formats differ. For example, the execv function has an argument vector, implemented as a NULL-terminated array of strings. Other members of the family such as execle use an environment variable to pass information to the executing program. The next code example goes into the details.

WHAT’S A PROCESS IMAGE?

Recall that a process is a program in execution, something dynamic. The executable program is stored somewhere, typically as a file on a local disk. To execute the program, the operating system first must load the file into memory. This in-memory representation of the process, read-only during process execution, is the process image.

#include <sys/types.h>  /* for safety: maybe there's no unistd.h */
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
  pid_t pid = fork();      /* try to create a child process */
  if (-1 == pid) {         /* did the fork() work? */
    perror("fork()");      /* if not, error message and exit */
    exit(-1);
  }
  if (!pid) {              /* fork() returns 0 to the child */
    char* const args[ ] =
      {"./cline", "foo", "bar", "123", NULL}; /* some cmd-line args: NULL to terminate */
    int ret = execv("./cline", args);   /* "v" for "vector" */
    if (-1 == ret) {                    /* check for failure */
      perror("execv(...)");
      exit(-1);
    }
    else
      printf("This should not print! ");     /* never executes */
  }
  return 0;
}
Listing 7-3

The exec family of functions

The execing program (see Listing 7-3) forks a child process, which then calls execv to execute the cline program (recall Listing 1-7). Each function in the exec family does the following:
  • Replaces the image of the process that calls an exec function with a new process image. This is described as overlaying one process image with another.

  • The new process, in this case cline , runs with the same pid as the original process, in this case execing.

The cline program expects command-line arguments, which are supplied in a NULL-terminated array of strings; the cline program simply prints the arguments to the standard output and then exits.

In the execing program , the call to fork follows the usual pattern except that parent process has nothing left to do if the fork succeeds; the parent terminates by returning from main. By contrast, the child process invokes execv with two arguments:
  • The first argument is the path to the executable as a string, in this case ".cline".

  • The second argument is an array of strings, including (by tradition) the name of the executable as the first element in this array. A NULL marks the end of the string array.

The execv function returns -1 to signal an error—and otherwise does not return. Instead, the overlayed process image is used to execute the overlay program, in this case cline. Accordingly, the last printf statement in the execing program
printf("This should not print! ");

does not execute. Only the newly executed cline program runs to completion: the process image for the forked child indeed has been overlaid.

There is a short experiment that can confirm the overlay in the execing program :
  • Immediately after the successful fork of the child process, print the child’s pid value, which can be obtained with a call within the if block to the getpid function .

  • Amend the cline program to print its own pid, again using the library function getpid.

The two printed pid values should be the same, thereby confirming that the execed program cline is executing under the forked child’s pid. The code available on GitHub includes this experiment.

7.3.1 Process Id and Exit Status

The next program reviews the forking API, in particular the pid and ppid values for a child process, but also focuses on the information available about how a child process terminates. The exit status of a forked process is available, with convenient macros for extracting this status information. These macros belong to C’s waiting API, whose principal functions are wait (one argument for ease of use) and waitpid (three or four arguments for fine-grained control). The example introduces the waitpid function .

In production-grade multiprocessing programs , logic likely depends on the state of the constituent processes, including information about how a given process terminates. For example, a multiprocessing web server such as Nginx needs to track whether the master process and the worker processes (request handlers) are still alive and, if not, the exit status of a terminated process. The multiprocessing examples so far have ignored the exit status of a child process. The forthcoming exiting example focuses on the child’s exit status and how the parent can get this status.
#include <unistd.h>     /* symbolic constants */
#include <stdio.h>      /* printf, etc. */
#include <sys/wait.h>   /* waiting on process termination */
#include <stdlib.h>     /* utilities */
void main() {
  int status;         /* parent captures child's status here */
  int cret = 0xaa11bb22;  /* child returns this value */
  pid_t cpid = fork();    /* spawn the child process */
  if (0 == cpid) {        /* fork() returns 0 to the child */
    printf("Child's pid and ppid: %i  %i ", getpid(), getppid()); /* 2614 2613 */
    printf("Child returns %x explicitly. ", cret);
    _exit(cret);               /* return an arbitrary value */
  }
  else { /* fork() returns new pid to the parent process */
    printf("Parent's pid: %i ", getpid());   /* 2613 */
    printf("Waiting for child to exit ");
    if (-1 != waitpid(cpid, &status, 0)) { /* wait for child to exit, store its status */
      if (WIFEXITED(status))
      printf("Normal exit with %x ", WEXITSTATUS(status)); /** 22 **/
      else if (WIFSIGNALED(status))
        printf("Signaled with %x ", WTERMSIG(status));
      else if (WIFSTOPPED(status))
        printf("Stopped with %x ", WSTOPSIG(status)); /* stop pauses the process */
      else
      puts("peculiar...");
    }
    exit(0); /* parent exits with normal termination */
  }
}
Listing 7-4

Exit status

In the exiting program (see Listing 7-4), one process forks another in the by-now-familiar way. The parent waits for the child with a call to waitpid , which expects three arguments:
  • The first argument is the pid of the process on which to wait, in this case the child.

  • The second argument points to an int variable where the child’s exit or comparable status is stored.

  • The last argument consists of additional options, for instance, WNOHANG for return at once if no child has exited.

The wait(NULL) call used earlier is shorthand for
waitpid(-1, NULL, NULL);

The first argument to waitpid (-1) means, in effect, any child of mine; the second argument is NULL instead of a pointer to an int variable to store the child’s exit status; and the third argument is NULL for no flags.

For the child process , there are various possibilities that a waiter such as the parent needs to consider. Three of these possibilities are considered in the exiting program :
  • The child exits normally, with a nonnegative return value.

  • The child receives a signal such as SIGKILL (terminate immediately), which cannot be ignored, or SIGTERM (please terminate immediately), which can be ignored.

  • The child receives a SIGSTOP (stop executing: pause) signal, which cannot be ignored.

In this example, the child exits normally with a call to _exit. The WEXITSTATUS macro returns the low-order 8 bits of the child’s 32-bit explicitly returned value, 0xaa11bb22 in hex. The macro thus extracts 22.

The exiting program also confirms that a child’s ppid is the same as its parent’s pid. In a sample run, this value was 2613, and the child’s pid was 2614. These values are not guaranteed to be consecutive, but it is a common pattern: the child’s pid is one greater than the parent’s.

7.4 Interprocess Communication Through Shared Memory

Although every process has its own address space, which ensures that processes do not share memory locations by default, processes can share memory. A standard library provides the appropriate functions. Shared memory is, like pipes, a mechanism for interprocess communication. A code example with two processes explores the details.

There are two separate libraries and APIs for shared memory: the legacy System V library and API, and the more recent POSIX pair. These APIs should never be mixed in a single application, however. The POSIX pair is still in development and dependent upon the version of the operating system kernel, which impacts code portability. By default, the POSIX API implements shared memory as a memory-mapped file : for a shared memory segment, the system maintains a backing file with corresponding contents. Shared memory under POSIX can be configured without a backing file, but this may impact portability. My example uses the POSIX API with a backing file, which combines the benefits of memory access (speed) and file storage (persistence).

The shared memory example has two programs, named memwriter and memreader , and uses a semaphore to coordinate their access to the shared memory. Whenever shared memory comes into the picture with a writer, so does the risk of a memory-based race condition with indeterminate results; hence, the semaphore is used to coordinate (synchronize) access to the shared memory so that the writer and the reader operations do not overlap.

The memwriter program , which creates the shared memory segment, should be started first in its own terminal. The memreader program then can be started (within a dozen seconds) in its own terminal. The output from the memreader is
This is the way the world ends...

Here is a review of how semaphores work as a synchronization mechanism. A general semaphore also is called a counting semaphore, as it has a value (typically initialized to zero) that can be incremented. Consider a shop that rents bicycles, with a hundred of them in stock, with a program that clerks use to do the rentals. Every time a bike is rented, the semaphore is incremented by one; when a bike is returned, the semaphore is decremented by one. Rentals can continue until the value hits 100 but then must halt until at least one bike is returned, thereby decrementing the semaphore to 99.

A binary semaphore is a special case requiring only two values, which are traditionally 0 and 1. In this situation, a semaphore acts as a mutex : a mutual exclusion construct. The shared memory example uses a semaphore as a mutex . When the semaphore’s value is 0, the memwriter alone can access the shared memory. After writing, this process increments the semaphore’s value, thereby allowing the memreader to read the shared memory.
/** Compilation: gcc -o memwriter memwriter.c -lrt -lpthread **/
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <semaphore.h>
#include <string.h>
#include "shmem.h"
void report_and_exit(const char* msg) {
  perror(msg);
  exit(-1);
}
int main() {
  int fd = shm_open(BackingFile,      /* name from smem.h */
                    O_RDWR | O_CREAT, /* read/write, create if needed */
                    AccessPerms);     /* access permissions (0644) */
  if (fd < 0) report_and_exit("Can't open shared mem segment...");
  ftruncate(fd, ByteSize); /* get the bytes */
  caddr_t memptr = mmap(NULL,       /* let system pick where to put segment */
                        ByteSize,   /* how many bytes */
                        PROT_READ | PROT_WRITE, /* access protections */
                        MAP_SHARED, /* mapping visible to other processes */
                        fd,         /* file descriptor */
                        0);         /* offset: start at 1st byte */
  if ((caddr_t) -1  == memptr) report_and_exit("Can't get segment...");
  fprintf(stderr, "shared mem address: %p [0..%d] ", memptr, ByteSize - 1);
  fprintf(stderr, "backing file:       /dev/shm%s ", BackingFile );
  /* semaphore code to lock the shared mem */
  sem_t* semptr = sem_open(SemaphoreName, /* name */
                           O_CREAT,       /* create the semaphore */
                           AccessPerms,   /* protection perms */
                           0);            /* initial value */
  if (semptr == (void*) -1) report_and_exit("sem_open");
  strcpy(memptr, MemContents); /* copy some ASCII bytes to the segment */
  /* increment the semaphore so that memreader can read */
  if (sem_post(semptr) < 0) report_and_exit("sem_post");
  sleep(12); /* give reader a chance */
  /* clean up */
  munmap(memptr, ByteSize); /* unmap the storage */
  close(fd);
  sem_close(semptr);
  shm_unlink(BackingFile); /* unlink from the backing file */
  return 0;
}
Listing 7-5

The memwriter program

The memwriter and memreader programs communicate through shared memory as follows. The memwriter program (see Listing 7-5) calls the shm_open library function to get a file descriptor for the backing file that the system coordinates with the shared memory. At this point, no memory has been allocated. The subsequent call to the misleadingly named function ftruncate
ftruncate(fd, ByteSize); /* get the bytes */

allocates ByteSize bytes , in this case, a modest 512 bytes. The memwriter and memreader programs access the shared memory only, not the backing file. The system is responsible for synchronizing the shared memory and the backing file.

The memwriter then calls the mmap library function
caddr_t memptr = mmap(NULL,       /* let system pick where to put segment */
                      ByteSize,   /* how many bytes */
                      PROT_READ | PROT_WRITE, /* access protections */
                      MAP_SHARED, /* mapping visible to other processes */
                      fd,         /* file descriptor */
                      0);         /* offset: start at 1st byte */

to get a pointer to the shared memory. (The memreader makes a similar call.) The pointer type caddr_t starts with a c for calloc, which initializes dynamically allocated storage to zeros. The memwriter uses the memptr for the later write operation, which uses the library strcpy function . At this point, the memwriter is ready for writing, but it first creates a semaphore to ensure exclusive access to the shared memory.

If the call to sem_open for the semaphore’s creation succeeds
sem_t* semptr = sem_open(SemaphoreName, /* name */
                         O_CREAT,       /* create the semaphore */
                         AccessPerms,   /* protection perms */
                         0);            /* initial value */

then the writing can proceed. The SemaphoreName (any unique nonempty name will do) identifies the semaphore in both the memwriter and the memreader . The initial value of zero gives the semaphore’s creator (in this case, the memwriter) the right to proceed (in this case, to the write operation).

After writing, the memwriter increments the semaphore value to 1:
if (sem_post(semptr) < 0)
with a call to the sem_post library function . Incrementing the semaphore releases the mutex lock and enables the memreader to perform its read operation. For good measure, the memwriter also unmaps the shared memory from the memwriter address space :
munmap(memptr, ByteSize); /* unmap the storage *
This bars the memwriter from further access to the shared memory.
/** Compilation: gcc -o memreader memreader.c -lrt -lpthread **/
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <semaphore.h>
#include <string.h>
#include "shmem.h"
void report_and_exit(const char* msg) {
  perror(msg);
  exit(-1);
}
int main() {
  int fd = shm_open(BackingFile, O_RDWR, AccessPerms);  /* empty to begin */
  if (fd < 0) report_and_exit("Can't get file descriptor...");
  /* get a pointer to memory */
  caddr_t memptr = mmap(NULL,       /* let system pick where to put segment */
                        ByteSize,   /* how many bytes */
                        PROT_READ | PROT_WRITE, /* access protections */
                        MAP_SHARED, /* mapping visible to other processes */
                        fd,         /* file descriptor */
                        0);         /* offset: start at 1st byte */
  if ((caddr_t) -1 == memptr) report_and_exit("Can't access segment...");
  /* create a semaphore for mutual exclusion */
  sem_t* semptr = sem_open(SemaphoreName, /* name */
                           O_CREAT,       /* create the semaphore */
                           AccessPerms,   /* protection perms */
                           0);            /* initial value */
  if (semptr == (void*) -1) report_and_exit("sem_open");
  /* use semaphore as a mutex (lock) by waiting for writer to increment it */
  if (!sem_wait(semptr)) { /* wait until semaphore != 0 */
    int i;
    for (i = 0; i < strlen(MemContents); i++)
      write(STDOUT_FILENO, memptr + i, 1); /* one byte at a time */
    sem_post(semptr);
  }
  /* cleanup */
  munmap(memptr, ByteSize);
  close(fd);
  sem_close(semptr);
  unlink(BackingFile);
  return 0;
}
Listing 7-6

The memreader program

In both the memwriter and memreader (see Listing 7-6) programs, the shared memory functions of primary interest are shm_open and mmap : on success, the first call returns a file descriptor for the backing file, which the second call then uses to get a pointer to the shared memory segment. The calls to shm_open are similar in the two programs except that the memwriter program creates the shared memory, whereas the memreader only accesses this already allocated memory:
int fd = shm_open(BackingFile, O_RDWR | O_CREAT, AccessPerms); /* memwriter */
int fd = shm_open(BackingFile, O_RDWR, AccessPerms);           /* memreader */
With a file descriptor in hand, the calls to mmap are the same:
caddr_t memptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

The first argument to mmap is NULL, which means that the system determines where to allocate the memory in virtual address space . It is possible (but tricky) to specify an address instead. The MAP_SHARED flag indicates that the allocated memory is shareable among processes, and the last argument (in this case, zero) means that the offset for the shared memory should be the first byte. The size argument specifies the number of bytes to be allocated (in this case, 512), and the protection argument indicates that the shared memory can be written and read.

When the memwriter program executes successfully, the system creates and maintains the backing file; on my system, the file is /dev/shm/shMemEx, with shMemEx as my name (given in the header file shmem.h) for the shared storage. In the current version of the memwriter and memreader programs, the statement
shm_unlink(BackingFile); /* removes backing file */

removes the backing file. If the unlink statement is omitted, then the backing file persists after the program terminates.

The memreader, like the memwriter, accesses the semaphore through its name in a call to sem_open. But the memreader then goes into a wait state until the memwriter increments the semaphore , whose initial value is 0:
if (!sem_wait(semptr)) { /* wait until semaphore != 0 */

Once the wait is over, the memreader reads the ASCII bytes from the shared memory, cleans up, and terminates.

The shared memory API includes operations explicitly to synchronize the shared memory segment and the backing file. These operations have been omitted from the example to reduce clutter and keep the focus on the memory-sharing and semaphore code.

The memwriter and memreader programs are likely to execute without inducing a race condition even if the semaphore code is removed: the memwriter creates the shared memory segment and writes immediately to it; the memreader cannot even access the shared memory until this has been created. However, best practice requires that shared memory access is synchronized whenever a write operation is in the mix, and the semaphore API is important enough to be highlighted in a code example.

7.5 Interprocess Communication Through File Locking

Programmers are all too familiar with file access, including the many pitfalls (nonexistent files, bad file permissions, and so on) that beset the use of files in programs. Nonetheless, shared files may be the most basic mechanism for interprocess communication. Consider the relatively simple case in which one process (producer) creates and writes to a file and another process (consumer) reads from this same file:
         writes  +-----------+  reads
producer-------->| disk file |<-------consumer
                 +-----------+
The obvious challenge in using a shared file is that a race condition might arise: the producer and the consumer might access the file at exactly the same time, thereby making the outcome indeterminate. To avoid a race condition , the file must be locked in a way that prevents a conflict between a write operation and any another operation, whether a read or a write. The locking API in the standard system library can be summarized as follows:
  • A producer should gain an exclusive lock on the file before writing to the file. An exclusive lock can be held by one process at most, which rules out a race condition because no other process can access the file until the lock is released. (It is possible to lock only part of a file.)

  • A consumer should gain at least a shared lock on the file before reading from the file. Multiple readers can hold a shared lock at the same time, but no writer can access a file when even a single reader holds a shared lock. A shared lock promotes efficiency. If one process is just reading a file and not changing its contents, there is no reason to prevent other processes from doing the same. Writing, however, clearly demands exclusive access to a file, as a whole or just in part.

The standard I/O library includes a utility function named fcntl that can be used to inspect and manipulate both exclusive and shared locks on a file. The function works through the by-now-familiar file descriptor , a nonnegative integer value that, within a process, identifies a file. (Recall that different file descriptors in different processes may identify the same physical file.) For file locking, Linux provides the library function flock, which is a thin wrapper around fcntl. The code examples use the fcntl function to expose API details.
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#define FileName "data.dat"
#define DataString "Now is the winter of our discontent Made glorious summer by this sun of York "
void report_and_exit(const char* msg) {
  perror(msg);
  exit(-1); /* EXIT_FAILURE */
}
int main() {
  struct flock lock;
  lock.l_type = F_WRLCK;    /* read/write (exclusive versus shared) lock */
  lock.l_whence = SEEK_SET; /* base for seek offsets */
  lock.l_start = 0;         /* 1st byte in file */
  lock.l_len = 0;           /* 0 here means 'until EOF' */
  lock.l_pid = getpid();    /* process id */
  int fd; /* file descriptor to identify a file within a process */
  if ((fd = open(FileName, O_RDWR | O_CREAT, 0666)) < 0)  /* -1 signals an error */
    report_and_exit("open failed...");
  if (fcntl(fd, F_SETLK, &lock) < 0) /** F_SETLK doesn't block, F_SETLKW does **/
    report_and_exit("fcntl failed to get lock...");
  else {
    write(fd, DataString, strlen(DataString)); /* populate data file */
    fprintf(stderr, "Process %d has written to data file... ", lock.l_pid);
  }
  /* Now release the lock explicitly. */
  lock.l_type = F_UNLCK;
  if (fcntl(fd, F_SETLK, &lock) < 0)
    report_and_exit("explicit unlocking failed...");
  close(fd); /* close the file: would unlock if needed */
  return 0;  /* terminating the process would unlock as well */
}
Listing 7-7

The producer program

The main steps in the producer program (see Listing 7-7) can be summarized as follows. The program declares a variable of type struct flock, which represents a lock, and initializes the structure’s five fields. The first initialization
lock.l_type = F_WRLCK; /* exclusive lock */

makes the lock an exclusive (read-write) rather than a shared (read-only) lock. If the producer gains the lock, then no other process will be able to write or read the file until the producer releases the lock, either explicitly with the appropriate call to fcntl or implicitly by closing the file. (When the process terminates, any opened files would be closed automatically, thereby releasing the lock.) The program then initializes the remaining fields. The chief effect is that the entire file is to be locked. However, the locking API allows only designated bytes to be locked. For example, if the file contains multiple text records, then a single record (or even part of a record) could be locked and the rest left unlocked.

The first call to fcntl
if (fcntl(fd, F_SETLK, &lock) < 0)

tries to lock the file exclusively, checking whether the call succeeded. In general, the fcntl function returns -1 (hence, less than zero) to indicate failure. The second argument F_SETLK means that the call to fcntl does not block: the function returns immediately, either granting the lock or indicating failure. If the flag F_SETLKW (the W at the end is for wait) were used instead, the call to fcntl would block until gaining the lock was possible. In the calls to fcntl, the first argument fd is the file descriptor, the second argument specifies the action to be taken (in this case, F_SETLK for setting the lock), and the third argument is the address of the lock structure (in this case, &lock).

If the producer gains the lock, the program writes two text records to the file. After writing to the file, the producer changes the lock structure’s l_type field to the unlock value:
lock.l_type = F_UNLCK;
and calls fcntl to perform the unlocking operation. The program finishes up by closing the file and exiting.
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#define FileName "data.dat"
void report_and_exit(const char* msg) {
  perror(msg);
  exit(-1); /* EXIT_FAILURE */
}
int main() {
  struct flock lock;
  lock.l_type = F_WRLCK;    /* read/write (exclusive) lock */
  lock.l_whence = SEEK_SET; /* base for seek offsets */
  lock.l_start = 0;         /* 1st byte in file */
  lock.l_len = 0;           /* 0 here means 'until EOF' */
  lock.l_pid = getpid();    /* process id */
  int fd; /* file descriptor to identify a file within a process */
  if ((fd = open(FileName, O_RDONLY)) < 0)  /* -1 signals an error */
    report_and_exit("open to read failed...");
  /* If the file is write-locked, we can't continue. */
  fcntl(fd, F_GETLK, &lock); /* sets lock.l_type to F_UNLCK if no write lock */
  if (lock.l_type != F_UNLCK)
    report_and_exit("file is still write locked...");
  lock.l_type = F_RDLCK; /* prevents any writing during the reading */
  if (fcntl(fd, F_SETLK, &lock) < 0)
    report_and_exit("can't get a read-only lock...");
  /* Read the bytes (they happen to be ASCII codes) one at a time. */
  int c; /* buffer for read bytes */
  while (read(fd, &c, 1) > 0)    /* 0 signals EOF */
    write(STDOUT_FILENO, &c, 1); /* write one byte to the standard output */
  /* Release the lock explicitly. */
  lock.l_type = F_UNLCK;
  if (fcntl(fd, F_SETLK, &lock) < 0)
    report_and_exit("explicit unlocking failed...");
  close(fd);
  return 0;
}
Listing 7-8

The consumer program

The consumer program (see Listing 7-8) is more complicated than necessary to highlight features of the locking API. In particular, the consumer program first checks whether the file is exclusively locked and only then tries to gain a shared lock. The relevant code is
lock.l_type = F_WRLCK;
...
fcntl(fd, F_GETLK, &lock); /* sets lock.l_type to F_UNLCK if no write lock */
if (lock.l_type != F_UNLCK)
  report_and_exit("file is still write locked...");

The F_GETLK operation specified in the fcntl call checks for a lock, in this case, an exclusive lock given as F_WRLCK in the first statement earlier. If the specified lock does not exist, then the fcntl call automatically changes the lock type field to F_UNLCK to indicate this fact. If the file is exclusively locked, the consumer terminates. (A more robust version of the program might have the consumer sleep a bit and try again several times.)

If the file is not currently locked, then the consumer tries to gain a shared (read-only) lock (F_RDLCK). To shorten the program, the F_GETLK call to fcntl could be dropped because the F_RDLCK call would fail if a read-write lock already were held by some other process. Recall that a read-only lock does prevent any other process from writing to the file but allows other processes to read from the file. In short, a shared lock can be held by multiple processes. After gaining a shared lock, the consumer program reads the bytes one at a time from the file, prints the bytes to the standard output, releases the lock, closes the file, and terminates.

Here is the output from the two programs launched from the same terminal:
% ./producer
Process 29255 has written to data file...
% ./consumer
Now is the winter of our discontent
Made glorious summer by this sun of York

The data shared through this interprocess communication is text: two lines from Shakespeare’s play Richard III . Yet the shared file’s contents could be voluminous, arbitrary bytes (e.g., a digitized movie), which makes file sharing an impressively flexible mechanism. The downside is that file access is relatively slow, whether the access involves reading or writing. As always, programming comes with trade-offs.

7.6 Interprocess Communication Through Message Queues

Earlier code examples highlighted pipes, both named and unnamed. Pipes of either type have strict FIFO behavior: the first byte written is the first byte read, the second byte written is the second byte read, and so forth. Message queues can behave in the same way but are flexible enough that byte chunks can be retrieved out of FIFO order .

As the name suggests, a message queue is a sequence of messages, each of which has two parts :
  • The payload, which is an array of bytes (char).

  • A type, given as a positive integer value; types categorize messages for flexible retrieval.

Consider the following depiction of a message queue, with each message labeled with an integer type :
          +-+    +-+    +-+    +-+
sender--->|3|--->|2|--->|2|--->|1|--->receiver
          +-+    +-+    +-+    +-+

Of the four messages shown, the one labeled 1 is at the front, that is, closest to the receiver. Next come two messages with label 2, and finally, a message labeled 3 at the back. If strict FIFO behavior were in play, then the messages would be received in the order 1-2-2-3. However, the message queue allows other retrieval orders. For example, the messages could be retrieved by the receiver in the order 3-2-1-2.

The mqueue example consists of two programs: the sender that writes to the message queue and the receiver that reads from this queue. Both programs include the header file queue.h shown in Listing 7-9.
#define ProjectId 123
#define PathName  "queue.h" /* any existing, accessible file would do */
#define MsgLen    4
#define MsgCount  6
typedef struct {
  long type;                 /* must be of type long */
  char payload[MsgLen + 1];  /* bytes in the message */
} queuedMessage;
Listing 7-9

The header file queue.h

The header file defines a structure type named queuedMessage , with payload (byte array) and type (integer) fields. This file also defines symbolic constants (the #define directives), the first two of which are used to generate a key that, in turn, is used to get a message queue ID. The ProjectId can be any positive integer value, and the PathName must be of an existing, accessible file—in this case, the file queue.h. The setup statements in both the sender and the receiver programs are
key_t key = ftok(PathName, ProjectId);   /* generate key */
int qid = msgget(key, 0666 | IPC_CREAT); /* use key to get queue id */
The ID qid is, in effect, the counterpart of a file descriptor for message queues.
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <stdlib.h>
#include <string.h>
#include "queue.h"
void report_and_exit(const char* msg) {
  perror(msg);
  exit(-1); /* EXIT_FAILURE */
}
int main() {
  key_t key = ftok(PathName, ProjectId);
  if (key < 0) report_and_exit("couldn't get key...");
  int qid = msgget(key, 0666 | IPC_CREAT);
  if (qid < 0) report_and_exit("couldn't get queue id...");
  char* payloads[] = {"msg1", "msg2", "msg3", "msg4", "msg5", "msg6"};
  int types[] = {1, 1, 2, 2, 3, 3}; /* each must be > 0 */
  int i;
  for (i = 0; i < MsgCount; i++) {
    /* build the message */
    queuedMessage msg;
    msg.type = types[i];
    strcpy(msg.payload, payloads[i]);
    /* send the message */
    msgsnd(qid, &msg, MsgLen + 1, IPC_NOWAIT); /* don't block */
    printf("%s sent as type %i ", msg.payload, (int) msg.type);
  }
  return 0;
}
Listing 7-10

The message sender program

The preceding sender program sends out six messages, two each of a specified type: the first messages are of type 1, the next two of type 2, and the last two of type 3. The sending statement
msgsnd(qid, &msg, MsgLen + 1, IPC_NOWAIT);
is configured to be nonblocking (the flag IPC_NOWAIT) because the messages are so small. The only danger is that a full queue, unlikely in this example, would result in a sending failure. The following receiver program also receives messages using the IPC_NOWAIT flag.
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <stdlib.h>
#include "queue.h"
void report_and_exit(const char* msg) {
  perror(msg);
  exit(-1); /* EXIT_FAILURE */
}
int main() {
  key_t key= ftok(PathName, ProjectId); /* key to identify the queue */
  if (key < 0) report_and_exit("key not gotten...");
  int qid = msgget(key, 0666 | IPC_CREAT); /* access if created already */
  if (qid < 0) report_and_exit("no access to queue...");
  int types[] = {3, 1, 2, 1, 3, 2}; /* different than in sender */
  int i;
  for (i = 0; i < MsgCount; i++) {
    queuedMessage msg; /* defined in queue.h */
    if (msgrcv(qid, &msg, MsgLen + 1, types[i], MSG_NOERROR | IPC_NOWAIT) < 0)
      puts("msgrcv trouble...");
    printf("%s received as type %i ", msg.payload, (int) msg.type);
  }
  /** remove the queue **/
  if (msgctl(qid, IPC_RMID, NULL) < 0)  /* NULL = 'no flags' */
    report_and_exit("trouble removing queue...");
  return 0;
}
Listing 7-11

The message receiver program

The receiver program does not create the message queue, although the API suggests as much. In the receiver, the call
int qid = msgget(key, 0666 | IPC_CREAT);
is misleading because of the IPC_CREAT flag , but this flag really means create if needed, otherwise access. The sender program calls msgsnd to send messages, whereas the receiver calls msgrcv to retrieve them. In this example, the sender sends the messages in the order 1-1-2-2-3-3, but the receiver then retrieves them in the order 3-1-2-1-3-2, showing that message queues are not bound to strict FIFO behavior:
% ./sender
msg1 sent as type 1
msg2 sent as type 1
msg3 sent as type 2
msg4 sent as type 2
msg5 sent as type 3
msg6 sent as type 3
% ./receiver
msg5 received as type 3
msg1 received as type 1
msg3 received as type 2
msg2 received as type 1
msg6 received as type 3
msg4 received as type 2
The preceding output shows that the sender and the receiver can be launched from the same terminal. The output also shows that the message queue persists even after the sender process creates the queue, writes to it, and exits. The queue goes away only after the receiver process explicitly removes the queue with the call to msgctl:
if (msgctl(qid, IPC_RMID, NULL) < 0) /* remove queue */

7.7 Multithreading

Recall that a multithreaded process has multiple threads (sequences) of executable instructions , which can be executed concurrently and, on a multiprocessor machine, in parallel. Multithreading, like multiprocessing, is a way to multitask. Multithreading has the upside of efficiency because thread-level context switches are quite fast but the downside of challenging the programmer with the twin perils of race conditions and deadlock. Code examples go into detail. To begin, an example of pthread (the standard thread library) basics should be helpful.
/* compilation: gcc -o greet greet.c -lpthread */
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define ThreadCount 4
void* greet(void* my_id) { /* void* is 8 bytes on a 64-bit machine */
  unsigned i, n = ThreadCount;
  for (i = 0; i < n; i++) {
    printf("from thread %ld... ", (unsigned long) my_id);
    sleep(rand() % 3);
  }
  return 0;
} /* implicit call to pthread_exit(NULL) */
void main() {
  pthread_t threads[ThreadCount];
  unsigned long i;
  for (i = 0; i < ThreadCount; i++) {
    /* four args: pointer to pthread_t instance, attributes, start function,
       and argument passed to start function */
    int flag = pthread_create(threads + i,  /* 0 on success */
                              NULL,
                              greet,
                              (void*) i + 1);
    if (flag < 0) {
      perror(NULL);
      exit(-1);
    }
  }
  puts("main exiting...");
  pthread_exit(NULL); /* allows other threads to continue execution */
}
Listing 7-12

A first multithreaded example

The multiT program (see Listing 7-12) has five threads in all: the main thread, which executes the body of main, and four additional threads that main creates through calls to the library function pthread_create. The pthread_create function takes four arguments:
  • The first argument is a pointer to a pthread_t instance, in this case an element in the threads array.

  • The second argument specifies thread attributes. A value of NULL indicates that the default attributes should be used.

  • The third argument is a pointer to the thread’s start function, which the thread executes once the operating system starts the thread. A created thread automatically terminates when it returns from its start function. The start function can call other functions and do whatever else comes naturally to functions.

  • The fourth and last argument specifies what should be passed, as an argument, to the start function. In this case, the argument passed to the greet start function will be one of the values 1, 2, 3, and 4, which identify each of the created threads. The argument passed to the start function is always of the generic type void*, and NULL for no argument can be used.

All four of the created threads execute the same code, the body of the greet function, but no race condition arises. Arguments passed to a function, and local (auto or register) variables within the function, are thereby thread-safe because each thread gets its own copies. If a variable is neither extern nor static, then it represents a thread-safe memory location.

The pthread_create function returns -1 to signal an error and 0 to signal success. A successfully created thread is ready to be scheduled for execution on a processor.

At the end of main, the multiT program calls the library function pthread_exit with an argument of NULL. The address of an int exit-status variable also could be used as the argument. This call from main allows other threads to continue executing. On a sample run, for instance, the output began:
from thread 2...
from thread 4...
main exiting...
from thread 3...
...

The order of thread execution is indeterminate. Once the threads are created, the operating system takes over the scheduling, using whatever algorithm the host system employs. A pthread instance is a native thread under operating system control. By contrast, a green thread is under the control of a virtual machine. For example, early implementations of Java (before JDK 1.4) were required to support only green threads. If the multiT program is run several times, the output is likely to differ each time.

WHAT’S POSIX?

The Portable Operating System Interface is a family of standards from the IEEE Computer Society meant to encourage compatibility among operating systems. The multithreading examples use pthreads, where the p stands for POSIX.

7.7.1 A Thread-Based Race Condition

The next code example illustrates a race condition in a multithreaded program. The program later introduces a mechanism for coordinating thread execution, thereby preventing this race condition. A short depiction of a race condition follows.

Suppose that there is a static variable named n, which is initialized to 1 and updated as follows:
n += rand();  /* add a pseudo random value to n */
The assignment operator += makes it clear that two operations are involved: an addition followed by an assignment. Suppose that this same statement belongs to two separate threads of execution, T1 and T2, each of which accesses the same variable n. For emphasis, assume that each thread executes literally at the same time on a multiprocessor machine. Here is one possible scenario, where each of the numbered items represents one tick of the system clock :
  1. 1.

    Thread T1 gets 123 from its call to rand() and performs the addition. Assume that the sum of the two numbers 123 + 1 = 124 is stored on the stack. Call this storage location temp1, which now holds 124.

     
  2. 2.

    Thread T2 gets 987 from its call to rand() and performs the addition. The sum 988 is stored in temp2, also on the stack.

     
  3. 3.

    Thread T2 performs the assignment, using the value from temp2: the value of n is updated to 988.

     
  4. 4.

    Thread T1 performs the assignment, using the value from temp1: the value of n changes to 124.

     

It is clear that improper interleaving of machine-level instructions has taken place. Thread T2 does its addition and assignment without interruption, which is the correct way to perform the two operations. By contrast, thread T1 does its addition, is delayed two ticks of the clock, and then finishes up with an assignment. By coming in last, thread T1 wins the race: the final value of variable n, 124, reflects only what thread T1 did, and what thread T2 did is effectively lost.

The two operations, the addition and then the assignment, make up a critical section , a sequence of operations that must be executed in a single-threaded, uninterrupted manner: if one thread starts its addition, no other thread should access variable n until this first thread completes its work with an assignment. The code segment at present does not enforce single-threaded or thread-safe execution of the
n += rand(); /* addition then assignment */

critical section . The outcome is, therefore, indeterminate and unpredictable.

7.7.2 The Miser/Spendthrift Race Condition

The forthcoming miserSpend program encourages a race condition by having two threads concurrently update a shared memory location, in this case the single static variable named account, which represents a shared bank account: both threads access the same account. A memory-based race condition requires contention for a shared memory location. Of course, the account variable could be extern rather than static without changing the program’s behavior.

The miser (saver) and the spendthrift (spender) are implemented as two separate threads , each with uncoordinated access to the account. To highlight the race condition, the miser and the spendthrift update the balance the same number of times, given as a command-line argument . Here is a depiction of what goes on in the miserSpend program:
      increment  +---------+  decrement
miser----------->| account |<-----------spendthrift  ## updates are done many times
                 +---------+

On a multiprocessor machine , the miser and the spendthrift can execute in a truly parallel fashion. Because access to the account is uncoordinated, a race condition ensues, and the final value of account is indeterminate. Indeed, if the two threads increment and decrement a sufficient number of times (e.g., ten million apiece), it becomes highly unlikely that the account will have zero as its value at the end, or that the account will have a repeated value over multiple runs.

As in the earlier multithreading example, the main thread starts the other threads, but the main thread now must wait for the miser and the spendthrift threads to terminate. For the program to illustrate the race condition, the main thread must be the last thread standing. The reason is that the main thread prints the final value of the account and must not do so prematurely, that is, before all of the updates have completed. Otherwise, the main thread might print the value of account when this value just happens to be zero. The pthread library has a function to enable the required waiting.
void report_and_die(const char* msg) {
  fprintf(stderr, "%s ", msg);
   exit(-1);
}
void main(int argc, char* argv[]) {
  if (argc < 2) report_and_die("Usage: saveSpend <number of operations apiece> ");
  int n = atoi(argv[1]); /** command-line argument conversion to integer **/
  pthread_t miser, spendt;
  if (pthread_create(&miser, NULL, deposit, &n) < 0)
    report_and_die("pthread_create: miser");
  if (pthread_create(&spendt, NULL, withdraw, &n) < 0)
    report_and_die("pthread_create: spendt");
  pthread_join(miser,  NULL);  /* main thread waits on miser: NULL for exit status */
  pthread_join(spendt, NULL);  /* main thread waits on spendt: NULL for exit status */
  printf("The final account balance is: %10i ", account);
}
Listing 7-13

Creating, starting, and waiting on the miser and spendthrift threads

The code for the saveSpend program is divided into two parts for readability. The first part (see Listing 7-13) has the main thread create and then start two other threads: the miser and the spendthrift threads. Each created thread is of type pthread_t , and the pthread_create function can be reviewed as follows:
  • The first argument is the address of a pthread_t instance, in this case, of either the miser or the spendt variable.

  • The second argument, NULL, indicates that default thread properties are to be used.

  • The third argument is the address of the start function, either deposit (miser) or withdraw (spendthrift). Recall that each created thread terminates automatically when exiting its start function.

  • The fourth argument is the address of the argument passed to the start function, in this case the address of integer variable n, which is the number of times that each started thread should update the account.

The saveSpend program introduces only one new function from the pthread API, pthread_join . The caller of the function, in this case main, thereby goes into a wait state until the thread identified in the first argument has exited. For review, the main function calls the pthread_join function twice:
pthread_join(miser,  NULL);  /* main thread waits on miser */
pthread_join(spendt, NULL);  /* main thread waits on spendt */
If the miser already has exited, the first call to pthread_join returns immediately; if not, the call returns when the miser does exit. The second argument to pthread_join can be used to get the exit status of the thread given as the second argument; in this case, the status is ignored with NULL as the second argument. The two calls to pthread_join ensure that the main thread prints the final balance—the balance after the other two threads have terminated.
/** To compile: gcc -o saveSpend saveSpend.c -lpthread **/
#include<stdio.h>
#include<pthread.h>
#include<stdlib.h>
static int account = 0;  /** shared storage across the threads **/
void update(int n) {
  account += n; /** critical section **/
}
void* deposit(void* n) {  /** miser code **/
  int limit = *(int*) n, i;
  for (i = 0; i < limit; i++) update(+1); /* add 1 to account */
  return NULL;
} /** thread terminates when exiting deposit **/
void* withdraw(void* n) { /** spendt code **/
  int limit = *(int*) n, i;
  for (i = 0; i < limit; i++) update(-1); /* subtract 1 from account */
  return NULL;
} /** thread terminates when exiting withdraw **/
Listing 7-14

The miser/spendthrift start functions

The second part of the saveSpend program (see Listing 7-14) has the two start functions for the created threads: deposit (miser) and withdraw (spendthrift). Each of these functions takes, as its single argument, the number of times to perform an account update, implemented as the update function: the deposit function calls update with 1 as the argument, whereas the withdraw function calls update with -1 as the argument.

A command-line argument determines the number of deposits and withdrawals, and this number is the same for the miser and the spendthrift. The command-line argument should be sufficiently large to be interesting, that is, to confirm the race condition. If the number is too small (e.g., 100), then the miser might do its 100 deposits before the spendthrift does any withdrawals. The goal is to have each thread run long enough that there is improper interleaving of the arithmetic and assignment operations in the critical section, the body of the update function . With a command-line argument of 10M (million), the output from two consecutive runs was
The final account balance is:   203692
The final account balance is: -1800416

With a command-line argument of 10M, a result of zero is highly unlikely.

In the saveSpend program, the account is changed in only one place: the function update, which takes a single int argument and updates the account by this amount. For the saveSpend program to behave properly, the body of update function must execute in a single-threaded fashion. There are different ways to enforce this policy, and using a mutex to lock access to the account is one way. (Recall the earlier example of the memwriter/memreader in which a semaphore is used as a mutex.) In the current example, the mutex from the pthread library ensures single-threaded execution of a critical section—the body of the update function in which the account is either incremented or decremented.
static int account = 0;  /** shared storage across the threads **/
static pthread_mutex_t lock; /* named lock for clarity */
void update(int n) {
  if (0 == pthread_mutex_lock(&lock)) {
    account += n;                    /** critical section **/
    pthread_mutex_unlock(&lock);
  }
}
Listing 7-15

Fixing the saveSpend program

The saveSpend program requires only a few changes to fix (see Listing 7-15):
  • A pthread_mutex_lock variable named lock is added. There should be a single lock to ensure that the miser and the spendthrift contend for the same lock. The lock is static but could be extern as well.

  • The lock is used in the update function. To update the account, a thread first must grab the lock, expressed here as the condition of the if clause. The pthread_mutex_lock function returns 0 to signal that the lock has been grabbed.

  • Once a thread completes its update, the thread releases the lock so that another thread can try to grab it.

With these changes in place, the saveSpend program always prints 0 as the value of the account when the miser and spendthrift threads have terminated.

One more change is recommended in fixing the saveSpend program. After the miser and spendthrift threads terminate, the lock is no longer needed; hence, it should be destroyed. The function main could be changed as follows:
...
pthread_join(spendt, NULL);
pthread_mutex_destroy(&lock); /** added **/
A high-level summary of the pthread_mutex seems in order:
  • To execute a locked critical section, a thread first must grab the lock. After finishing the execution of the critical section, a thread should release the lock to enable some other thread to grab the lock and thus to safeguard against deadlock.

  • If multiple threads are contending for the lock, the implementation ensures that exactly one thread grabs it.

  • In general, a mutex such as pthread_mutex does not guarantee fairness. For example, if two threads are contending for the lock, the mutex implementation does not guarantee that each thread will be successful half the time. However, the saveSpend program has other logic to ensure that the miser and the spendthrift threads execute the same number of times.

If the fixed saveSpend program is run with a sufficiently large loop count (e.g., 10,000,000) as the command-line argument, there will be noticeable slowdown compared to the original version of the program. There is a performance cost to mutual exclusion, which enforces single-threaded execution of a critical section; in this code example, the cost ensures that the saveSpend program runs correctly.

7.8 Deadlock in Multithreading

Deadlock can occur in either a multiprocessing or multithreading. In the multithreading context, deadlock can occur with just two threads: T1 and T2. To access a shared resource R, either T1 or T2 must hold two locks (L1 and L2) at the same time. Suppose the two threads try to access R, with T1 managing to grab lock L1 and T2 managing to grab lock L2. Each thread now waits indefinitely for the other to release its held lock—and deadlock results. Deadlock is usually inadvertent, of course, but the next code example tries to cause deadlock.
/** To compile: gcc -o deadlock deadlock.c -lpthread **/
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static pthread_mutex_t lock1, lock2; /** two locks protect the resource **/
static int resource = 0;             /** the resource **/
void grab_locks(const char* tname,
                const char* lock_name,
                const char* other_lock_name,
                pthread_mutex_t* lock,
                pthread_mutex_t* other_lock) {
  printf("%s trying to grab %s... ", tname, lock_name);
  pthread_mutex_lock(lock);
  printf("%s grabbed %s ", tname, lock_name);
  if (0 == strcmp(tname, "thread1")) usleep(100); /** fix is in! **/
  printf("%s trying to grab %s... ", tname, other_lock_name);
  pthread_mutex_lock(other_lock);
  printf("%s grabbed %s ", tname, other_lock_name);
  resource = (0 == strcmp(tname, "thread1")) ? -9999 : 1111;
  pthread_mutex_unlock(other_lock);
  pthread_mutex_unlock(lock);
}
void* thread1() {
  grab_locks("thread1", "lock1", "lock2", &lock1, &lock2); /* lock1...lock2 */
  return NULL;
}
void* thread2() {
  grab_locks("thread2", "lock2", "lock1", &lock2, &lock1); /* lock2...lock1 */
  return NULL;
}
void main(){
    pthread_t t1, t2;
    pthread_create(&t1, NULL, thread1, NULL);     /* start thread 1 */
    pthread_create(&t2, NULL, thread2, NULL);     /* start thread 2 */
    pthread_join(t1, NULL);           /* wait for thread 1 */
    pthread_join(t2, NULL);           /* wait for thread 2 */
    printf("Number: %i (Unlikely to print...) ", resource);
}
Listing 7-16

Deadlocking with threads

The deadlock program (see Listing 7-16) is likely but not certain to deadlock. Although deadlock is intended, the code still might execute in such a way that deadlock does not occur. On a sample run, however, the deadlock program produced this output :
thread1 trying to grab lock1...
thread1 grabbed lock1
thread2 trying to grab lock2...
thread2 grabbed lock2
thread2 trying to grab lock1...
thread1 trying to grab lock2...

A code analysis shows what happened.

The main thread creates two threads: t1 and t2. Thread t1 is created first, and the output confirms that t1 starts executing first—although the order of execution is indeterminate. There are two locks, lock1 and lock2, which protect resource, an int variable: thread t1 tries to set this variable to -9999, whereas thread t2 tries to set the variable to 1111. To set the variable, a thread must grab both locks.

Thread t1 has thread1 as its start function, and t2 has thread2 as its start function. In turn, these functions immediately call the grab_locks function , but with arguments in a different order. Recall that, in multithreading, each thread has its own copies of arguments and local variables.

Given the output shown previously, the concurrent execution of grab_locks can be summarized as follows:
  1. 1.

    Thread t1 succeeds in grabbing lock1 but fails in the attempt to grab lock2. After grabbing lock1, thread t1 sleeps for 100 microseconds—time enough, as it turns out, for thread t2 to grab lock2.

     
  2. 2.

    After grabbing lock2, thread t2 tries to grab lock1, which thread t1 already holds. At this point, t1 holds lock1 and t2 holds lock2.

     
  3. 3.

    The last two statements in the grab_locks function release the locks. However, neither thread can proceed to the release code without first grabbing a lock that the other thread already holds—deadlock.

     

Why is deadlock not certain in the deadlock program? On my desktop machine, no deadlock results if the usleep call is removed from the grab_locks function . No deadlock results if the argument passed to usleep is sufficiently small. Even with the current usleep value of 100, it is possible that thread t1 might grab both locks before thread t2 even begins executing. It is also possible, on a multiprocessor machine, that thread t2 is scheduled on a faster processor than is t1; as a result, t2 grabs both locks before t1 even begins executing. A thread that holds both locks can proceed to the release code: no deadlock occurs. The deadlock program tries to cause deadlock, but even this requires some experimentation by setting the amount of time that thread t1 sleeps after grabbing the first lock.

The deadlock program tries to cause deadlock, but the real-world challenge is a concurrent program that, although designed not to deadlock, does so anyway. Modern database systems typically include at least a deadlock-detection module . In general, however, software systems neither detect, nor prevent, nor recover from deadlock. The burden thus falls on the programmer to write code that avoids deadlock.

7.9 SIMD Parallelism

The acronym SIMD was introduced in the mid-1960s as part of Flynn’s taxonomy for parallel computing. SIMD stands for single instruction, multiple data stream . Flynn’s taxonomy introduces other acronyms (e.g., MIMD for multiple instruction, multiple data stream) to describe additional approaches to parallel computation. This section focuses on SIMD parallelism.

Imagine integer values collected in array and a code segment that doubles the value of each element. A conventional approach would be to loop over the array and, one element at a time, double each value. In a SIMD architecture, a single instruction would execute on each element in parallel. The serial or iterative computation gives way to a one-step parallel computation, with a boost in performance that is both intuitive and compelling.

The concurrent programs examined so far become truly parallel programs without any programmer intervention. If a multiprocessing or multithreading program happens to execute on a multiprocessor machine (now the norm), then the operating system transforms the concurrent program into a parallel one by scheduling processes/threads onto different processors. SIMD parallelism differs in that parallel instructions come into play. SIMD is thus a type of instruction-level parallelism , which requires underlying architectural support.

The appeal of SIMD parallelism is obvious. Even everyday applications regularly iterate over arrays, performing the same operation on each element. For an array of size N, this iterative approach requires that N instructions be executed in sequence. Assume, for simplicity, that each instruction requires one tick of the system clock. In this scenario, doubling the array elements takes N ticks. If the doubling can be done in a single SIMD instruction, the time required drops from N ticks to roughly one tick, although there is nontrivial overhead to set up the parallel addition.

For some time, computers have had devices tailored for SIMD. A graphics processing unit (GPU) is a case in point; indeed, the acronym GP_GPU describes a GPU designed for general purpose rather than just graphics-specific processing . There are various C libraries and entire frameworks devoted to putting such devices to use in SIMD processing. This section goes another way, focusing instead on how the standard C compilers are now able to use native SIMD instructions, in particular on modern Intel and AMD machines. (ARM Neon machines likewise support SIMD.)

In the late 1990s, Intel released the P5 (P for Pentium) line of microprocessors, which support the MMX instruction set , a first step toward SIMD parallelism. The MM registers associated with this instruction set, and the instruction set itself, soon gave way to SSE (Streaming SIMD Extensions) in different versions (e.g., SSE2 and SSE4). The XMM registers of SSE are 128 bits in size and small in number—only eight to begin but later sixteen. The SIMD architecture and instruction set have continued to evolve. For example, the XMM registers (128 bits) now have siblings: YMM registers (256 bits) and ZMM registers (512 bits).
#include <stdio.h>
#define Length 8
typedef double doubleV8 __attribute__ ((vector_size (Length * sizeof(double)))); /** critical **/
void main() {
  doubleV8 dataV1 = {1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8};  /* no square brackets on dataV1 */
  doubleV8 dataV2 = {4.4, 6.6, 1.1, 3.3, 5.5, 2.2, 3.3, 5.5};  /* no square brackets on dataV2 */
  doubleV8 add = dataV1 + dataV2;
  doubleV8 mul = dataV1 * dataV2;
  doubleV8 div = dataV1 / dataV2;
  int i;
  for (i = 0; i < Length; i++)
    printf("%f ", add[i]); /* 5.500000 8.800000 4.400000 7.700000 11.000000 8.800000 11.000000 14.300000 */
  putchar(' ');
  for (i = 0; i < Length; i++)
    printf("%f ", mul[i]); /* 4.840000 14.520000 3.630000 14.520000 30.250000 14.520000 25.410000 48.400000 */
  putchar(' ');
  for (i = 0; i < Length; i++)
    printf("%f ", div[i]); /* 0.250000 0.333333 3.000000 1.333333 1.000000 3.000000 2.333333 1.600000 */
  putchar(' ');
}
Listing 7-17

A SIMD program in C

The simd program (see Listing 7-17) has a typedef that triggers the C compiler to use native SIMD instructions and the supporting architectural components, in particular SIMD registers. The typedef makes doubleV8 an alias for a double vector by using a special attribute:
__attribute__ ((vector_size (Length * sizeof(double)))

The attribute specifier has two underscores in front and in back. The specified attribute is vector_size, whose value is Length (defined as 8) multiplied by sizeof(double), which is typically 8 bytes. A doubleV8 instance is thereby defined as a vector of eight 8-byte floating-point values, which requires 512 bits in all.

With this typedef in place, the arithmetic operations in the remaining code are easy to read—and highly efficient. To begin, each of the two doubleV8 variables, dataV1 and dataV2, is initialized. Notice that the square brackets usually associated with arrays are absent. Here, for review, is the initialization of vector dataV1:
doubleV8 dataV1 = {1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8};
The vectors dataV1 and dataV2 can be used with indexes, as the three loops near the end of the code illustrate:
printf("%f ", div[i]); /* print ith value */
However, the arithmetic operations to add, multiply, and divide the vectors are one statement apiece in the source code. Here, for review, is the multiplication of the two vectors:
doubleV8 mul = dataV1 * dataV2; /* no looping! */

The standard compilers now make SIMD programming straightforward in C itself, without any additional libraries or tools. Of course, the underlying architecture must support machine-level SIMD instructions. It is reasonable to expect that SIMD architectures will continue to improve and that the C compilers will continue to generate code that takes advantage of the evolving SIMD instruction sets and architectures.

7.10 What’s Next?

The next chapter covers miscellaneous topics to provide a better sense of the libraries available in C, both standard and third party. There is also a section on building software libraries from scratch. As usual, the code examples highlight the power and flexibility of C.

The forthcoming code examples cover regular expressions for pattern matching and data validation; assertions for enforcing conditions in code modules; locale management for internationalization ; the compilation of C code into WebAssembly for high-performance web modules; signals for interprocess communication ; and the building, deployment, and use (by both C and Python clients) of software libraries.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.144.237.77