Skip to content

Commit 821b067

Browse files
authored
[Profiler] Add Reader/Writer Spinning mutex (#8904)
1 parent 09013ef commit 821b067

4 files changed

Lines changed: 942 additions & 2 deletions

File tree

profiler/src/ProfilerEngine/Datadog.Profiler.Native.Linux/CMakeLists.txt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@ if (RUN_UBSAN)
4848
endif()
4949

5050
if (RUN_TSAN)
51-
add_compile_options(-fsanitize=thread -g -fno-omit-frame-pointer -DDD_SANITIZERS)
51+
# DD_SANITIZE_THREAD (distinct from the general DD_SANITIZERS flag shared
52+
# with ASAN/UBSAN) gates TSAN-only annotations (see SpinningMutex.hpp /
53+
# ReaderWriterSpinningMutex.hpp) that call into libtsan's __tsan_mutex_* API.
54+
# Those symbols only exist when actually linked with -fsanitize=thread, so
55+
# this must NOT be defined under ASAN/UBSAN-only builds.
56+
add_compile_options(-fsanitize=thread -g -fno-omit-frame-pointer -DDD_SANITIZERS -DDD_SANITIZE_THREAD)
5257
endif()
5358

5459
if(ISLINUX)
Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2022 Datadog, Inc.
3+
4+
#pragma once
5+
6+
#include <atomic>
7+
#include <cassert>
8+
#include <chrono>
9+
10+
#include <errno.h>
11+
#include <time.h>
12+
13+
#ifdef DD_SANITIZE_THREAD
14+
#include <sanitizer/tsan_interface.h>
15+
#define TSAN_MUTEX_CREATE __tsan_mutex_create
16+
#define TSAN_MUTEX_DESTROY __tsan_mutex_destroy
17+
#define TSAN_MUTEX_PRE_LOCK __tsan_mutex_pre_lock
18+
#define TSAN_MUTEX_POST_LOCK __tsan_mutex_post_lock
19+
#define TSAN_MUTEX_PRE_UNLOCK __tsan_mutex_pre_unlock
20+
#define TSAN_MUTEX_POST_UNLOCK __tsan_mutex_post_unlock
21+
#else
22+
#define TSAN_MUTEX_CREATE(...)
23+
#define TSAN_MUTEX_DESTROY(...)
24+
#define TSAN_MUTEX_PRE_LOCK(...)
25+
#define TSAN_MUTEX_POST_LOCK(...)
26+
#define TSAN_MUTEX_PRE_UNLOCK(...)
27+
#define TSAN_MUTEX_POST_UNLOCK(...)
28+
#endif
29+
30+
// ReaderWriterSpinningMutex
31+
// =====================
32+
//
33+
// A reader-writer lock built entirely out of std::atomic operations: no
34+
// pthread mutex, no condition variable, no futex/syscall in the fast path.
35+
// It is meant as a signal-handler-safe alternative to std::shared_mutex /
36+
// std::shared_timed_mutex for code that may be read from inside a POSIX
37+
// signal handler (e.g. the timer_create-based CPU profiler's SIGPROF handler,
38+
// or the wall-time profiler's SIGUSR1 handler).
39+
//
40+
// It satisfies (a relevant subset of) the SharedMutex/SharedTimedMutex named
41+
// requirements, so it can be used directly with std::shared_lock<T> and
42+
// std::unique_lock<T>.
43+
//
44+
// Design
45+
// ------
46+
// A single state word encodes the lock:
47+
// 0 -> unlocked
48+
// -1 -> exclusively locked (by a writer)
49+
// N (N > 0) -> N concurrent readers
50+
//
51+
// A separate "waiting writer" counter is used purely for fairness: once at
52+
// least one writer is waiting, new readers back off instead of joining, so a
53+
// steady stream of readers cannot starve a writer forever. This does NOT
54+
// change correctness (readers already holding the lock are unaffected), only
55+
// how quickly a pending writer can get in.
56+
//
57+
// Safety note
58+
// -----------
59+
// This primitive only makes the *lock itself* signal-safe (bounded; the
60+
// slow path's backoff sleep goes straight to ::clock_nanosleep() rather than
61+
// std::this_thread::sleep_for(), see BoundedSleep() below for why). It does
62+
// NOT by itself prevent the classic same-thread reentrancy deadlock (writer
63+
// holds the lock, gets interrupted by a signal targeted at the very same
64+
// thread, and the handler tries to take the lock too). Bounded
65+
// try_lock_for/try_lock_shared_for calls turn that
66+
// scenario into "handler gives up after the timeout" rather than "hang
67+
// forever", which is the same trade-off already made by ManagedCodeCache
68+
// today. If true reentrancy-proof behavior is required, combine this with
69+
// blocking the relevant signals for the duration of the write (see
70+
// ScopedProfilerSignalBlocker in ManagedCodeCache.cpp) or avoid the lock
71+
// entirely on the read path (we could seek for a lock-free append-only design).
72+
class ReaderWriterSpinningMutex
73+
{
74+
public:
75+
ReaderWriterSpinningMutex() noexcept :
76+
_state(0),
77+
_waitingWriters(0)
78+
{
79+
TSAN_MUTEX_CREATE(this, __tsan_mutex_not_static);
80+
}
81+
82+
~ReaderWriterSpinningMutex()
83+
{
84+
TSAN_MUTEX_DESTROY(this, __tsan_mutex_not_static);
85+
}
86+
87+
ReaderWriterSpinningMutex(const ReaderWriterSpinningMutex&) = delete;
88+
ReaderWriterSpinningMutex& operator=(const ReaderWriterSpinningMutex&) = delete;
89+
ReaderWriterSpinningMutex(ReaderWriterSpinningMutex&&) = delete;
90+
ReaderWriterSpinningMutex& operator=(ReaderWriterSpinningMutex&&) = delete;
91+
92+
// -------------------------------------------------------------------
93+
// Exclusive (writer) API
94+
// -------------------------------------------------------------------
95+
96+
void lock() noexcept
97+
{
98+
// Fast path: skip the (noinline) slow path and the waiting-writer
99+
// bookkeeping entirely when the lock is immediately available, which
100+
// is the common case for short-lived writer critical sections.
101+
if (try_lock())
102+
{
103+
return;
104+
}
105+
106+
WaitingWriterGuard waitingGuard(_waitingWriters);
107+
try_lock_exclusive_until_slow(std::chrono::steady_clock::time_point::max());
108+
}
109+
110+
template <typename Rep, typename Period>
111+
bool try_lock_for(std::chrono::duration<Rep, Period> timeout_duration) noexcept
112+
{
113+
if (try_lock())
114+
{
115+
return true;
116+
}
117+
118+
WaitingWriterGuard waitingGuard(_waitingWriters);
119+
return try_lock_exclusive_until_slow(std::chrono::steady_clock::now() + timeout_duration);
120+
}
121+
122+
template <typename Clock, typename Duration>
123+
bool try_lock_until(std::chrono::time_point<Clock, Duration> timeout_time) noexcept
124+
{
125+
return try_lock_for(timeout_time - Clock::now());
126+
}
127+
128+
bool try_lock() noexcept
129+
{
130+
TSAN_MUTEX_PRE_LOCK(this, __tsan_mutex_try_lock);
131+
int32_t expected = kFree;
132+
bool result = _state.compare_exchange_strong(expected, kExclusive,
133+
std::memory_order_acquire,
134+
std::memory_order_relaxed);
135+
TSAN_MUTEX_POST_LOCK(this, result ? __tsan_mutex_try_lock : __tsan_mutex_try_lock_failed, 0);
136+
return result;
137+
}
138+
139+
void unlock() noexcept
140+
{
141+
TSAN_MUTEX_PRE_UNLOCK(this, 0);
142+
assert(_state.load(std::memory_order_relaxed) == kExclusive);
143+
_state.store(kFree, std::memory_order_release);
144+
TSAN_MUTEX_POST_UNLOCK(this, 0);
145+
}
146+
147+
// -------------------------------------------------------------------
148+
// Shared (reader) API
149+
// -------------------------------------------------------------------
150+
151+
void lock_shared() noexcept
152+
{
153+
// Fast path: same rationale as lock() above.
154+
if (try_lock_shared())
155+
{
156+
return;
157+
}
158+
159+
try_lock_shared_until_slow(std::chrono::steady_clock::time_point::max());
160+
}
161+
162+
template <typename Rep, typename Period>
163+
bool try_lock_shared_for(std::chrono::duration<Rep, Period> timeout_duration) noexcept
164+
{
165+
if (try_lock_shared())
166+
{
167+
return true;
168+
}
169+
return try_lock_shared_until_slow(std::chrono::steady_clock::now() + timeout_duration);
170+
}
171+
172+
template <typename Clock, typename Duration>
173+
bool try_lock_shared_until(std::chrono::time_point<Clock, Duration> timeout_time) noexcept
174+
{
175+
return try_lock_shared_for(timeout_time - Clock::now());
176+
}
177+
178+
bool try_lock_shared() noexcept
179+
{
180+
TSAN_MUTEX_PRE_LOCK(this, __tsan_mutex_try_lock | __tsan_mutex_read_lock);
181+
// Fairness: honor the waiting-writer flag here too, not just in the
182+
// slow (blocking) path below. Without this, a tight retry loop of
183+
// plain try_lock_shared() calls (which is exactly what a spinning
184+
// reader does) would always win the fast path and could starve a
185+
// waiting writer indefinitely.
186+
// relaxed is enough: _waitingWriters is purely an advisory fairness
187+
// hint (it doesn't guard any other memory), so a stale read only
188+
// means a reader occasionally wins one extra race before backing
189+
// off - a liveness nicety, not a safety property. Correctness is
190+
// fully governed by the CAS on _state in try_lock_shared_once().
191+
bool result = _waitingWriters.load(std::memory_order_relaxed) == 0 && try_lock_shared_once();
192+
TSAN_MUTEX_POST_LOCK(this, (result ? __tsan_mutex_try_lock : __tsan_mutex_try_lock_failed) | __tsan_mutex_read_lock, 0);
193+
return result;
194+
}
195+
196+
void unlock_shared() noexcept
197+
{
198+
TSAN_MUTEX_PRE_UNLOCK(this, __tsan_mutex_read_lock);
199+
[[maybe_unused]] auto previous = _state.fetch_sub(1, std::memory_order_release);
200+
assert(previous > kFree);
201+
TSAN_MUTEX_POST_UNLOCK(this, __tsan_mutex_read_lock);
202+
}
203+
204+
// Exposed for tests only: number of writers currently blocked in lock()/
205+
// try_lock_for(). Used to assert on fairness/starvation behavior.
206+
int32_t WaitingWriterCountForTest() const noexcept
207+
{
208+
return _waitingWriters.load(std::memory_order_relaxed);
209+
}
210+
211+
private:
212+
static constexpr int32_t kFree = 0;
213+
static constexpr int32_t kExclusive = -1;
214+
215+
static constexpr uint32_t kMaxActiveSpin = 4000;
216+
static constexpr std::chrono::nanoseconds kYieldSleep = std::chrono::microseconds(500);
217+
218+
// RAII helper: increments the waiting-writer counter for the lifetime of
219+
// a blocking lock() / try_lock_for() call, regardless of how it exits
220+
// (success or timeout), so readers can see "a writer is waiting" and back
221+
// off without the counter ever leaking upward.
222+
struct WaitingWriterGuard
223+
{
224+
explicit WaitingWriterGuard(std::atomic<int32_t>& counter) noexcept : _counter(counter)
225+
{
226+
_counter.fetch_add(1, std::memory_order_relaxed);
227+
}
228+
229+
~WaitingWriterGuard()
230+
{
231+
_counter.fetch_sub(1, std::memory_order_relaxed);
232+
}
233+
234+
WaitingWriterGuard(const WaitingWriterGuard&) = delete;
235+
WaitingWriterGuard& operator=(const WaitingWriterGuard&) = delete;
236+
237+
std::atomic<int32_t>& _counter;
238+
};
239+
240+
// Sleeps for at most `duration` without going through
241+
// std::this_thread::sleep_for(): this primitive can be exercised from a
242+
// POSIX signal handler (that is its whole purpose), and sleep_for() is
243+
// not documented as async-signal-safe - on this toolchain it is a thin
244+
// wrapper around ::nanosleep(), which POSIX explicitly does NOT put on
245+
// the async-signal-safe list (see signal-safety(7); only bare sleep(3)
246+
// is listed, not nanosleep(2)/clock_nanosleep(2)). Calling
247+
// ::clock_nanosleep() directly is not a POSIX-certified guarantee either
248+
// (no bounded-sleep primitive is), but it avoids any indirection through
249+
// libstdc++'s <thread> internals (chrono conversions, its own possible
250+
// lazy-init) and, unlike ::nanosleep(), reports errors via its return
251+
// value rather than errno, so this call touches no thread-global state
252+
// at all beyond the syscall itself.
253+
static void BoundedSleep(std::chrono::steady_clock::duration duration) noexcept
254+
{
255+
if (duration <= std::chrono::steady_clock::duration::zero())
256+
{
257+
return;
258+
}
259+
260+
auto seconds = std::chrono::duration_cast<std::chrono::seconds>(duration);
261+
auto nanoseconds = std::chrono::duration_cast<std::chrono::nanoseconds>(duration - seconds);
262+
263+
struct timespec ts;
264+
ts.tv_sec = static_cast<time_t>(seconds.count());
265+
ts.tv_nsec = static_cast<long>(nanoseconds.count());
266+
267+
// On EINTR, clock_nanosleep() refreshes ts in place with the
268+
// remaining relative time, so re-issuing the call with the same ts
269+
// resumes waiting for what is left rather than restarting the full
270+
// duration.
271+
while (::clock_nanosleep(CLOCK_MONOTONIC, 0, &ts, &ts) == EINTR)
272+
{
273+
}
274+
}
275+
276+
bool try_lock_shared_once() noexcept
277+
{
278+
int32_t expected = _state.load(std::memory_order_relaxed);
279+
while (expected >= kFree)
280+
{
281+
if (_state.compare_exchange_weak(expected, expected + 1,
282+
std::memory_order_acquire,
283+
std::memory_order_relaxed))
284+
{
285+
return true;
286+
}
287+
// expected has been updated by compare_exchange_weak; retry unless
288+
// a writer holds the lock.
289+
}
290+
return false;
291+
}
292+
293+
__attribute__((noinline)) bool try_lock_exclusive_until_slow(std::chrono::steady_clock::time_point timeoutTime) noexcept
294+
{
295+
uint32_t spincount = 0;
296+
for (;;)
297+
{
298+
TSAN_MUTEX_PRE_LOCK(this, __tsan_mutex_try_lock);
299+
int32_t expected = kFree;
300+
if (_state.compare_exchange_weak(expected, kExclusive,
301+
std::memory_order_acquire,
302+
std::memory_order_relaxed))
303+
{
304+
TSAN_MUTEX_POST_LOCK(this, __tsan_mutex_try_lock, 0);
305+
return true;
306+
}
307+
TSAN_MUTEX_POST_LOCK(this, __tsan_mutex_try_lock_failed, 0);
308+
309+
if (spincount < kMaxActiveSpin)
310+
{
311+
++spincount;
312+
#ifdef __x86_64__
313+
asm volatile("pause");
314+
#else
315+
asm volatile("yield");
316+
#endif
317+
}
318+
else
319+
{
320+
auto now = std::chrono::steady_clock::now();
321+
if (now >= timeoutTime)
322+
{
323+
return false;
324+
}
325+
// Clamp to the remaining budget: unconditionally sleeping the
326+
// full kYieldSleep here would let a short deadline (e.g. a
327+
// signal handler's bounded try_lock_for) overshoot by nearly
328+
// kYieldSleep, since the next deadline check only happens
329+
// after waking up.
330+
BoundedSleep(std::min<std::chrono::steady_clock::duration>(kYieldSleep, timeoutTime - now));
331+
}
332+
}
333+
}
334+
335+
__attribute__((noinline)) bool try_lock_shared_until_slow(std::chrono::steady_clock::time_point timeoutTime) noexcept
336+
{
337+
uint32_t spincount = 0;
338+
for (;;)
339+
{
340+
TSAN_MUTEX_PRE_LOCK(this, __tsan_mutex_try_lock | __tsan_mutex_read_lock);
341+
// Fairness: if a writer is already waiting, do not let new readers
342+
// jump the queue; only readers already holding the lock can keep
343+
// going. This bounds how long a writer can be starved.
344+
// See try_lock_shared() above for why relaxed is sufficient here.
345+
if (_waitingWriters.load(std::memory_order_relaxed) == 0 && try_lock_shared_once())
346+
{
347+
// Must echo __tsan_mutex_try_lock here too (not just
348+
// read_lock): pre_lock announced this as a try-lock attempt,
349+
// and TSAN requires that flag to match on the post_lock call
350+
// that reports its outcome - see try_lock_shared() above,
351+
// which does the same on its success path.
352+
TSAN_MUTEX_POST_LOCK(this, __tsan_mutex_try_lock | __tsan_mutex_read_lock, 0);
353+
return true;
354+
}
355+
TSAN_MUTEX_POST_LOCK(this, __tsan_mutex_try_lock_failed | __tsan_mutex_read_lock, 0);
356+
357+
if (spincount < kMaxActiveSpin)
358+
{
359+
++spincount;
360+
#ifdef __x86_64__
361+
asm volatile("pause");
362+
#else
363+
asm volatile("yield");
364+
#endif
365+
}
366+
else
367+
{
368+
auto now = std::chrono::steady_clock::now();
369+
if (now >= timeoutTime)
370+
{
371+
return false;
372+
}
373+
// See try_lock_exclusive_until_slow() above for why this is
374+
// clamped rather than an unconditional sleep_for(kYieldSleep).
375+
BoundedSleep(std::min<std::chrono::steady_clock::duration>(kYieldSleep, timeoutTime - now));
376+
}
377+
}
378+
}
379+
380+
std::atomic<int32_t> _state;
381+
std::atomic<int32_t> _waitingWriters;
382+
};

0 commit comments

Comments
 (0)