debugger


Concurrency bugs are hard to spot and hard to debug. They have a tendency to slip through the cracks of traditional testing. In this blog post I’ll go through a simple example of a buggy program and explain why the bug is so hard to reproduce and how a dynamic debugger like Jinx can accelerate this proces by orders of magnitude.

Toy Example

How do you show somebody that their program has a concurrency bug? You come up with an interleaving of threads that demonstrates the bug: “What if thread A is pre-empted between this instruction and the next one, and thread B jumps in and executes that instruction?”

Here’s a trivially incorrect example of a concurrent class:

class Circle {
   std::mutex mutex;
   double r; // radius
   double c; // circumference
public:
   Circle(double radius) : r(radius), c(2 * PI * r) {}
   void SetRadius(double radius) {
      mutex.lock();
      r = radius;
      mutex.unlock();
   }
   void UpdateCircumference() {
      mutex.lock();
      c = 2 * PI * r;
      mutex.unlock();
   }
   double GetArea() {
      mutex.lock();
      double area = 0.5 * c * r;
      mutex.unlock();
      return area;
   }
};

The problem arises if thread A is doing this:

circle.SetRadius(r1);
circle.UpdateCircumference();

while thread B is doing this:

area = circle.GetArea();

The question you might ask the unfortunate programmer during a code review (you have those, right?) is: “What if thread A is pre-empted between SetRadius and UpdateCircumference and thread B jumps in and executes GetArea?”. That’s the interleaving that exposes the atomicity violation bug in this code.

Code reviews don’t catch all bugs, so how about some defensive programming? Suppose the programmer adds an assertion, just in case:

area = circle.GetArea();
assert(area == PI * circle.GetRadius() * circle.GetRadius());

Will this help in uncovering the bug? (By the way, did you notice that this assertion has a concurrency bug of its own?)

Back-of-the-Envelope Calculation

Let’s assume that, during normal testing on a multicore machine, the non-atomic update in thread A executes once a second, and so does the the GetArea call in thread B.

We’ll hit the bug if thread B executes its _mutex.lock() in GetArea after thread A takes the lock in SetRadius and before it re-takes the lock in UpdateCircumference. That’s the window of vulnerability.

If thread B starts its call within the vulnerability window, it will read the new value of r and the old value of c. Notice that while thread A holds the lock, thread B is spinning, waiting for thread A to release the lock.

Most of the time in this window is spent acquiring and releasing the lock — which on average should take about 1000 processor cycles. On a 1GHz core that’s about 10-6s.

In our example, we would have to run the test continuously for about 106s in order to hit the bug. That’s about 10 days of testing. Of course your mileage may vary, but you see what the problem is. Concurrency bugs are very hard to reproduce.

Crash Early and Crash Often

A tool that could accelerate the detection of concurrency bugs would save the developers and testers a lot of time and effort. That’s what Corensic’s Jinx does. The basic idea is simple: Run multiple simulations of the execution of code fragments in parallel, carefully picking the candidate interleavings to increase the chances of hitting the vulnerability window.

The enabling technology in this case is virtualization. Jinx is able to periodically virtualize the machine for short periods of time in order to run its simulations.

XXX

During Simulation Round, Jinx runs several exploratory executions in virtual space, picks the most promising one, and runs it in real space. In this case, Jinx found an execution that leads to a crash.

Here’s what Jinx does when it takes control of the machine:

  1. It stops the running program and takes a snapshot of its state.
  2. It runs about a millisecond-worth of execution of the program in virtual space and records all memory accesses.
  3. It explores several alternative executions of the same fragment in virtual space. It ranks them and picks (“retires” in our jargon) the one that either crashes the program or is most likely to manifest a concurrency bug.

In the picture above, Jinx took a snapshot at point A and ran several simulations, one of them resulting in a (virtual) crash at point B. It then retired this particular simulation and ran it in real space, crashing the program at point C.

Of course, the most important part of the algorithm is the choice of executions for simulations. Random rescheduling of the threads is as unlikely to catch the bug as is regular testing. Exhaustive exploration of all possible interleavings would take astronomical time.

Smart Exploration

Imagine that you film the activities of all processors while executing a multithreaded program. Each frame corresponds to a single processor instruction. You’d end up with N tapes from N cores. Your task is to splice those tapes into one continuous movie. You can cut the tapes at arbitrary locations and interleave the fragments. Because of combinatorial explosion, the number of possible movies is overwhelming. That’s how many possible executions there are in a concurrent program. But Jinx has some additional information that cuts down this combinatorial space to a much more reasonable size.

