Lab 2.5: Concurrency Debugging Lab
Objectives
In previous Labs, we have been practicing "how to write concurrent code correctly". This Lab flips the script—we are facing code that is already written, but it contains bugs. Your task is not to implement from scratch, but to use tools and methodologies to locate issues, understand root causes, fix them, and perform regression verification.
This Lab provides five "intentionally broken concurrent programs," each containing a known type of concurrency defect—data race, lost wakeup, deadlock, use-after-free, and false sharing performance pitfalls. You need to go through the complete debugging loop of "locate → hypothesize → verify → fix" without looking at the answers first. The code structure reuses the patterns you are familiar with from Labs 0–2 (thread pools, queues, atomic counters), so the learning curve is low, and you can focus entirely on the debugging process itself.
Prerequisites
Before starting, ensure you have read the following chapters:
- ch08-01: Concurrent program debugging techniques — TSan, helgrind, custom logging
- ch08-02: Concurrent performance testing and benchmarking — perf, performance analysis methods
- Lab 0–2: Understand the basic structure of
std::jthread,std::atomic, andstd::latch
Environment Setup
The core of this Lab is toolchain configuration. You need the following tools:
- TSan: Compile with GCC/Clang using
-fsanitize=thread - helgrind: Part of Valgrind,
valgrind --tool=helgrind - perf: Linux performance analysis tool,
perf statandperf record
Install Valgrind (if not already installed):
sudo apt install valgrindDebugging Methodology
Before diving into each buggy program, let's establish a unified debugging workflow. Every time you encounter a concurrency issue, proceed with the following steps:
Step 1: Confirm if it is really a concurrency issue. Run the same logic with a single thread. If the result is correct, then it is indeed a defect introduced by concurrency.
Step 2: Narrow down the reproduction scope. Reduce the number of threads, data volume, and run iterations to find the minimal reproduction path. Smaller reproduction code is easier to locate.
Step 3: Select the right tool. Use TSan for data races, helgrind or std::scoped_lock for deadlocks, and perf for performance anomalies.
Step 4: Interpret the reports. What do "previous write" and "current read" in a TSan report mean? How do you read the lock order graph in a helgrind report?
Step 5: Regression after fixing. Don't just "run it once." Run the full test suite under TSan to ensure the problem is completely eliminated.
Bug 1: Data Race on Shared Counter
Symptoms
Multiple threads modify the same int counter without locks, and the final result does not equal the expected value. Running it multiple times yields different results each time.
Defective Code
Expand (21 lines)Collapse
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
int main() {
int counter = 0; // Data race: non-atomic shared variable
std::vector<std::jthread> threads;
for (int i = 0; i < 8; ++i) {
threads.emplace_back([&counter] {
for (int j = 0; j < 1000000; ++j) {
counter++; // Unsafe concurrent write
}
});
}
// threads join automatically here (RAII)
std::cout << "Final counter: " << counter << std::endl;
return 0;
}Debugging Tasks
- Run the program and record the difference between the actual output and the expected value.
- Run with TSan and interpret the data race location indicated in the report.
- Fix the code (replace
intwithstd::atomic<int>). - Choose the appropriate memory order (hint:
std::memory_order_relaxedis sufficient for pure counting). - Perform regression verification with TSan.
Verification
After the fix, running 10 times consecutively should yield results equal to 8 * 1000000 (8,000,000). TSan should report no errors.
Bug 2: Lost Wakeup
Symptoms
The producer calls notify_one() before the consumer enters wait(), causing the consumer to block permanently. The program hangs.
Defective Code
Expand (30 lines)Collapse
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
void producer() {
std::lock_guard<std::mutex> lock(mtx);
ready = true;
cv.notify_one(); // Notification happens here
// If consumer hasn't started waiting yet, the signal is lost
}
void consumer() {
std::unique_lock<std::mutex> lock(mtx);
// Lost wakeup: if notify_one() was called before this line,
// this wait will block forever.
cv.wait(lock, [] { return ready; });
std::cout << "Processing data..." << std::endl;
}
int main() {
std::jthread prod(producer);
// Simulate timing: sleep to ensure producer runs first
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::jthread cons(consumer);
}Debugging Tasks
- Run the program multiple times and observe if it hangs every time (depends on thread scheduling).
- Use timeout logging to assist diagnosis: add a
wait_fortimeout tocv.wait(), and print a log if it times out. - Fix the code: Change
cv.wait(lock)tocv.wait(lock, predicate). - Explain why predicate waiting can solve both spurious wakeups and lost wakeups.
Verification
After the fix, the program should exit normally within 1 second. Try different thread start orders to ensure it runs correctly.
Bug 3: Deadlock from Lock Ordering
Symptoms
Two threads acquire two mutexes in reverse order. Under specific scheduling, a deadlock occurs. The program hangs.
Defective Code
Expand (26 lines)Collapse
#include <iostream>
#include <mutex>
#include <thread>
std::mutex mtx_a;
std::mutex mtx_b;
void thread1() {
std::lock_guard<std::mutex> lock_a(mtx_a);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard<std::mutex> lock_b(mtx_b); // Potential deadlock point
std::cout << "Thread 1 acquired both locks" << std::endl;
}
void thread2() {
std::lock_guard<std::mutex> lock_b(mtx_b); // Locks B first
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard<std::mutex> lock_a(mtx_a); // Potential deadlock point
std::cout << "Thread 2 acquired both locks" << std::endl;
}
int main() {
std::jthread t1(thread1);
std::jthread t2(thread2);
// Deadlock: t1 holds A, wants B; t2 holds B, wants A
}Debugging Tasks
- Run the program multiple times and observe if it occasionally hangs (deadlock requires a specific scheduling order).
- Run with helgrind:
valgrind --tool=helgrind ./your_program, and interpret the lock order conflict report. - Use
std::scoped_lockto acquire both locks simultaneously, eliminating the lock ordering issue. - Alternatively, unify the locking order for both threads (both A then B).
Verification
After the fix, it should not hang even after running 100 times consecutively. helgrind should no longer report lock order conflicts.
Bug 4: Use-After-Free in Detached Thread
Symptoms
A detached thread continues to access a local variable that has been destroyed. The program may crash, output garbage values, or behave normally—behavior depends entirely on timing.
Defective Code
#include <iostream>
#include <string>
#include <thread>
void task() {
std::string message = "Hello from background";
std::jthread t([&message] { // Capture by reference
// Risk: 'message' is destroyed when task() returns
// but this thread might still be running.
std::cout << message << std::endl;
});
t.detach(); // Detach the thread
} // 'message' is destroyed here, but the detached thread might still access it
int main() {
task();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
return 0;
}Debugging Tasks
- Run the program multiple times—sometimes output is normal, sometimes garbled, sometimes segmentation fault.
- Run with TSan and view the use-after-free report (TSan can detect accesses to freed memory).
- Fix the code: Use value capture instead of reference capture
[message], so the thread owns its own copy. - Alternatively, replace
detachwithjointo ensure the thread completes before the variable is destroyed.
Verification
After the fix, running 50 times consecutively should output "Hello from background" normally. TSan should report no errors.
Bug 5: False Sharing Performance Trap
Symptoms
Two threads modify atomic variables in adjacent memory locations. Performance is far below expectations. Functionally correct, but throughput is even lower than the single-threaded version.
Defective Code
Expand (36 lines)Collapse
#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
#include <vector>
struct Counter {
std::atomic<int> a;
std::atomic<int> b; // False sharing: 'a' and 'b' share the same cache line
};
Counter counter;
void worker_a() {
for (int i = 0; i < 10000000; ++i) {
counter.a.fetch_add(1, std::memory_order_relaxed);
}
}
void worker_b() {
for (int i = 0; i < 10000000; ++i) {
counter.b.fetch_add(1, std::memory_order_relaxed);
}
}
int main() {
auto start = std::chrono::high_resolution_clock::now();
std::jthread t1(worker_a);
std::jthread t2(worker_b);
auto end = std::chrono::high_resolution_clock::now();
std::cout << "Time elapsed: "
<< std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()
<< "ms" << std::endl;
}Debugging Tasks
- Run this version first and record the time taken.
- Fix: Add cache line padding between the two atomics (
alignas(64)or manual padding). - Use
perf statto observe the change in cache miss counts before and after the fix. - Compare the time taken before and after the fix and calculate the speedup.
The fixed structure should look like:
struct Counter {
alignas(64) std::atomic<int> a;
char padding[64 - sizeof(std::atomic<int>)]; // Manual padding
std::atomic<int> b;
};Verification
The time taken after the fix should be 2-5 times faster than before (depending on CPU architecture). Observing the cache-misses metric with perf stat should show a significant decrease.
Self-Check List
- [ ] All 5 bug programs located and fixed.
- [ ] For every fix, you can explain "why the original code fails under specific timing".
- [ ] Can distinguish the defect types that TSan and helgrind are good at detecting.
- [ ] Bug 1: Can explain why non-atomic
intis UB in multithreading. - [ ] Bug 2: Can explain how predicate waiting solves both spurious and lost wakeups.
- [ ] Bug 3: Can draw the resource allocation graph for the deadlock (circular wait).
- [ ] Bug 4: Can explain the lifetime risk of detach + reference capture.
- [ ] Bug 5: Can use
perfdata to demonstrate cache miss changes, not just "it got faster after padding". - [ ] All fixed code passes TSan without reports.