Skip to content

Commit 40be0dc

Browse files
authored
fix(spinlock): bound OptionalSharedLockGuard spin in signal handler paths (#572)
1 parent fbcbab6 commit 40be0dc

4 files changed

Lines changed: 165 additions & 8 deletions

File tree

ddprof-lib/src/main/cpp/counters.h

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,10 @@
114114
X(JVMTI_STACKS_REQUESTED, "jvmti_stacks_requested") \
115115
X(JVMTI_STACKS_FAILED_WRONG_PHASE, "jvmti_stacks_failed_wrong_phase") \
116116
X(JVMTI_STACKS_FAILED_OTHER, "jvmti_stacks_failed_other") \
117-
X(JVMTI_STACKS_DROPPED_LOCK, "jvmti_stacks_dropped_lock")
117+
/* Delegated stacks dropped at slot-lock. Rec-lock drops from all recording \
118+
* paths (delegated and direct) go into SAMPLES_DROPPED_REC_LOCK. */ \
119+
X(JVMTI_STACKS_DROPPED_LOCK, "jvmti_stacks_dropped_lock") \
120+
X(SAMPLES_DROPPED_REC_LOCK, "samples_dropped_rec_lock")
118121
#define X_ENUM(a, b) a,
119122
typedef enum CounterId : int {
120123
DD_COUNTER_TABLE(X_ENUM) DD_NUM_COUNTERS

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2084,6 +2084,8 @@ void FlightRecorder::recordEvent(int lock_index, int tid, u64 call_trace_id,
20842084
rec->flushIfNeeded(buf);
20852085
rec->addThread(lock_index, tid);
20862086
}
2087+
} else {
2088+
Counters::increment(SAMPLES_DROPPED_REC_LOCK);
20872089
}
20882090
}
20892091

@@ -2111,6 +2113,8 @@ void FlightRecorder::recordEventDelegated(int lock_index, int tid,
21112113
rec->flushIfNeeded(buf);
21122114
rec->addThread(lock_index, tid);
21132115
}
2116+
} else {
2117+
Counters::increment(SAMPLES_DROPPED_REC_LOCK);
21142118
}
21152119
}
21162120

ddprof-lib/src/main/cpp/spinLock.h

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
#define _SPINLOCK_H
1919

2020
#include "arch.h"
21+
#include <cassert>
2122

2223
// Cannot use regular mutexes inside signal handler.
2324
// This lock is based on CAS busy loop. GCC atomic builtins imply full barrier.
@@ -34,7 +35,7 @@ class alignas(DEFAULT_CACHE_LINE_SIZE) SpinLock {
3435
static_assert(sizeof(SpinLock) == DEFAULT_CACHE_LINE_SIZE);
3536
}
3637

37-
void reset() { _lock = 0; }
38+
void reset() { __atomic_store_n(&_lock, 0, __ATOMIC_RELAXED); }
3839

3940
bool tryLock() { return __sync_bool_compare_and_swap(&_lock, 0, 1); }
4041

@@ -44,7 +45,15 @@ class alignas(DEFAULT_CACHE_LINE_SIZE) SpinLock {
4445
}
4546
}
4647

47-
void unlock() { __sync_fetch_and_sub(&_lock, 1); }
48+
void unlock() {
49+
assert(__atomic_load_n(&_lock, __ATOMIC_RELAXED) == 1);
50+
__sync_fetch_and_sub(&_lock, 1);
51+
}
52+
53+
// Spin budget for bounded shared acquisition in signal handlers.
54+
// Bounds the number of CAS-retry iterations under reader contention;
55+
// does NOT bound wall-clock latency (CAS stall time is hardware-dependent).
56+
static constexpr int DEFAULT_SHARED_SPIN_BUDGET = 256;
4857