As I mentioned before, during the first exploratory execution of a program fragment Jinx records all memory accesses. Of particular interest to us are accesses to shared memory — we recognize those when a thread touches a memory location that’s already been touched by another thread. Those shared memory accesses are our cue points — that’s where we cut the tape.

The idea is that the fragments between shared-memory accesses have no dependency on what other threads are doing, so any interleaving between them will have no impact on program behavior. What matters is the order in which the shared memory accesses happen, and their re-arrangements may produce distinct observable behaviors.

Since in a typical concurrent program there aren’t that many shared variables, the number of possible rearrangements is quite manageable.

Let’s go back to our example and study it in more detail. The simplest implementation of a mutex is based on one shared memory location, let’s call it l, and the accesse to it goes through some version of the exchange instruction (for instance LOCK CMPXCHG), which we will simply call xchg. From the point of Jinx, both lock and unlock look like xchg l. There are two more shared variables, circle.r and circle.c.

Suppose that, during the initial exploration, threads A and B executed their respective accesses to Circle. As I explained earlier, it’s very unlikely that thread B would hit the vulnerability window, so the execution proceeded without conflict and Jinx recorded all accesses to shared variables.

Initial exploration: Thread B makes its call to GetArea far outside the vulnerability window. Only accesses to shared memory are represented in this diagram.

Let’s concentrate on the shared variable l used by the mutex. Thread A accessed l four times, so there are four distinct locations to which thread B’s first access to l could be moved (this is done by selectively delaying some threads). Jinx might therefore run four possible simulations involving l. Here’s the first one:

Simulation 1: Thread B takes the lock before thread A's first access to l. Notice that thread A has to spin (the xchg instruction is repeated three times). It has to wait for thread B to release the lock. Thread B correctly calculates the old area of the circle.

Notice an interesting fact: The simulated execution is not just a simple splicing of two recorded tapes. Unlike in the original execution, when thread A tries to enter the critical section it is blocked by thread B and has to spin. A new choice of interleaving often leads to a different execution.

The second and third simulations result in the bug manifesting itself.

Simulation 2: Thread B accesses l between thread A's first and second access to l. This time thread B has to spin until thread A releases the lock. After that, thread A has to spin until thread B releases the lock. This simulation hits the bug -- thread B sees the new value of r and the old value of c.

Simulation 3: Thread B accesses l between thread A's second and third access to l. This simulation also hits the bug.

The fourth simulation misses the vulnerability window.

Simulation 4: Thread B accesses l between thread A's third and fourth access to l. Thread B has to spin, but the bug is not hit -- the new area of the circle is correctly calculated.

This is not bad. Suddenly the likelihood of producing buggy behavior shot up substantially.

Let’s do another back-of-the-envelope calculation. Assume that during each simulation round Jinx samples about 1ms of program execution and works on it for roughly 100ms. In order not to slow the program too much, Jinx may perform, say, 5 simulations rounds per second.

The probability that both accesses from our example will fall within the 1ms sampling period is 10-3. Jinx can perform the 103 simulation rounds necessary to reproduce the bug in 200 seconds or slightly more than 3 minutes. Compare this with the 10 days of unassisted testing we got previously.

What Happens Next?

Suppose that, after running for about 3 minutes, Jinx found a simulation that manifested a concurrency bug. The question is: What are the consequences of this bug? If the bug causes an assertion to fire, or results in an access fault within the same 1ms simulation period, Jinx will immediately re-run the same simulation in real time and cause the program to crash, thus proving beyond reasonable doubt that there was indeed a bug in the program.

But even if Jinx cannot trip the program, it will still rate different simulations according to its own heuristics, and it will run the one that scores the highest. In our example, even if the five simulations (including the original run) are rated the same, Jinx will pick one of them with the probability 1/5. Since two of them expose the bug, Jinx should be able to hit one in less than 10 minutes. Although the program won’t crash immediately, it will be running with corrupt data, which will eventually be discovered.

Conclusion

A dynamic concurrency debugger works by rearranging thread interleavings. In general, this method leads to an exponential explosion. Jinx uses two innovative techniques to make this approach feasible: It runs multiple simulations in parallel using virtual machine technology, and it rearranges thread timings based on shared-memory communications. The result is a concurrency bug accelerator that can speed testing up by orders of magnitude while running a program at almost normal speed.

