Readers/writer locks
Readers and writer locks are used for exactly what their name implies: multiple readers can be using a resource, with no writers, or one writer can be using a resource with no other writers or readers.
This situation occurs often enough to warrant a special kind of synchronization primitive devoted exclusively to that purpose.
Often you'll have a data structure that's shared by a bunch of threads.
Obviously, only one thread can be writing to the data structure at a time.
If more than one thread was writing, then the threads could potentially overwrite each other's data.
To prevent this from happening, the writing thread would obtain the rwlock
(the readers/writer lock) in an exclusive manner, meaning that it and only it has access to the data
structure.
Note that the exclusivity of the access is controlled strictly by voluntary means.
It's up to you, the system designer, to ensure that all threads that touch
the data area synchronize by using the rwlocks.
The opposite occurs with readers.
Since reading a data area is a non-destructive operation, any number of threads
can be reading the data (even if it's the same piece of data that another thread is reading).
An implicit point here is that no threads can be writing to the data
area while any threads are reading from it.
Otherwise, the reading threads may be confused by reading a part of the data,
getting preempted by a writing thread, and then, when the reading thread resumes,
continue reading data, but from a newer update
of the data.
A data inconsistency would then result.
Let's look at the calls that you'd use with rwlocks.
int pthread_rwlock_init (pthread_rwlock_t *lock,
const pthread_rwlockattr_t *attr);
int pthread_rwlock_destroy (pthread_rwlock_t *lock);
The pthread_rwlock_init() function takes the lock
argument (of type pthread_rwlock_t) and initializes it
based on the attributes specified by attr.
We're just going to use an attribute of NULL in our
examples, which means, Use the defaults.
For detailed information about the attributes, see the entries in the
QNX OS C Library Reference for
pthread_rwlockattr_init(),
pthread_rwlockattr_destroy(),
pthread_rwlockattr_getpshared(),
and
pthread_rwlockattr_setpshared().
When done with the rwlock, you'd typically call pthread_rwlock_destroy() to destroy the lock, which invalidates it. You should never use a lock that is either destroyed or hasn't been initialized.
non-exclusiveaccess, and a writer will want
exclusiveaccess. To keep the names simple, the functions are named after the user of the locks:
int pthread_rwlock_rdlock (pthread_rwlock_t *lock);
int pthread_rwlock_tryrdlock (pthread_rwlock_t *lock);
int pthread_rwlock_wrlock (pthread_rwlock_t *lock);
int pthread_rwlock_trywrlock (pthread_rwlock_t *lock);
There are four functions instead of the two that you may have expected.
The expected
functions are
pthread_rwlock_rdlock()
and
pthread_rwlock_wrlock(),
which are used by readers and writers, respectively.
These are blocking calls—if the lock isn't available for the
selected operation, the thread will block.
When the lock becomes available in the appropriate mode, the thread will
unblock.
Because the thread unblocked from the call, it can now assume that it's
safe to access the resource protected by the lock.
Sometimes, though, a thread won't want to block, but instead will want to
see if it could get the lock.
That's what the try
versions are for.
It's important to note that the try
versions will obtain the
lock if they can, but if they can't, then they won't block, but instead
will just return an error indication.
The reason they have to obtain the lock if they can is simple.
Suppose that a thread wanted to obtain the lock for reading, but didn't want
to wait in case it wasn't available.
The thread calls
pthread_rwlock_tryrdlock(),
and is told that it could have the lock.
If the pthread_rwlock_tryrdlock() didn't allocate the
lock, then bad things could happen—another thread could preempt the one
that was told to go ahead, and the second thread could lock the resource in an
incompatible manner.
Since the first thread wasn't actually given the lock, when the first thread
goes to actually acquire the lock (because it was told it could), it would use
pthread_rwlock_rdlock(),
and now it would block, because the resource was no longer available in that mode.
So, if we didn't lock it if we could, the thread that called the try
version could still potentially block anyway!
int pthread_rwlock_unlock (pthread_rwlock_t *lock);
Once a thread has done whatever operation it wanted to do on the resource, it would release the lock by calling pthread_rwlock_unlock(). If the lock is now available in a mode that corresponds to the mode requested by another waiting thread, then that thread would be made READY.
Note that we can't implement this form of synchronization with just a mutex.
The mutex acts as a single-threading agent, which would be okay for the writing
case (where you want only one thread to be using the resource at a time) but
would fall flat in the reading case, because only one reader would be allowed.
A semaphore couldn't be used either, because there's no way to distinguish
the two modes of access—a semaphore would allow multiple readers,
but if a writer were to acquire the semaphore, as far as the semaphore is
concerned this would be no different from a reader acquiring it, and now you'd
have the ugly situation of multiple readers and one or more writers!
We'll see this in
An example of synchronization,
later in this chapter.
One final note on the use of rwlocks: they aren't recursive and therefore aren't promotive. In other words, you can't lock your rwlock over and over again without unlocking first. You also can't lock your rwlock for reading and then for writing without unlocking first. This tradeoff was done to keep the rwlock as lightweight (in terms of size and speed) as possible.