4958
bool tryLockShared() {
5059
// Spins while no exclusive lock is held and the CAS to acquire a shared
@@ -60,6 +69,23 @@ class alignas(DEFAULT_CACHE_LINE_SIZE) SpinLock {
6069
return false;
6170
}
6271

72+
// Bounded variant for signal-handler paths. Returns false when an exclusive
73+
// lock is observed OR the spin budget is exhausted under reader contention.
74+
bool tryLockShared(int max_spins) {
75+
int value;
76+
int spins = 0;
77+
while ((value = __atomic_load_n(&_lock, __ATOMIC_ACQUIRE)) <= 0) {
78+
if (__sync_bool_compare_and_swap(&_lock, value, value - 1)) {
79+
return true;
80+
}
81+
if (++spins >= max_spins) {
82+
return false;
83+
}
84+
spinPause();
85+
}
86+
return false;
87+
}
88+
6389
void lockShared() {
6490
int value;
6591
while ((value = __atomic_load_n(&_lock, __ATOMIC_ACQUIRE)) > 0 ||
@@ -68,7 +94,10 @@ class alignas(DEFAULT_CACHE_LINE_SIZE) SpinLock {
6894
}
6995
}
7096

71-
void unlockShared() { __sync_fetch_and_add(&_lock, 1); }
97+
void unlockShared() {
98+
assert(__atomic_load_n(&_lock, __ATOMIC_RELAXED) < 0);
99+
__sync_fetch_and_add(&_lock, 1);
100+
}
72101
};
73102

74103
// RAII guard classes for automatic lock management
@@ -89,12 +118,17 @@ class SharedLockGuard {
89118
SharedLockGuard& operator=(SharedLockGuard&&) = delete;
90119
};
91120

121+
// Acquires a shared lock with a bounded CAS-retry budget. Returns without
122+
// acquiring (ownsLock() == false) when an exclusive lock is observed or
123+
// the spin budget is exhausted under reader contention. Safe to use in
124+
// signal-handler paths.
92125
class OptionalSharedLockGuard {
93126
SpinLock* _lock;
94127
public:
95-
explicit OptionalSharedLockGuard(SpinLock* lock) : _lock(lock) {
96-
if (!_lock->tryLockShared()) {
97-
// Exclusive lock is held; no unlock needed. Only fails when an exclusive lock is observed.
128+
explicit OptionalSharedLockGuard(
129+
SpinLock* lock, int max_spins = SpinLock::DEFAULT_SHARED_SPIN_BUDGET)
130+
: _lock(lock) {
131+
if (!_lock->tryLockShared(max_spins)) {
98132
_lock = nullptr;
99133
}
100134
}
@@ -103,7 +137,7 @@ class OptionalSharedLockGuard {
103137
_lock->unlockShared();
104138
}
105139
}
106-
bool ownsLock() { return _lock != nullptr; }
140+
bool ownsLock() const { return _lock != nullptr; }
107141

108142
// Non-copyable and non-movable
109143
OptionalSharedLockGuard(const OptionalSharedLockGuard&) = delete;
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Copyright 2026 Datadog, Inc
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
#include <gtest/gtest.h>
18+
#include "spinLock.h"
19+
#include "../../main/cpp/gtest_crash_handler.h"
20+
#include <atomic>
21+
#include <thread>
22+
23+
static constexpr char SPINLOCK_TEST_NAME[] = "SpinLockTest";
24+
25+
class SpinLockTest : public ::testing::Test {
26+
protected:
27+
void SetUp() override {
28+
installGtestCrashHandler<SPINLOCK_TEST_NAME>();
29+
}
30+
void TearDown() override {
31+
restoreDefaultSignalHandlers();
32+
}
33+
34+
SpinLock lock;
35+
};
36+
37+
// OptionalSharedLockGuard acquires on a free lock and releases on destruction.
38+
TEST_F(SpinLockTest, OptionalGuard_UncontendedAcquire) {
39+
{
40+
OptionalSharedLockGuard g(&lock);
41+
EXPECT_TRUE(g.ownsLock());
42+
}
43+
// After destruction the lock must be back to 0 (unlocked).
44+
// Verify by taking an exclusive lock — would spin forever if still shared.
45+
EXPECT_TRUE(lock.tryLock());
46+
lock.unlock();
47+
}
48+
49+
// When an exclusive lock is held, tryLockShared returns false immediately
50+
// (first load sees _lock == 1 > 0, exits without spinning).
51+
TEST_F(SpinLockTest, OptionalGuard_ExclusiveHeld_ImmediateReturn) {
52+
lock.lock();
53+
OptionalSharedLockGuard g(&lock, 1000000);
54+
EXPECT_FALSE(g.ownsLock());
55+
lock.unlock();
56+
}
57+
58+
// The spin budget must be honoured: even with a tiny budget the constructor
59+
// returns (does not hang) when readers are continuously racing the CAS.
60+
TEST_F(SpinLockTest, OptionalGuard_SpinBudgetBound) {
61+
// A background contender thread rapidly locks/unlocks shared to create
62+
// CAS contention on _lock; the guard must return without hanging.
63+
std::atomic<bool> stop{false};
64+
65+
// Background thread hammers shared lock/unlock to create CAS contention.
66+
std::thread contender([&] {
67+
while (!stop.load(std::memory_order_relaxed)) {
68+
lock.lockShared();
69+
lock.unlockShared();
70+
}
71+
});
72+
73+
// With a very small budget the guard must return promptly, not hang.
74+
for (int i = 0; i < 1000; ++i) {
75+
OptionalSharedLockGuard g(&lock, 8);
76+
// ownsLock() may be true or false depending on timing — we only assert
77+
// the constructor returned (i.e. we reach here without hanging).
78+
(void)g.ownsLock();
79+
}
80+
81+
stop.store(true, std::memory_order_relaxed);
82+
contender.join();
83+
}
84+
85+
// Verifies that the budget is enforced: with an exclusive lock held,
86+
// tryLockShared(N) must return false regardless of N.
87+
TEST_F(SpinLockTest, OptionalGuard_BudgetEnforced_ExclusivePath) {
88+
lock.lock();
89+
// With any budget, exclusive lock causes immediate false return.
90+
EXPECT_FALSE(lock.tryLockShared(1));
91+
EXPECT_FALSE(lock.tryLockShared(1000));
92+
EXPECT_FALSE(lock.tryLockShared(SpinLock::DEFAULT_SHARED_SPIN_BUDGET));
93+
lock.unlock();
94+
}
95+
96+
// Multiple shared guards can be held simultaneously (readers don't starve each other).
97+
TEST_F(SpinLockTest, OptionalGuard_SharedReentrancy) {
98+
OptionalSharedLockGuard g1(&lock);
99+
OptionalSharedLockGuard g2(&lock);
100+
EXPECT_TRUE(g1.ownsLock());
101+
EXPECT_TRUE(g2.ownsLock());
102+
// Exclusive try must fail while shared locks are held.
103+
EXPECT_FALSE(lock.tryLock());
104+
}
105+
106+
// tryLockShared() (unbounded) still works correctly alongside the bounded overload.
107+
TEST_F(SpinLockTest, TryLockShared_ExclusiveHeld_ReturnsFalse) {
108+
lock.lock();
109+
EXPECT_FALSE(lock.tryLockShared());
110+
lock.unlock();
111+
}
112+
113+
TEST_F(SpinLockTest, TryLockShared_Free_ReturnsTrue) {
114+
EXPECT_TRUE(lock.tryLockShared());
115+
lock.unlockShared();
116+
}

0 commit comments

Comments
 (0)