Recently we’ve had yet another discussion at work about the definition of data race. You’d think that being in the business of detecting data races, we should know what a data race is. Unfortunately, it’s not so simple. Defining a data race is one of the harder problems faced by the designers of memory models (and concurrency bug checkers). Java had it figured out some time ago, and now C++11 has its own definition. But our product, Jinx, is language agnostic–it catches data races at the processor level.

The problem is that there is no one-to-one mapping between data races as defined by a higher-level language and the object code produced by the compiler. Consider a lock-free algorithm written in C++ using weak atomics (e.g., using memory_order_relaxed operations on atomic variables). When compiled for the x86 processor, the assembly instructions might look identical to those generated without atomics. But from the point of view of C++, the first version is race-free and the second is full of races. In this case the role of atomics is reduced to being nothing more than programmer’s annotation that says, “I know what I’m doing.” (And also, “This code is portable.”)

On the other hand, neither is there one-to-one correspondence between data races, as defined by a higher-level language, and concurrency bugs. Sometimes looking at how the code is actually executed on a multicore processor may flag concurrency errors that don’t manifest themselves as data races at the source level. And then there are programs that mix high-level languages and inline assembly, for which high-level analysis is impossible.

The purpose of this blog post is to gain some insight into data races at the processor level. I’ll start with the description of the x86 memory model and a definition of data race. I’ll explain the relationship between data races and sequential consistency. Then I’ll show you an example of a Linux spinlock, which contains a deliberate data race. This race can be classified as a non-triangular data race. I’ll explain what a triangular data race is and why it might be important in analyzing program execution.

The x86 Memory Model

In order to understand data races at the lowest level you have to dig into a processor’s memory model. For the x86, both Intel and AMD produced documents that provide some guidance, but fall short of defining a formal model. It took a lot of research and double guessing before academics and Intel engineers agreed on one particular model called Total Store Order (TSO).

The gist of this model is that, at least conceptually, all writes to memory from a particular core go through this core’s write buffer, which is invisible to other cores. The contents of this buffer find its way to global memory in FIFO order at some random intervals or as a result of special instructions. These special instructions are either LOCK’d instructions (the ones with the LOCK prefix) or fences (actually, only MFENCE is relevant here, because the model covers regular write-back memory, as opposed to write-combining memory or non-temporal instructions). Those instructions flush the buffer of the core that is executing them. It’s pretty surprising that such a simple model explains all the strange behaviors of the x86 processor with respect to memory loads and stores.

Total Store Order processor memory model

A few more details: Several x86 instructions are modeled as a series of actions (see our earlier blog entry). In particular, a LOCK’d instruction can be split into smaller constituents:

  1. LOCK, which flushes the buffer and locks access to global memory for all cores,
  2. loads, stores, and arithmetic actions specific to a particular instruction,
  3. UNLOCK, which flushes the buffer and unlocks access to global memory.

For instance, the execution of:

LOCK XCHG [x], EAX

may be modeled by:

  1. flushing the local write buffer,
  2. acquiring the lock on the bus,
  3. loading x from memory to a temporary register,
  4. writing EAX to x (through the local write buffer),
  5. copying the value from the temporary to EAX,
  6. flushing the write buffer (thus forcing the new value of x to be written to global memory), and
  7. releasing the bus lock.

Keep in mind that this is an idealized model, not necessarily corresponding to actual architecture or microcode.

x86 Data Races

Let’s start with the simplest definition of a data race and build things up from there. Imagine that you are single-stepping through a program that has multiple threads running on multiple cores. At each step you pick one core and execute the next instruction in its thread. For complex (but not LOCK’d) instructions, like INC[x], you might execute just one of its actions, e.g, the load of [x], at a time. This kind of step-by-step execution is called sequentially consistent, as long as all stores go directly to global memory.

A data race occurs if you can pick two actions accessing the same memory location on two separate cores to be executed next to each other, and at least one of them is a store. Notice that if these two actions can be made adjacent in a global execution order, they can also be executed in the reverse order. Now, if both actions were just memory loads, the order wouldn’t matter. This is the reflection of a more general principle that you can never have data races on immutable data. But if one of the accesses is a store, the order does matter.

This definition of data race is the foundation of dynamic race detectors, including Jinx. Jinx virtually (that is, using a virtual machine technology) re-runs parts of your program in a sequentially consistent manner, rearranging the threads in order to, among other things, bring shared memory accesses next to each other. If the program is properly synchronized, this should be impossible. For instance, one of the threads will be blocked waiting for a lock (this time I’m talking about higher-level locks, or critical sections) while the other is accessing a shared variable under the same lock.

