Skip to content

Commit 006259b

Browse files
authored
fix(tsan): atomic reads in findCallTrace to eliminate data race (#559)
1 parent b3f7d38 commit 006259b

2 files changed

Lines changed: 125 additions & 3 deletions

File tree

ddprof-lib/src/main/cpp/callTraceHashTable.cpp

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -232,10 +232,19 @@ CallTrace *CallTraceHashTable::findCallTrace(LongHashTable *table, u64 hash) {
232232

233233
u32 slot = probe.slot();
234234
while (true) {
235-
if (keys[slot] == hash) {
236-
return table->values()[slot].trace;
235+
// Use atomic load: keys[] can be written concurrently via CAS in put()
236+
// when a table is promoted to prev but still has in-flight insertions.
237+
u64 key = __atomic_load_n(&keys[slot], __ATOMIC_ACQUIRE);
238+
if (key == hash) {
239+
// Use acquireTrace() to pair with the RELEASE store in setTrace().
240+
// If still PREPARING, treat as not found: callers will create a new entry.
241+
CallTrace *trace = table->values()[slot].acquireTrace();
242+
if (trace == CallTraceSample::PREPARING) {
243+
return nullptr;
244+
}
245+
return trace;
237246
}
238-
if (keys[slot] == 0) {
247+
if (key == 0) {
239248
return nullptr;
240249
}
241250
if (!probe.hasNext()) {

ddprof-lib/src/test/cpp/stress_callTraceStorage.cpp

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2014,6 +2014,119 @@ TEST_F(StressTestSuite, HashTableSpinWaitEdgeCasesTest) {
20142014
EXPECT_LT(drop_rate, 0.5) << "Excessive trace drop rate: " << drop_rate;
20152015
}
20162016

2017+
// Regression test: TSan data race in findCallTrace() — keys[] plain reads
2018+
// raced with atomic CAS writes from concurrent put(). When a table is
2019+
// promoted to prev after expansion, threads that loaded the old table
2020+
// pointer before the CAS swap still write into it while new threads call
2021+
// findCallTrace(prev, hash) — the read must be atomic.
2022+
//
2023+
// Memory-ordering races on 8-byte aligned values are benign on x86_64 (the
2024+
// CPU guarantees naturally-atomic loads), so the race is only reliably
2025+
// detectable under ThreadSanitizer. The test is therefore skipped outside
2026+
// TSan builds to avoid giving false confidence that the regression is covered.
2027+
//
2028+
// The dedup assertion (dedup_mismatches == 0) validates findCallTrace
2029+
// correctness across expansion boundaries independently of memory ordering;
2030+
// it catches logic regressions in all build configurations.
2031+
TEST_F(StressTestSuite, FindCallTraceAtomicReadRaceTest) {
2032+
#if !defined(__SANITIZE_THREAD__) && !(defined(__has_feature) && __has_feature(thread_sanitizer))
2033+
GTEST_SKIP() << "TSan-only race regression: re-run with -fsanitize=thread";
2034+
#endif
2035+
2036+
// Fill the table to just below the 75% expansion threshold
2037+
// (INITIAL_CAPACITY = 65536; 75% = 49152).
2038+
const int FILL_TARGET = 48800;
2039+
const int NUM_THREADS = 16;
2040+
const int OPS_PER_THREAD = 400;
2041+
// Number of pre-filled entries to re-insert concurrently as a dedup check.
2042+
// After expansion these will be looked up via findCallTrace(prev, hash).
2043+
const int VERIFY_COUNT = 200;
2044+
2045+
void* aligned_memory = std::aligned_alloc(alignof(CallTraceHashTable), sizeof(CallTraceHashTable));
2046+
ASSERT_NE(aligned_memory, nullptr);
2047+
auto hash_table_ptr = std::unique_ptr<CallTraceHashTable, void(*)(CallTraceHashTable*)>(
2048+
new(aligned_memory) CallTraceHashTable(),
2049+
[](CallTraceHashTable* ptr) {
2050+
ptr->~CallTraceHashTable();
2051+
std::free(ptr);
2052+
}
2053+
);
2054+
CallTraceHashTable& hash_table = *hash_table_ptr;
2055+
hash_table.setInstanceId(42);
2056+
2057+
// Pre-fill single-threaded up to just below the expansion threshold,
2058+
// recording trace_ids for the last VERIFY_COUNT entries.
2059+
std::vector<u64> expected_ids(VERIFY_COUNT);
2060+
std::vector<ASGCT_CallFrame> verify_frames(VERIFY_COUNT);
2061+
2062+
for (int i = 0; i < FILL_TARGET; ++i) {
2063+
ASGCT_CallFrame frame;
2064+
frame.bci = i + 1;
2065+
frame.method_id = reinterpret_cast<jmethodID>(0x10000 + i);
2066+
u64 id = hash_table.put(1, &frame, false, 1);
2067+
if (i >= FILL_TARGET - VERIFY_COUNT) {
2068+
int vi = i - (FILL_TARGET - VERIFY_COUNT);
2069+
expected_ids[vi] = id;
2070+
verify_frames[vi] = frame;
2071+
}
2072+
}
2073+
2074+
std::atomic<bool> go{false};
2075+
std::atomic<uint64_t> successes{0};
2076+
// Counts put() calls that returned a trace_id different from the originally
2077+
// recorded id for a known pre-filled entry. A non-zero value means
2078+
// findCallTrace(prev, hash) returned nullptr when it should have returned
2079+
// the entry — either a logic bug or a memory-ordering failure.
2080+
std::atomic<uint64_t> dedup_mismatches{0};
2081+
std::vector<std::thread> workers;
2082+
2083+
// Half the threads insert new distinct frames to trigger expansion. They
2084+
// load the old table pointer before the CAS swap and write keys[] in prev,
2085+
// creating the concurrent-write side of the race window.
2086+
for (int t = 0; t < NUM_THREADS / 2; ++t) {
2087+
workers.emplace_back([&, t]() {
2088+
while (!go.load(std::memory_order_acquire)) { /* spin */ }
2089+
for (int i = 0; i < OPS_PER_THREAD; ++i) {
2090+
ASGCT_CallFrame frame;
2091+
frame.bci = FILL_TARGET + 1 + t * OPS_PER_THREAD + i;
2092+
frame.method_id = reinterpret_cast<jmethodID>(0x80000 + t * OPS_PER_THREAD + i);
2093+
u64 id = hash_table.put(1, &frame, false, 1);
2094+
if (id != 0 && id != CallTraceStorage::DROPPED_TRACE_ID) {
2095+
successes.fetch_add(1, std::memory_order_relaxed);
2096+
}
2097+
}
2098+
});
2099+
}
2100+
2101+
// The other half re-insert pre-filled frames. After expansion the new
2102+
// table is empty for those hashes, so put() claims a new slot and calls
2103+
// findCallTrace(prev, hash) — the read side of the race window. A correct
2104+
// findCallTrace must return the original trace so dedup produces the same
2105+
// trace_id.
2106+
for (int t = NUM_THREADS / 2; t < NUM_THREADS; ++t) {
2107+
workers.emplace_back([&, t]() {
2108+
while (!go.load(std::memory_order_acquire)) { /* spin */ }
2109+
for (int i = 0; i < VERIFY_COUNT; ++i) {
2110+
u64 id = hash_table.put(1, &verify_frames[i], false, 1);
2111+
if (id != 0 && id != CallTraceStorage::DROPPED_TRACE_ID &&
2112+
id != expected_ids[i]) {
2113+
dedup_mismatches.fetch_add(1, std::memory_order_relaxed);
2114+
}
2115+
}
2116+
});
2117+
}
2118+
2119+
go.store(true, std::memory_order_release);
2120+
for (auto& w : workers) {
2121+
w.join();
2122+
}
2123+
2124+
EXPECT_GT(successes.load(), 0u) << "No successful insertions after expansion";
2125+
EXPECT_EQ(dedup_mismatches.load(), 0u)
2126+
<< "findCallTrace returned wrong trace_id for pre-filled entries after expansion "
2127+
"(findCallTrace failed to locate entry in prev table)";
2128+
}
2129+
20172130
// Test 13: Hash Table Memory Allocation Failure Stress Test
20182131
TEST_F(StressTestSuite, HashTableAllocationFailureStressTest) {
20192132
const int NUM_THREADS = 8;

0 commit comments

Comments
 (0)