Software in 6.005
Objectives
IntroductionEarlier, we defined thread safety for a data type or a function as behaving correctly when used from multiple threads, regardless of how those threads are executed, without additional coordination. Show
Here’s the general principle: the correctness of a concurrent program should not depend on accidents of timing. To achieve that correctness, we enumerated four strategies for making code safe for concurrency:
We talked about strategies 1-3 earlier. In this reading, we’ll finish talking about strategy 4, using synchronization to implement your own data type that is safe for shared-memory concurrency. SynchronizationThe correctness of a concurrent program should not depend on accidents of timing. Since race conditions caused by concurrent manipulation of shared mutable data are disastrous bugs — hard to discover, hard to reproduce, hard to debug — we need a way for concurrent modules that share memory to synchronize with each other. Locks are one synchronization technique. A lock is an abstraction that allows at most one thread to own it at a time. Holding a lock is how one thread tells other threads: “I’m changing this thing, don’t touch it right now.” Locks have two operations:
Using a lock also tells the compiler and processor that you’re using shared memory concurrently, so that registers and caches will be flushed out to shared storage. This avoids the problem of reordering, ensuring that the owner of a lock is always looking at up-to-date data. Bank account exampleOur first example of shared memory concurrency was a bank with cash machines. The diagram from that example is on the right. The bank has several cash machines, all of which can read and write the same account objects in memory. Of course, without any coordination between concurrent reads and writes to the account balances, things went horribly wrong. To solve this problem with locks, we can add a lock that protects each bank account. Now, before they can access or update an account balance, cash machines must first acquire the lock on that account. In the diagram to the right, both A and B are trying to access account 1. Suppose B acquires the lock first. Then A must wait to read and write the balance until B finishes and releases the lock. This ensures that A and B are synchronized, but another cash machine C is able to run independently on a different account (because that account is protected by a different lock). DeadlockWhen used properly and carefully, locks can prevent race conditions. But then another problem rears its ugly head. Because the use of locks requires threads to wait ( In the figure to the right, suppose A and B are making simultaneous transfers between two accounts in our bank. A transfer between accounts needs to lock both accounts, so that money can’t disappear from the system. A and B each acquire the lock on their respective “from” account: A acquires the lock on account 1, and B acquires the lock on account 2. Now, each must acquire the lock on their “to” account: so A is waiting for B to release the account 2 lock, and B is waiting for A to release the account 1 lock. Stalemate! A and B are frozen in a “deadly embrace,” and accounts are locked up. Deadlock occurs when concurrent modules are stuck waiting for each other to do something. A deadlock may involve more than two modules: the signal feature of deadlock is a cycle of dependencies, e.g. A is waiting for B which is waiting for C which is waiting for A. None of them can make progress. You can also have deadlock without using any locks. For example, a message-passing system can experience deadlock when message buffers fill up. If a client fills up the server’s buffer with requests, and then blocks waiting to add another request, the server may then fill up the client’s buffer with results and then block itself. So the client is waiting for the server, and the server waiting for the client, and neither can make progress until the other one does. Again, deadlock ensues. In the Java Tutorials, read:
Developing a threadsafe abstract data typeLet’s see how to use synchronization to implement a threadsafe ADT. You can see all the code for this example on GitHub: edit buffer example. You are not expected to read and understand all the code. All the relevant parts are excerpted below. Suppose we’re building a multi-user editor, like Google Docs, that allows multiple people to connect to it and edit it at the same time. We’ll need a mutable datatype to represent the text in the document. Here’s the interface; basically it represents a string with insert and delete operations:
A very simple rep for this datatype would just be a string:
The downside of this rep is that every time we do an insert or delete, we have to copy the entire string into a new string. That gets expensive. Another rep we could use would be a character array, with space at the end. That’s fine if the user is just typing new text at the end of the document (we don’t have to copy anything), but if the user is typing at the beginning of the document, then we’re copying the entire document with every keystroke. A more interesting rep, which is used by many text editors in practice, is called a gap buffer. It’s basically a character array with extra space in it, but instead of having all the extra space at the end, the extra space is a gap that can appear anywhere in the buffer. Whenever an insert or delete operation needs to be done, the datatype first moves the gap to the location of the operation, and then does the insert or delete. If the gap is already there, then nothing needs to be copied — an insert just consumes part of the gap, and a delete just enlarges the gap! Gap buffers are particularly well-suited to representing a string that is being edited by a user with a cursor, since inserts and deletes tend to be focused around the cursor, so the gap rarely moves.
In a multiuser scenario, we’d want multiple gaps, one for each user’s cursor, but we’ll use a single gap for now. Steps to develop the datatypeRecall our recipe for designing and implementing an ADT:
In all these steps, we’re working entirely single-threaded at first. Multithreaded clients should be in the back of our minds at all times while we’re writing specs and choosing reps (we’ll see later that careful choice of operations may be necessary to avoid race conditions in the clients of your datatype). But get it working, and thoroughly tested, in a sequential, single-threaded environment first. Now we’re ready for the next step:
This part of the reading is about how to do step 4. We already saw how to make a thread safety argument, but this time, we’ll rely on synchronization in that argument. And then the extra step we hinted at above:
LockingLocks are so commonly-used that Java provides them as a built-in language feature. In Java, every object has a lock implicitly associated with it — a
You can’t call
Synchronized regions like this provide mutual exclusion: only one thread at a time can be in a synchronized region guarded by a given object’s lock. In other words, you are back in sequential programming world, with only one thread running at a time, at least with respect to other synchronized regions that refer to the same object. Locks guard access to dataLocks are used to guard a shared data variable, like the account balance shown here. If all accesses to a data variable are guarded (surrounded by a synchronized block) by the same lock object, then those accesses will be guaranteed to be atomic — uninterrupted by other threads. Because every object in Java has a lock implicitly associated with it, you might think that simply owning an object’s lock would prevent other threads from accessing that object. That is not the case. Acquiring the lock associated with object
in thread t does one thing and one thing only:
prevents other threads from entering a Locks only provide mutual exclusion with other threads that acquire the same lock. All accesses to a data variable must be guarded by the same lock. You might guard an entire collection of variables behind a single lock, but all modules must agree on which lock they will all acquire and release. Monitor patternWhen you are writing methods of a class, the most convenient lock is the object instance itself, i.e.
Note the very careful discipline here. Every method that touches the rep must be guarded with the lock — even apparently small and trivial ones like This approach is called the monitor pattern. A monitor is a class whose methods are mutually exclusive, so that only one thread can be inside an instance of the class at a time. Java provides some syntactic sugar for the monitor pattern. If you add the keyword
Notice that the Reading exercisesSynchronizing with locks If thread B tries to acquire a lock currently held by thread A: What happens to thread A? blocks until B acquires the lock blocks until B releases the lock nothing What happens to thread B? blocks until A acquires the lock blocks until A releases the lock nothing (missing explanation) This list is mine, all mine Suppose What is true while A is in a it owns the lock on it does not own the lock on no other thread can use
observers of no other thread can use mutators of no other thread can acquire the lock on no other thread can acquire locks on elements in (missing explanation) OK fine but this synchronized List is totally mine Suppose It is now safe to use call call iterate over the list call (missing explanation) I heard you like locks so I acquired your lock so you can lock while you acquire Suppose we run this code:
On the line “uh oh, deadlock?”, do we experience deadlock? If we don’t deadlock, on the line “do we own the lock on obj”, does the thread own the lock on obj? (missing explanation) Thread safety argument with synchronizationNow that we’re protecting
The same argument works for Note that the encapsulation of the class, the absence of rep exposure, is very important for making this argument. If text were public:
then clients outside Locking disciplineA locking discipline is a strategy for ensuring that synchronized code is threadsafe. We must satisfy two conditions:
The monitor pattern as used here satisfies both rules. All the shared mutable data in the rep — which the rep invariant depends on — are guarded by the same lock. Atomic operationsConsider a find-and-replace operation on the
This method makes three different calls to To prevent this, Giving clients access to a lockIt’s sometimes useful to make your datatype’s lock available to clients, so that they can use it to implement higher-level atomic operations using your datatype. So one approach to the problem with
And then
The effect of this is to enlarge the synchronization region that the monitor pattern already put around the individual Sprinkling synchronized everywhere?So is thread safety simply a matter of putting the First, you actually don’t want to synchronize methods willy-nilly. Synchronization imposes a large cost on your program. Making a synchronized method call may take significantly longer, because of the need to acquire a lock (and flush caches and communicate with other processors). Java leaves many of its mutable datatypes unsynchronized by default exactly for these performance reasons. When you don’t need synchronization, don’t use it. Another argument for using Finally, it’s not actually sufficient to sprinkle
This wouldn’t do what we want. It would indeed acquire a lock — because Worse, however, it wouldn’t provide useful protection, because other code that touches the document probably wouldn’t be acquiring the same lock. It wouldn’t actually eliminate our race conditions. The Designing a datatype for concurrency
So if we’re designing a datatype specifically for use in a concurrent system, we
need to think about providing operations that have better-defined semantics when they are interleaved. For example, it might be better to pair As another example, consider the
Deadlock rears its ugly headThe locking approach to thread safety is powerful, but (unlike confinement and immutability) it introduces blocking into the program. Threads must sometimes wait for other threads to get out of synchronized regions before they can proceed. And blocking raises the possibility of deadlock — a very real risk, and frankly far more common in this setting than in message passing with blocking I/O (where we first mentioned it). With locking, deadlock happens when threads acquire multiple locks at the same time, and two threads end up blocked while holding locks that they are each waiting for the other to release. The monitor pattern unfortunately makes this fairly easy to do. Here’s an example. Suppose we’re modeling the social network of a series of books:
Like Facebook, this social network is bidirectional: if x is friends with y, then y is friends with x. The Let’s create a couple of wizards:
And then think about what happens when two independent threads are repeatedly running:
We will deadlock very rapidly. Here’s why. Suppose thread A is about to execute
So A is holding Harry and waiting for Snape, and B is holding Snape and waiting for Harry. Both threads are stuck in The essence of the problem is acquiring multiple locks, and holding some of the locks while waiting for another lock to become free. Notice that it is possible for thread A and thread B to interleave such that deadlock does not occur: perhaps thread A acquires and releases both locks before thread B has enough time to acquire the first one. If the locks involved in a deadlock are also involved in a race condition — and very often they are — then the deadlock will be just as difficult to reproduce or debug. Deadlock solution 1: lock orderingOne way to prevent deadlock is to put an ordering on the locks that need to be acquired simultaneously, and ensuring that all code acquires the locks in that order. In our social network example, we might always acquire the locks on the Here’s what the code might look like:
(Note that the decision to order the locks alphabetically by the person’s name would work fine for this book, but it wouldn’t work in a real life social network. Why not? What would be better to use for lock ordering than the name?) Although lock ordering is useful (particularly in code like operating system kernels), it has a number of drawbacks in practice.
Deadlock solution 2: coarse-grained lockingA more common approach than lock ordering, particularly for application programming (as opposed to operating system or device driver programming), is to use coarser locking — use a single lock to guard many object instances, or even a whole subsystem of a program. For example, we might have a single lock for an entire social network, and have all the operations on any of its constituent parts synchronize on that lock. In the code below, all
Coarse-grained locks can have a significant performance penalty. If you guard a large pile of mutable data with a single lock, then you’re giving up the ability to access any of that data concurrently. In the worst case, having a single lock protecting everything, your program might be essentially sequential — only one thread is allowed to make progress at a time. Reading exercisesDeadlock In the code below three threads 1, 2, and 3 are trying to acquire locks on objects
This system is susceptible to deadlock. For each of the scenarios below, determine whether the system is in deadlock if the threads are currently on the indicated lines of code. Scenario A Thread 1 inside (missing explanation) Scenario B Thread 1 finished (missing explanation) Scenario C Thread 1 running (missing explanation) Scenario D Thread 1 blocked on (missing explanation) Locked out Examine the code again. In the previous problem, we saw deadlocks involving What about there is a possible deadlock where thread 1 owns the lock on there is a possible deadlock where thread 2 owns the lock on there is a possible deadlock where thread 3 owns the lock on
there are no deadlocks involving (missing explanation) Goals of concurrent program designNow is a good time to pop up a level and look at what we’re doing. Recall that our primary goals are to create software that is safe from bugs, easy to understand, and ready for change. Building concurrent software is clearly a challenge for all three of these goals. We can break the issues into two general classes. When we ask whether a concurrent program is safe from bugs, we care about two properties:
Deadlocks threaten liveness. Liveness may also require fairness, which means that concurrent modules are given processing capacity to make progress on their computations. Fairness is mostly a matter for the operating system’s thread scheduler, but you can influence it (for good or for ill) by setting thread priorities. Concurrency in practiceWhat strategies are typically followed in real programs?
We’ve omitted one important approach to mutable shared data because it’s outside the scope of this course, but it’s worth mentioning: a database. Database systems are widely used for distributed client/server systems like web applications. Databases avoid race conditions using transactions, which are similar to synchronized regions in that their effects are atomic, but they don’t have to acquire locks, though a transaction may fail and be rolled back if it turns out that a race occurred. Databases can also manage locks, and handle locking order automatically. For more about how to use databases in system design, 6.170 Software Studio is strongly recommended; for more about how databases work on the inside, take 6.814 Database Systems. And if you’re interested in the performance of concurrent programs — since performance is often one of the reasons we add concurrency to a system in the first place — then 6.172 Performance Engineering is the course for you. SummaryProducing a concurrent program that is safe from bugs, easy to understand, and ready for change requires careful thinking. Heisenbugs will skitter away as soon as you try to pin them down, so debugging simply isn’t an effective way to achieve correct threadsafe code. And threads can interleave their operations in so many different ways that you will never be able to test even a small fraction of all possible executions.
Which of the following property prevents simultaneous access to a shared resource?Mutual exclusion: it prevents simultaneous access to a shared resource. Hold and wait: Process is holding a resource that may be required by other processes.
Which of the following allows many users to share computer simultaneously Mcq?A time shared operating system allows multiple users to share computers simultaneously.
Which of the following are not used to prevent deadlock in the database?Explanation: Deadlock distribution is not a method in deadlock handling whereas, deadlock prevention is followed by deadlock detection and deadlock recovery.
Which of the following conditions occurs simultaneously in a system?Hence the correct answer is Mutual Exclusion, Hold and Wait, No Preemption, and Circular Wait.
|