Of course, the next question is about the locks themselves: They are implemented using the same instructions as the rest of the code, and use shared variables, so how come they don’t generate their own data races? Some race detectors recognize a few predefined patterns of instructions that correspond to locks in a particular language, library, or operating system. Again, Jinx tries to be agnostic.

Maybe unsurprisingly, higher level locks are implemented using LOCK’d instructions. So if we narrow the definition of data races to exclude conflicts between locked instructions, we should be fine. But are we allowed to play with definitions? How do we decide what is and what isn’t a data race? Are there some general principles to give us guidance? There is one I mentioned before called…

Sequential Consistency

Ideally, we would like to define data races so that they are always concurrency bugs. This isn’t easy, because we don’t have a definition of a bug. In a higher-level language, like C++, it’s enough to say that a data race leads to undefined behavior. But at a processor level, everything is defined. A processor won’t suddenly stop and say, “I don’t know what to do with this program.”

However, higher level languages give us a hint in terms of the DRF guarantee. DRF stands for Data-Race Freedom, and the guarantee is that if your program is free of data races, it is sequentially consistent (SC). Mind you, an SC program might still be buggy, and even have concurrency bugs, but at least we know how to reason about such programs. We run them in our minds, one action at a time, switching between threads at will, assuming that all writes go directly to main memory and are immediately visible to other threads. We know that this is not what really happens, but if a program is DRF, this is how it behaves.

Not all languages offer the DRF guarantee; C++ supports weak atomics that break SC without introducing data races. Nevertheless, DRF is a good goal.

What does it all mean for the x86? The definition of SC stands at the assembly level, although we have to allow for splitting complex instructions. For instance, INC[x] could be split into three actions: a load, an increment, and a store. If this instruction doesn’t have a LOCK prefix, another thread is free to execute its own actions in between the actions of the current instruction.

A TSO processor, like the x86, would be almost SC, if it weren’t for those pesky write buffers. Still, many memory-access conflicts on an x86 don’t break sequential consistency. For instance, because of the FIFO nature of the write buffers, two conflicting stores don’t break SC, and therefore are not considered a data race. Think of it this way: Inverting two adjacent stores from different threads doesn’t change anything because both stores go to separate write buffers. The external visibility of those stores depends on the order in which the buffers are discharged into global memory, and they are discharged in a sequentially consistent way.

So the only races on the x86 are those between loads and stores, except when both are LOCK’d (in which case they flush their buffers). That’s a good starting point, but a practical question remains: If we report all of those events as data races, how many of them will be considered false positives in real life? Unfortunately, the answer is: too many. It turns out that an efficient implementation of higher-order locks often contains deliberate races. It’s true that the acquisition of a lock usually starts with a LOCK’d intruction — often a CMPXCHG, also known as CAS– but the corresponding release might be implemented using an unsynchronized store. This is why the Jinx definition of a data race excludes conflicts in which even one of the instructions is LOCK’d.

Do we miss some legitimate data races because of that? Perhaps, but we haven’t seen such examples in practice yet. Interestingly, even this narrowed definition of data races flags some legitimate lock-free algorithms, in particular the Linux ultra-fast implementation of a spinlock.

Spinlock

Here’s an actual report produced by Jinx on Linux (Jinx can be used to debug the kernel):

Event: Data Race (3 times)
* Stack A
#0 0xffffffff81036d44 in __ticket_spin_lock at spinlock.h:65
#1 0xffffffff8159e7ee in arch_spin_lock at paravirt.h:744
#2 0xffffffff810909cd in futex_wake at futex.c:891
#3 0xffffffff81093288 in do_futex at futex.c:2602
#4 0xffffffff810933fb in sys_futex at futex.c:2636
* Stack B
#0 0xffffffff81036d74 in __ticket_spin_unlock at spinlock.h:101
#1 0xffffffff81090a58 in arch_spin_unlock at paravirt.h:760
#2 0xffffffff81093288 in do_futex at futex.c:2602
#3 0xffffffff810933fb in sys_futex at futex.c:2636

Two threads, A and B, ran into a race in spinlock.h. One access occurred at line 65 and the other at line 101 of that file. The two stack traces show that the caller, in both cases, was a futex, or a “fast mutex”– a Linux implementation of a thin lock.

