Analyzing Race Conditions With UML Sequence Diagrams
Concurrency Is Hard
Sooner or later, every software engineer faces concurrent code execution.
Where there is concurrency, there are race conditions.
Debugging race conditions can be fun at times, but it’s mostly annoying and painful due to their inconsistent nature.
The best way to avoid debugging race conditions is to eliminate them at the design stage.
In this post, I will show you a simple trick that will help you expose race conditions using UML sequence diagrams.
Example: Incrementing Non-Atomic Value From Several Threads
Let’s take a look at the following code example that might come up in a job interview when you’re asked about multithreading.
#include <iostream>
#include <thread>
#include <vector>
#include <chrono>
int main() {
int counter{0};
std::vector<std::thread> threads{};
for (size_t i = 0; i < 2; i++) {
threads.emplace_back([&counter] {
for (int i = 0; i < 100000; i++)
{
counter++;
}
});
}
for (auto& thread : threads) {
thread.join();
}
std::cout << "counter value is " << counter << std::endl;
return 0;
}
In this program, two threads are spawned, and each one increments a non-atomic counter in a loop. The result ends up somewhere between 100,000 and 200,000.
This behavior is caused by a race condition between the threads because
counter++
is not an atomic operation in the general case.
Now, let’s expose this race condition.
UML it!
Initial Representation
The UML sequence diagram of the code above might look like this:
Taking into account that the increment isn’t atomic in our case, we split it into several operations in the diagram.
Sequence “Unrolling”
The diagram above is an accurate enough representation of what is happening in the code, but to analyze potential timing issues, we need to modify it.
Now, we will represent the sequence in an “unrolled” view:
now we remove par
section:
then we unroll the loop
:
and the final step is starting moving arrows to see which combinations may occur:
We can clearly see that there is a case where the same value is read by two threads, and each one increments the same value before storing it.
I’ve represented only one case here as an example, but all possible cases should be analyzed when designing software.
Conclusion
As you can see, the “unrolling” is very straightforward, easily implemented, and doesn’t require special skills.
We can save hours of design time and spend months fixing the implementation.
Yes, it may be time-consuming, but the benefit of addressing it during the design stage is the time saved by not having to debug inconsistent behavior later and the reduced cost of implementing a reliable solution.