Skip to content

Commit 19720e6

Browse files
authored
feat(profiling): Introduce different profiler schedulers (getsentry#1616)
Previously, the only scheduling mechanism was via `signals.SIGPROF`. This was limited to UNIX platforms and was not always consistent. This PR introduces more ways to schedule the sampling. They are the following: - `_SigprofScheduler` uses `signals.SIGPROF` to schedule - `_SigalrmScheduler` uses `signals.SIGALRM` to schedule - `_SleepScheduler` uses threads and `time.sleep` to schedule - `_EventScheduler` uses threads and `threading.Event().wait` to schedule
1 parent e32f224 commit 19720e6

2 files changed

Lines changed: 243 additions & 45 deletions

File tree

sentry_sdk/client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,9 @@ def _capture_envelope(envelope):
134134
profiles_sample_rate = self.options["_experiments"].get("profiles_sample_rate")
135135
if profiles_sample_rate is not None and profiles_sample_rate > 0:
136136
try:
137-
setup_profiler()
138-
except ValueError:
139-
logger.debug("Profiling can only be enabled from the main thread.")
137+
setup_profiler(self.options)
138+
except ValueError as e:
139+
logger.debug(str(e))
140140

141141
@property
142142
def dsn(self):

sentry_sdk/profiler.py

Lines changed: 240 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -64,18 +64,15 @@ def nanosecond_time():
6464
_scheduler = None # type: Optional[_Scheduler]
6565

6666

67-
def setup_profiler(buffer_secs=60, frequency=101):
68-
# type: (int, int) -> None
67+
def setup_profiler(options):
68+
# type: (Dict[str, Any]) -> None
6969

7070
"""
71-
This method sets up the application so that it can be profiled.
72-
It MUST be called from the main thread. This is a limitation of
73-
python's signal library where it only allows the main thread to
74-
set a signal handler.
75-
7671
`buffer_secs` determines the max time a sample will be buffered for
7772
`frequency` determines the number of samples to take per second (Hz)
7873
"""
74+
buffer_secs = 60
75+
frequency = 101
7976

8077
global _sample_buffer
8178
global _scheduler
@@ -86,11 +83,19 @@ def setup_profiler(buffer_secs=60, frequency=101):
8683
# a capcity of `buffer_secs * frequency`.
8784
_sample_buffer = _SampleBuffer(capacity=buffer_secs * frequency)
8885

89-
_scheduler = _Scheduler(frequency=frequency)
86+
profiler_mode = options["_experiments"].get("profiler_mode", _SigprofScheduler.mode)
87+
if profiler_mode == _SigprofScheduler.mode:
88+
_scheduler = _SigprofScheduler(frequency=frequency)
89+
elif profiler_mode == _SigalrmScheduler.mode:
90+
_scheduler = _SigalrmScheduler(frequency=frequency)
91+
elif profiler_mode == _SleepScheduler.mode:
92+
_scheduler = _SleepScheduler(frequency=frequency)
93+
elif profiler_mode == _EventScheduler.mode:
94+
_scheduler = _EventScheduler(frequency=frequency)
95+
else:
96+
raise ValueError("Unknown profiler mode: {}".format(profiler_mode))
97+
_scheduler.setup()
9098

91-
# This setups a process wide signal handler that will be called
92-
# at an interval to record samples.
93-
signal.signal(signal.SIGPROF, _sample_stack)
9499
atexit.register(teardown_profiler)
95100

96101

@@ -100,32 +105,18 @@ def teardown_profiler():
100105
global _sample_buffer
101106
global _scheduler
102107

108+
if _scheduler is not None:
109+
_scheduler.teardown()
110+
103111
_sample_buffer = None
104112
_scheduler = None
105113

106-
# setting the timer with 0 will stop will clear the timer
107-
signal.setitimer(signal.ITIMER_PROF, 0)
108-
109-
# put back the default signal handler
110-
signal.signal(signal.SIGPROF, signal.SIG_DFL)
111114

112-
113-
def _sample_stack(_signal_num, _frame):
114-
# type: (int, Frame) -> None
115+
def _sample_stack(*args, **kwargs):
116+
# type: (*Any, **Any) -> None
115117
"""
116118
Take a sample of the stack on all the threads in the process.
117-
This handler is called to handle the signal at a set interval.
118-
119-
See https://www.gnu.org/software/libc/manual/html_node/Alarm-Signals.html
120-
121-
This is not based on wall time, and you may see some variances
122-
in the frequency at which this handler is called.
123-
124-
Notably, it looks like only threads started using the threading
125-
module counts towards the time elapsed. It is unclear why that
126-
is the case right now. However, we are able to get samples from
127-
threading._DummyThread if this handler is called as a result of
128-
another thread (e.g. the main thread).
119+
This should be called at a regular interval to collect samples.
129120
"""
130121

131122
assert _sample_buffer is not None
@@ -298,33 +289,240 @@ def slice_profile(self, start_ns, stop_ns):
298289

299290

300291
class _Scheduler(object):
292+
mode = "unknown"
293+
301294
def __init__(self, frequency):
302295
# type: (int) -> None
303296
self._lock = threading.Lock()
304297
self._count = 0
305298
self._interval = 1.0 / frequency
306299

300+
def setup(self):
301+
# type: () -> None
302+
raise NotImplementedError
303+
304+
def teardown(self):
305+
# type: () -> None
306+
raise NotImplementedError
307+
307308
def start_profiling(self):
308309
# type: () -> bool
309310
with self._lock:
310-
# we only need to start the timer if we're starting the first profile
311-
should_start_timer = self._count == 0
312311
self._count += 1
313-
314-
if should_start_timer:
315-
signal.setitimer(signal.ITIMER_PROF, self._interval, self._interval)
316-
return should_start_timer
312+
return self._count == 1
317313

318314
def stop_profiling(self):
319315
# type: () -> bool
320316
with self._lock:
321-
# we only need to stop the timer if we're stoping the last profile
322-
should_stop_timer = self._count == 1
323317
self._count -= 1
318+
return self._count == 0
319+
320+
321+
class _ThreadScheduler(_Scheduler):
322+
"""
323+
This abstract scheduler is based on running a daemon thread that will call
324+
the sampler at a regular interval.
325+
"""
326+
327+
mode = "thread"
328+
329+
def __init__(self, frequency):
330+
# type: (int) -> None
331+
super(_ThreadScheduler, self).__init__(frequency)
332+
self.event = threading.Event()
333+
334+
def setup(self):
335+
# type: () -> None
336+
pass
337+
338+
def teardown(self):
339+
# type: () -> None
340+
pass
341+
342+
def start_profiling(self):
343+
# type: () -> bool
344+
if super(_ThreadScheduler, self).start_profiling():
345+
# make sure to clear the event as we reuse the same event
346+
# over the lifetime of the scheduler
347+
self.event.clear()
348+
349+
# make sure the thread is a daemon here otherwise this
350+
# can keep the application running after other threads
351+
# have exited
352+
thread = threading.Thread(target=self.run, daemon=True)
353+
thread.start()
354+
return True
355+
return False
356+
357+
def stop_profiling(self):
358+
# type: () -> bool
359+
if super(_ThreadScheduler, self).stop_profiling():
360+
# make sure the set the event here so that the thread
361+
# can check to see if it should keep running
362+
self.event.set()
363+
return True
364+
return False
365+
366+
def run(self):
367+
# type: () -> None
368+
raise NotImplementedError
369+
370+
371+
class _SleepScheduler(_ThreadScheduler):
372+
"""
373+
This scheduler uses time.sleep to wait the required interval before calling
374+
the sampling function.
375+
"""
376+
377+
mode = "sleep"
378+
379+
def run(self):
380+
# type: () -> None
381+
while True:
382+
if self.event.is_set():
383+
break
384+
time.sleep(self._interval)
385+
_sample_stack()
386+
387+
388+
class _EventScheduler(_ThreadScheduler):
389+
"""
390+
This scheduler uses threading.Event to wait the required interval before
391+
calling the sampling function.
392+
"""
393+
394+
mode = "event"
324395

325-
if should_stop_timer:
326-
signal.setitimer(signal.ITIMER_PROF, 0)
327-
return should_stop_timer
396+
def run(self):
397+
# type: () -> None
398+
while True:
399+
if self.event.is_set():
400+
break
401+
self.event.wait(timeout=self._interval)
402+
_sample_stack()
403+
404+
405+
class _SignalScheduler(_Scheduler):
406+
"""
407+
This abstract scheduler is based on UNIX signals. It sets up a
408+
signal handler for the specified signal, and the matching itimer in order
409+
for the signal handler to fire at a regular interval.
410+
411+
See https://www.gnu.org/software/libc/manual/html_node/Alarm-Signals.html
412+
"""
413+
414+
mode = "signal"
415+
416+
@property
417+
def signal_num(self):
418+
# type: () -> signal.Signals
419+
raise NotImplementedError
420+
421+
@property
422+
def signal_timer(self):
423+
# type: () -> int
424+
raise NotImplementedError
425+
426+
def setup(self):
427+
# type: () -> None
428+
"""
429+
This method sets up the application so that it can be profiled.
430+
It MUST be called from the main thread. This is a limitation of
431+
python's signal library where it only allows the main thread to
432+
set a signal handler.
433+
"""
434+
435+
# This setups a process wide signal handler that will be called
436+
# at an interval to record samples.
437+
try:
438+
signal.signal(self.signal_num, _sample_stack)
439+
except ValueError:
440+
raise ValueError(
441+
"Signal based profiling can only be enabled from the main thread."
442+
)
443+
444+
# Ensures that system calls interrupted by signals are restarted
445+
# automatically. Otherwise, we may see some strage behaviours
446+
# such as IOErrors caused by the system call being interrupted.
447+
signal.siginterrupt(self.signal_num, False)
448+
449+
def teardown(self):
450+
# type: () -> None
451+
452+
# setting the timer with 0 will stop will clear the timer
453+
signal.setitimer(self.signal_timer, 0)
454+
455+
# put back the default signal handler
456+
signal.signal(self.signal_num, signal.SIG_DFL)
457+
458+
def start_profiling(self):
459+
# type: () -> bool
460+
if super(_SignalScheduler, self).start_profiling():
461+
signal.setitimer(self.signal_timer, self._interval, self._interval)
462+
return True
463+
return False
464+
465+
def stop_profiling(self):
466+
# type: () -> bool
467+
if super(_SignalScheduler, self).stop_profiling():
468+
signal.setitimer(self.signal_timer, 0)
469+
return True
470+
return False
471+
472+
473+
class _SigprofScheduler(_SignalScheduler):
474+
"""
475+
This scheduler uses SIGPROF to regularly call a signal handler where the
476+
samples will be taken.
477+
478+
This is not based on wall time, and you may see some variances
479+
in the frequency at which this handler is called.
480+
481+
This has some limitations:
482+
- Only the main thread counts towards the time elapsed. This means that if
483+
the main thread is blocking on a sleep() or select() system call, then
484+
this clock will not count down. Some examples of this in practice are
485+
- When using uwsgi with multiple threads in a worker, the non main
486+
threads will only be profiled if the main thread is actively running
487+
at the same time.
488+
- When using gunicorn with threads, the main thread does not handle the
489+
requests directly, so the clock counts down slower than expected since
490+
its mostly idling while waiting for requests.
491+
"""
492+
493+
mode = "sigprof"
494+
495+
@property
496+
def signal_num(self):
497+
# type: () -> signal.Signals
498+
return signal.SIGPROF
499+
500+
@property
501+
def signal_timer(self):
502+
# type: () -> int
503+
return signal.ITIMER_PROF
504+
505+
506+
class _SigalrmScheduler(_SignalScheduler):
507+
"""
508+
This scheduler uses SIGALRM to regularly call a signal handler where the
509+
samples will be taken.
510+
511+
This is based on real time, so it *should* be called close to the expected
512+
frequency.
513+
"""
514+
515+
mode = "sigalrm"
516+
517+
@property
518+
def signal_num(self):
519+
# type: () -> signal.Signals
520+
return signal.SIGALRM
521+
522+
@property
523+
def signal_timer(self):
524+
# type: () -> int
525+
return signal.ITIMER_REAL
328526

329527

330528
def _should_profile(transaction, hub):

0 commit comments

Comments
 (0)