Let’s have a look at spinlock.h. Line 65 is inside the function __ticket_spin_lock, which contains a bunch of inline assembly, which I’m not going to analyze here (although I will analyze similar code shortly):

 61 static __always_inline void __ticket_spin_lock(arch_spinlock_t *lock)
 62 {
 63     short inc = 0x0100;
 64
 65     asm volatile (
 66         LOCK_PREFIX "xaddw %w0, %1\n"
 67         "1:\t"
 68         "cmpb %h0, %b0\n\t"
 69         "je 2f\n\t"
 70         "rep ; nop\n\t"
 71         "movb %1, %b0\n\t"
 72         /* don't need lfence here, because loads are in-order */
 73         "jmp 1b\n"
 74         "2:"
 75         : "+Q" (inc), "+m" (lock->slock)
 76         :
 77         : "memory", "cc");
 78 }

The thing to notice is that this is the acquisition of a (ticketed) spinlock, the first instruction of which is indeed LOCK’d. Since Jinx doesn’t flag races with LOCK’d instructions, it must be the next instruction that accesses memory, movb, which is involved in the race. It reads a byte-sized value from memory without using a LOCK.

The other racing line, 101, is inside __ticket_spin_unlock:

 99 static __always_inline void __ticket_spin_unlock(arch_spinlock_t *lock)
100 {
101     asm volatile(UNLOCK_LOCK_PREFIX "incb %0"
102          : "+m" (lock->slock)
103          :
104          : "memory", "cc");
105 }

This is the release part of the ticketed spinlock, and it contains an increment instruction that accesses the memory (the same lock->slock variable as in the previous function). The macro, UNLOCK_LOCK_PREFIX, expands to nothing on processors other than Pentium Pro (which had an error). So we do have an unprotected store (part of incb) that races with an unprotected load (movb in the previous function). And yet, the code is correct. But to understand that, we need to learn about a more sophisticated notion of…

Triangular Data Races

Let’s go back to the x86 TSO memory model. The cores write into their private FIFO buffers. Data moves from these buffers into main memory. At any point in time only one buffer may discharge a previous write entry into memory, so those writes end up interleaved with each other. What’s important is that all processors see those writes appearing in main memory in the same order — the Total Store Order. (To be precise, the processor doing the write will see it immediately due to intra-processor forwarding, but that doesn’t matter in our case.)

Now imagine that you have an actual execution on the x86 and you want to show that it’s equivalent to an SC execution. You could do it, for instance, by rearranging instructions so that stores are moved up to the point where they were originally discharged into global memory. In order to preserve program order on individual processors, you would also have to move up the loads (and other instructions) that appear between those stores. But if you move a load forward too much, it might see a different store than it did in the original execution, and the new SC execution will not be equivalent to the original one.

A typical situation is when stores and loads cross each other, as in this program (x and y originally zero):

P1 P2
mov [x], 1 mov [y], 1
mov eax, [y] mov eax, [x]

If stores from both processors still sit in their respective buffers while the loads are performed, both eax registers will end up with zeros. If we try to move the stores forward to the point where the buffered writes were flushed, we’ll also have to push the loads, and they will end up reading ones instead of zeros. This is clearly a non-SC execution and the races it contains are definitely of interest to us.

Rearranging stores to create an equivalent execution with no buffering. Because program order must be preserved, the loads are rearranged too, resulting in different values being read. The SC execution is not equivalent to the original one. (Time flows from left to right.)

It turns out that all violations of SC on the x86 have one thing in common–a triangular data race. This is a triangular pattern (We have two of those in our example.):

P1 P2 Comment
mov [x], 1 Preceding store
No lock or fence
mov eax, [y] mov [y], 1 The race between a load and a store

More generally, a triangular data race occurs between a load action on one processor and a store action on another if the load is preceded by some other store. In the case above we have a race on y, and what makes it triangular is the odd store to x. The key is that this store may still sit in the write buffer when the racing load is made. If, on the other hand, there is any fence or LOCK/UNLOCK between the odd store and the racing load, we are fine, the race is not triangular.

What’s amazing is that it can be proven that any x86 program that doesn’t contain triangular data races is sequentially consistent. (For a formal definition of a triangular data race and a proof of the theorem, see Owen.) Armed with this knowledge, let’s revisit the spinlock example.

Spinlock and Triangular Races

The Linux ticketed spinlock is an elaboration on a basic spinlock. Here’s the (more readable) Intel assembly code for that simplified spinlock. There is a variable, lck, on which to spin, and its initial value is 1, corresponding to the lock being unclaimed. The address of lck is stored in eax.

