Sleepon locks

Another common situation that occurs in multithreaded programs is the need for a thread to wait until something happens. This something could be anything! It could be the fact that data is now available from a device, or that a conveyor belt has now moved to the proper position, or that data has been committed to disk, or whatever. Another twist to throw in here is that several threads may need to wait for the given event.

To accomplish this, we'd use either a condition variable (which we'll see next) or the much simpler sleepon lock.

To use sleepon locks, you actually need to perform several operations. Let's look at the calls first, and then look at how you'd use the locks.

int
pthread_sleepon_lock (void);

int
pthread_sleepon_unlock (void);

int
pthread_sleepon_broadcast (void *addr);

int
pthread_sleepon_signal (void *addr);

int
pthread_sleepon_wait (void *addr);
Don't be tricked by the prefix pthread_ into thinking that these are POSIX functions—they're not.

As described above, a thread needs to wait for something to happen. The most obvious choice in the list of functions above is the pthread_sleepon_wait() . But first, the thread needs to check if it really does have to wait. Let's set up an example. One thread is a producer thread that's getting data from some piece of hardware. The other thread is a consumer thread that's doing some form of processing on the data that just arrived. Let's look at the consumer first:

volatile int data_ready = 0;

consumer ()
{
    while (1) {
        while (!data_ready) {
            // WAIT
        }
        // process data
    }
}

The consumer is sitting in its main processing loop (the while (1)); it's going to do its job forever. The first thing it does is look at the data_ready flag. If this flag is a 0, it means there's no data ready. Therefore, the consumer should wait. Somehow, the producer will wake it up, at which point the consumer should reexamine its data_ready flag. Let's say that's exactly what happens, and the consumer looks at the flag and decides that it's a 1, meaning data is now available. The consumer goes off and processes the data, and then goes to see if there's more work to do, and so on.

We're going to run into a problem here. How does the consumer reset the data_ready flag in a synchronized manner with the producer? Obviously, we're going to need some form of exclusive access to the flag so that only one of those threads is modifying it at a given time. The method that's used in this case is built with a mutex, but it's a mutex that's buried in the implementation of the sleepon library, so we can access it only via two functions: pthread_sleepon_lock() and pthread_sleepon_unlock() . Let's modify our consumer:

consumer ()
{
    while (1) {
        pthread_sleepon_lock ();
        while (!data_ready) {
            // WAIT
        }
        // process data
        data_ready = 0;
        pthread_sleepon_unlock ();
    }
}

Now we've added the lock and unlock around the operation of the consumer. This means that the consumer can now reliably test the data_ready flag, with no race conditions, and also reliably set the flag.

Okay, great. Now what about the WAIT call? As we suggested earlier, it's effectively the pthread_sleepon_wait() call. Here's the second while loop:

        while (!data_ready) {
            pthread_sleepon_wait (&data_ready);
        }

The pthread_sleepon_wait() actually does three distinct steps!

  1. Unlock the sleepon library mutex.
  2. Perform the waiting operation.
  3. Re-lock the sleepon library mutex.

The reason it has to unlock and lock the sleepon library's mutex is simple — since the whole idea of the mutex is to ensure mutual exclusion to the data_ready variable, this means that we want to lock out the producer from touching the data_ready variable while we're testing it. But, if we don't do the unlock part of the operation, the producer would never be able to set it to tell us that data is indeed available! The re-lock operation is done purely as a convenience; this way the user of the pthread_sleepon_wait() doesn't have to worry about the state of the lock when it wakes up.

Let's switch over to the producer side and see how it uses the sleepon library. Here's the full implementation:

producer ()
{
    while (1) {
        // wait for interrupt from hardware here...
        pthread_sleepon_lock ();
        data_ready = 1;
        pthread_sleepon_signal (&data_ready);
        pthread_sleepon_unlock ();
    }
}

As you can see, the producer locks the mutex as well so that it can have exclusive access to the data_ready variable in order to set it.

It's not the act of writing a 1 to data_ready that awakens the client! It's the call to pthread_sleepon_signal() that does it.

Let's examine in detail what happens. We've identified the consumer and producer states as:

State Meaning
CONDVAR Waiting for the underlying condition variable associated with the sleepon
MUTEX Waiting for a mutex
READY Capable of using, or already using, the CPU
INTERRUPT Waiting for an interrupt from the hardware
Action Mutex owner Consumer state Producer state
Consumer locks mutex Consumer READY INTERRUPT
Consumer examines data_ready Consumer READY INTERRUPT
Consumer calls pthread_sleepon_wait() Consumer READY INTERRUPT
pthread_sleepon_wait() unlocks mutex Free READY INTERRUPT
pthread_sleepon_wait() blocks Free CONDVAR INTERRUPT
Time passes Free CONDVAR INTERRUPT
Hardware generates data Free CONDVAR READY
Producer locks mutex Producer CONDVAR READY
Producer sets data_ready Producer CONDVAR READY
Producer calls pthread_sleepon_signal() Producer CONDVAR READY
Consumer wakes up, pthread_sleepon_wait() tries to lock mutex Producer MUTEX READY
Producer releases mutex Free MUTEX READY
Consumer gets mutex Consumer READY READY
Consumer processes data Consumer READY READY
Producer waits for more data Consumer READY INTERRUPT
Time passes (consumer processing) Consumer READY INTERRUPT
Consumer finishes processing, unlocks mutex Free READY INTERRUPT
Consumer loops back to top, locks mutex Consumer READY INTERRUPT

The last entry in the table is a repeat of the first entry—we've gone around one complete cycle.

What's the purpose of the data_ready variable? It actually serves two purposes:

  • It's the status flag between the consumer and the producer that indicates the state of the system. If it's set to a 1, it means that data is available for processing; if it's set to a 0, it means that no data is available, and the consumer should block.
  • It serves as the place where sleepon synchronization occurs. More formally, the address of data_ready is used as a unique identifier, that serves as the rendezvous object for sleepon locks. We just as easily could have used (void *) 12345 instead of &data_ready —so long as the identifier is unique and used consistently, the sleepon library really doesn't care. Actually, using the address of a variable in a process is a guaranteed way to generate a process-unique number—after all, no two variables in a process will have the same address!

We'll defer the discussion of What's the difference between pthread_sleepon_signal() and pthread_sleepon_broadcast() to the discussion of condition variables next.