Label Instruction Comment
acquire: lock dec [eax] atomic {
tmp = [lck] – 1
[lck] = tmp
flag = (tmp >= 0)
flush local write buffer }
jns enter if (flag) goto enter
spin: cmp [eax], 0 flag = ([lck] <= 0)
jle spin if (flag) goto spin
jmp acquire goto acquire
enter: the critical section starts here

To acquire the lock, we first atomically decrement lck. If the lock was free, the decremented value should be non-negative (actually, zero). If it isn’t, we start spinning, waiting for lck to become positive, and then try to acquire it again.

To release the lock, we set lck back to 1:

release: mov eax, 1 [lck] = 1

Notice that the load part of the instruction cmp [eax],0 is in a data race with mov eax,1. This is the race that is detected by Jinx.

But does this race break sequential consistency? In order to break SC, the race would have to be triangular, which means that the racing load would have to be preceded by some other store that’s not flushed. But the last store before entering the spin loop is part of the LOCK’d instruction, lock dec [eax], which flushes the write buffer. Since there is no triangular race, the spinlock is indeed sequentially consistent.

Conclusion

It’s eye opening to realize how much wiggle room there is in the definition of data race. We have the informal definition: Two simultaneous accesses to the same memory location, one of them being a write. Except that now we have to define “simultaneous,” which is a bit tricky. Simultaneity is characterized by lack of ordering–two events are simultaneous if it’s impossible to tell which came before the other.

Events on different processors may be ordered through synchronizing actions. At the lowest level, these are LOCK’d instructions and fences. However, there is no simple mapping between high-level-language definitions of data races and what is observed at the processor level during execution.

Ultimately, the reason for defining data races is to be able to identify one type of concurrency bugs. But bug detection is in general undecidable, so the best we can do is to define behaviors that are most likely related to programmers’ errors. Most programmers reason about concurrent programs using the sequentially consistent model of execution. This is why the most insidious concurrency bugs are related to the breakdown of sequential consistency. There is a linkage between data races and (the lack of) sequential consistency, which is expressed in terms of the DRF guarantee: A program with no data races behaves in a sequentially consistent way.

We can reverse the reasoning and define data races in a way that would guarantee the DRF property, and this is what is usually done. Seen from that perspective, it’s possible to strengthen the DRF guarantee on TSO processors. The new TRF (Triangular Race Freedom) guarantee states that a program with no triangular data races behaves in a sequentially consistent way and vice versa.

We may reasonably assume that non-triangular data races are not bugs but rather were deliberately coded, either directly by programmers (as was the case with the Linux spinlock), or by a compiler translating a sequentially consistent high-level program. The jury is still out, though, because outside of lock-free programming any race, triangular or not, is still very likely to be a bug.

Acknowledgments

I’d like to thank Anthony Williams, Dmitriy V’yukov, and my co-workers for valuable feedback.

Bibliography

  1. Scott Owens, Susmit Sarkar, Peter Sewell, A better x86 memory model: x86-TSO
  2. Scott Owens, Reasoning about the Implementation of Concurrency Abstractions on x86-TSO

An acquaintance recently observed that his company was concerned more with correctness bugs than with crashing bugs. For them, correctness bugs are more painful to debug than crashing bugs and they also get worse when you make the leap to shared-memory parallel programming (of which threaded programming is one instance). This is a pretty typical story, and it’s worth considering why correctness bugs are so problematic. Of particular interest is why shared-memory parallel programming makes them so much worse.

Corruption is worse than crashing

If a program stops and complains, that’s one thing: an automated system or a human can just start it again. There might be real-time or reliability goals for the program that aren’t being met, but at least you know something went wrong. If it keeps on going, but corrupts the output in some way, there’s a much bigger problem. Most software is based on an implicit assumption of the fail-stop model. This just means that errors in the code stop the program’s forward progress before erroneous data can become nonvolatile: written to disk or communicated to another process. You can see examples of applications that make this assumption all the time, when they trashed their registry entries or made their tables inconsistent, and consequently can’t run any more. At least one study finds that 7% of bugs aren’t fail-stop bugs… that is they produce bad outputs rather than crashing or stopping.

Correctness bugs are hard to fix, hard to detect, and costly

In my acquaintance’s domain, correctness bugs result in subtly wrong graphics. There, the fault tends to get detected, but the error-resolution process is lengthy, as much more of the codebase is suspect. These bugs are detected later in the development process, because earlier stages lack sufficient verification to detect corruption, but crashes are always obvious. In other domains, correctness bugs could mean incorrect chances of success when drilling a test well, an incorrect diagnosis, a wrongly sequenced chromosome, a corrupted database, poor search results, or an ill-advised financial trade. In some of the worst cases, humans or machines take the data out of computation and start committing real-world resources to the results of the computation. Things get really expensive when errors in computation get into nonvolatile storage inside people’s brains.

Some of these bugs are eventually detected, as when you compute an incorrect launch window for a rocket. Others, like choosing not to drill a test well in a fruitful spot, will likely never be detected. The hidden and obvious economic drags of correctness bugs include unhappy customers, missed opportunities, and wasted efforts.

Correctness bugs are more horrible as your software gets more non-deterministic

These bugs get more insidious when you make the leap to shared-memory parallel programming. Whereas in the single-threaded, synchronous world, a given input to a program always yielded the same output, you’re now faced with programs that may give a correct output only a certain percentage of the time. You expect 100%, but it’s not generally possible to prove that a shared-memory parallel program produces deterministic results. This causes trouble for crashing bugs because it makes repros rarer, and allows bugs to creep further through the development process before detection. It also renders debuggers less useful… You can’t just step through until you hit the bug, because sometimes the bug happens, and sometimes it doesn’t, and often the debugger stops it from happening.

The problem is much worse in the case of correctness bugs: both correctness and non-deterministic bugs tend to get detected late in development, and combined, they appear very late. Users of your code can act upon the results because they’re not crashing bugs, and the bug can slip through your test because it’s non-deterministic, even if you have a test case that could have caught the bug! Input coverage isn’t enough any more. How do we deal with this problem?

Dealing with the Problem

Rough classification of programming bugs. Adding assertions to your program can turn correctness bugs into crashing bugs. Limiting shared mutable state reduces non-determinism.

The forces of non-determinism and corruption!

There are three big parts to the answer to this problem. They don’t make the problem go away. They just stop your life from becoming a hell of customer escalations and expensive round-trips to QA.

Choose a simple parallel programming model

First, the way you use multiple cores will change the amount of non-determinism in your software. All the parts of your program that can avoid shared-mutable memory should probably avoid it. For most applications, you’ll never eliminate non-determinism altogether. After all, what is input if not an access of shared-mutable state? But, you can try to guarantee that like inputs yield like outputs by being careful about the sorts of shared-memory parallel programming you do.

Turn your correctness bugs into crashing bugs

Next, increase the likelihood of your bugs being crashing bugs, rather than correctness bugs. The trusty assert() is your friend here. Ship your software with your asserts enabled, and don’t tolerate failing asserts. They tell you where the bug was, and what it was. When you get that bug report from the field, you know exactly what you need to fix. Programs that need to continue to function in the face of failure can use software fault-tolerance approaches like running every computation twice and comparing results, with an optional tie-breaker. Some problems are intrinsically easy to verify, like factoring an integer. Most are not. During test you can use late-stage output verification to achieve fail-stop. Make sure everything does this. By doing this, you’ll change more bugs into being crashing bugs, rather than correctness bugs.

Amplify your testing

Lastly, amplify your non-deterministic bug-find rate. If you expect to deploy software that will run at a rate of tens of thousands of machine-hours per hour, that is, you have tens of thousands of users, or a few users using thousands of computers, you won’t match their ability to find non-deterministic bugs in software without amplifying your bug-find rate. This is a potential success disaster: shipping lots of software will make you realize how buggy it is.

Our goal with Jinx is to let the developer use one computer to test as thoroughly as thousands would, and to let QA engineers make one test pass count for a thousand. To learn more about what we’re doing with Jinx, check out our website!

Can a data race not be a bug? In the strictest sense I would say it’s always a bug. A correct program written in a high-level language should run the same way on every processor present, past, and future. But there is no proscription, or even a convention, about what a processor should (or shouldn’t) do when it encounters a race. This is usually described in higher-level language specs by the ominous phrase: “undefined behavior.” A data race could legitimately reprogram your BIOS, wipe out your disk, and stop the processor’s fan causing a multi-core meltdown.

Data race: Multiple threads accessing the same memory location without intervening synchronization, with at least one thread performing a write.

However, if your program is only designed to run on a particular family of processor, say the x86, you might allow certain types of data races for the sake of performance. And as your program matures, i.e., goes through many cycles of testing and debugging, the proportion of buggy races to benign races keeps decreasing. This becomes a real problem if you are using a data-race detection tool that cannot distinguish between the two. You get swamped by false positives.

Microsoft Research encountered and dealt with this problem when running their race detector called DataCollider on the Windows kernel (see Bibliography). Their program found 25 actual bugs, and almost an order of magnitude more benign data races. I’ll summarize their methodology and discuss their findings about benign data races.

Data Races in Windows Kernel

The idea of the program is very simple. Put a hardware breakpoint on a shared memory access and wait for one of the threads to stumble upon it. This is a code breakpoint, which is triggered when the particular code location is executed. The x86 also supports data breakpoints, which are triggered when the program accesses a specific memory location. So when a thread hits the code breakpoint, DataCollider installs a data breakpoint on the location the thread was just accessing. It then stalls the current thread and let all other threads run. If any one of them hits the data breakpoint, it’s a race (as long as one of the accesses is a write). Consider this: If there was any synchronization between the two accesses, the second thread would have been blocked from accessing that location. Since it wasn’t, we have a classic data race.

Notice that this method might not catch all data races, but it doesn’t produce false positives. Except, of course, when the race is considered benign.

There are other interesting details of the algorithm. One is the choice of code locations for installing breakpoints. DataCollider first analyzes the program’s assembly code to create a pool of memory accesses. It discards all thread-local accesses and explicitly synchronized instructions (for instance, the ones with the LOCK prefix). It then randomly picks locations for breakpoints from this pool. Notice that rarely executed paths are as likely to be sampled as the frequently executed ones. This is important because data races often hide in less frequented places.

Pruning Benign Races

90% of data races caught by DataCollider in the Windows kernel were benign. For several reasons it’s hard to say how general this result is. First, the kernel had already been tested and debugged for some time, so many low-hanging concurrency bugs have been picked. Operating system kernels are highly optimized for a particular processor and might use all kinds of tricks to improve performance. Finally, kernels often use unusual synchronization strategies. Still, it’s interesting to see what shape benign data races take.

It turns out that half of false positives came from lossy counters. There are many places where statistics are gathered: counting certain kinds of events, either for reporting or for performance enhancements. In those situations losing a few increments is of no relevance. However not all counters are lossy and, for instance, a data race in reference counting is a serious bug. DataCollider uses simple heuristic to detect lossy counters–they are the ones that are always incremented. A reference counter, on the other hand, is as often incremented as decremented.

Another benign race happens when one thread reads a particular bit in a bitfield while another thread updates another bit. A bit update is a read-modify-write (RMW) sequence: The thread reads the previous value of the bitfield, modifies one bit, and writes the whole bitfield back. Other bits are overwritten in the process too, but their new values are the same as the old values. A read from another thread of any of the the non-changed bits does not interfere with the write, at least not on the x86. Of course if yet another thread modified one of those bits, it would be a real bug, and it would be caught separately. The pruning of this type of race requires analysis of surrounding code (looking for the masking of other bits).

Windows kernel also has some special variables that are racy by design–current time is one such example. DataCollider has these locations hard-coded and automatically prunes them away.

There are benign races that are hard to prune automatically, and those are left for manual pruning (in fact, DataCollider reports all races, it just de-emphasizes the ones it considers benign). One of them is the double-checked locking pattern (DCLP), where a thread makes a non-synchronized read to be later re-confirmed under the lock. This pattern happens to work on the x86, although it definitely isn’t portable.

Finally, there is the interesting case of idempotent writes— two racing writes that happen to write the same value to the same location. Even though such scenarios are easy to prune, the implementers of DataCollider decided not to prune them because more often than not they led to the uncovering of concurrency bugs. Below is a table that summarizes various cases.

Benign race Differentiate from Pruned?
Lossy counter Reference counting Yes
Read and write of different bits Read and write of the whole word Yes
Deliberately racy variables Yes
DCLP No
Idempotent writes No

Conclusion

In the ideal world there would be no data races. But a concurrency bug detector must take into account the existence of benign data races. In the early stages of product testing the majority of detected races are real bugs. It’s only when chasing the most elusive of concurrency bugs that it becomes important to weed out benign races. But it’s the elusive ones that bite the hardest.

Bibliography

  1. John Erickson, Madanlal Musuvathi, Sebastian Burckhardt, Kirk Olynyk, Effective Data-Race Detection for the Kernel