Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

gh-133465: Allow PyErr_CheckSignals to be called without holding the GIL. #133466

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
Loading
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Allow PyErr_CheckSignals to be called without holding the GIL.
Compiled-code modules that implement time-consuming operations that
don’t require manipulating Python objects, are supposed to call
PyErr_CheckSignals frequently throughout each such operation, so that
if the user interrupts the operation with control-C, it is cancelled
promptly.  In the normal case where no signals are pending,
PyErr_CheckSignals is cheap; however, callers must hold the GIL,
and compiled-code modules that implement time-consuming operations
are also supposed to release the GIL during each such operation.
The overhead of reclaiming the GIL in order to call PyErr_CheckSignals,
and then releasing it again, sufficiently often for reasonable user
responsiveness, can be substantial.

If my understanding of the thread-state rules is correct,
PyErr_CheckSignals only *needs* the GIL if it has work to do.
*Checking* whether there is a pending signal, or a pending request
to run the cycle collector, requires only a couple of atomic loads.
Therefore:  Reorganize the logic of PyErr_CheckSignals and its close
relatives (_PyErr_CheckSignals and _PyErr_CheckSignalsTstate) so that
all the “do we have anything to do” checks are done in a batch before
anything that needs the GIL.  If any of them are true, acquire the GIL,
repeat the check (because another thread could have stolen the event
while we were waiting for the GIL), and then actually do the work,
enabling callers to *not* hold the GIL.

(There are some fine details here that I’d really appreciate a second
pair of eyes on — see the comments in the new functions
_PyErr_CheckSignalsHoldingGIL and _PyErr_CheckSignalsNoGIL.)
  • Loading branch information
zackw committed May 5, 2025
commit c0f5de9719ccf13cae89baeccaf4e9f3079923f2
62 changes: 43 additions & 19 deletions 62 Doc/c-api/exceptions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,8 @@ Querying the error indicator
Signal Handling
===============

See the :mod:`signal` module for an overview of signals and signal
handling.

.. c:function:: int PyErr_CheckSignals()

Expand All @@ -639,29 +641,51 @@ Signal Handling
single: SIGINT (C macro)
single: KeyboardInterrupt (built-in exception)

This function interacts with Python's signal handling.
This function is to be called by long-running C code that wants to
be interruptible by user requests, such as by pressing Ctrl-C.

When it is called from the main thread and under the main Python
interpreter, it checks whether any signals have been delivered to
the process, and if so, invokes the corresponding Python-level
signal handler (if there is one) for each signal.

:c:func:`PyErr_CheckSignals()` attempts to call signal handlers
for each signal that has been delivered since the last time it
was called. If all signal handlers complete successfully, it
returns ``0``. However, if a signal handler raises an exception,
that exception is stored in the error indicator for the main thread,
and :c:func:`PyErr_CheckSignals()` immediately returns ``-1``.
(When this happens, some of the pending signals may not have had
their signal handlers called; they will be called the next time
:c:func:`PyErr_CheckSignals()` is called.)

Callers of :c:func:`PyErr_CheckSignals()` should treat a ``-1``
return value the same as any other failure of a C-API function;
they should immediately cease work, clean up (deallocating
resources, etc.) and propagate the failure status to their
callers.

When this function is called from other than the main thread, or
other than the main Python interpreter, it does not invoke any
signal handlers, and it always returns ``0``.

Regardless of context, calling this function may have the side
effect of running the cyclic garbage collector.

If the function is called from the main thread and under the main Python
interpreter, it checks whether a signal has been sent to the processes
and if so, invokes the corresponding signal handler. If the :mod:`signal`
module is supported, this can invoke a signal handler written in Python.

The function attempts to handle all pending signals, and then returns ``0``.
However, if a Python signal handler raises an exception, the error
indicator is set and the function returns ``-1`` immediately (such that
other pending signals may not have been handled yet: they will be on the
next :c:func:`PyErr_CheckSignals()` invocation).

If the function is called from a non-main thread, or under a non-main
Python interpreter, it does nothing and returns ``0``.

This function can be called by long-running C code that wants to
be interruptible by user requests (such as by pressing Ctrl-C).
.. warning::
This function may execute arbitrary Python code before returning
to its caller.

.. note::
The default Python signal handler for :c:macro:`!SIGINT` raises the
:exc:`KeyboardInterrupt` exception.
This function can be called without an :term:`attached thread state`
(see :ref:`threads`). However, this function may internally
attach (and then release) a thread state (only if it has any
work to do); it must be safe to do that at each point where
this function is called.

.. note::
The default Python signal handler for :c:macro:`!SIGINT` raises
the :exc:`KeyboardInterrupt` exception.

.. c:function:: void PyErr_SetInterrupt()

Expand Down
5 changes: 5 additions & 0 deletions 5 Doc/c-api/init.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,11 @@ to be released to attach their thread state, allowing true multi-core parallelis
For example, the standard :mod:`zlib` and :mod:`hashlib` modules detach the
:term:`thread state <attached thread state>` when compressing or hashing data.

.. note::
Any code that executes for a long time without returning to the
Python interpreter should call :c:func:`PyErr_CheckSignals()`
at reasonable intervals (at least once a millisecond) so that
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is too frequent for my taste. Use interactions feel "delay-less" under 10 msec, and for ^C 100 even msec feels very quick, and 1sec would still be acceptable. After a few seconds I would hit ^C again.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is briefly discussed in the talk. If you add enough PyErr_CheckSignals calls to your extension module that every long-running loop can be interrupted, in practice this makes you do the check way too often, once every few tens of micro seconds. This is fine with the hypothetical new PyErr_CheckSignals_Detached, but with the old version, where you have to reclaim the GIL, it's way too costly. So in the talk I recommended looking at the actual system clock (with clock_gettime) and only calling PyErr_CheckSignals if a millisecond or more had gone by. That's faster than required for human responsiveness, yes, but the remaining overhead is the overhead of looking at the clock and you can't reduce that by doing the actual calls even less often. So that's where "at least once a millisecond" came from.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe explain that better in the docs then.

it can be interrupted by the user.

.. _gilstate:

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
:c:func:`PyErr_CheckSignals` has been changed to acquire the global
interpreter lock (GIL) itself, only when necessary (i.e. when it has work to
do). This means that modules that perform lengthy computations with the GIL
released may now call :c:func:`PyErr_CheckSignals` during those computations
without re-acquiring the GIL first. (However, it must be *safe to* acquire
the GIL at each point where :c:func:`PyErr_CheckSignals` is called. Also,
keep in mind that it can run arbitrary Python code before returning to you.)
Comment on lines +1 to +7
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A NEWS entry should be more concise, users can refer to docs for in depth explanations.

Copy link
Author

@zackw zackw May 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this better?

:c:func:`PyErr_CheckSignals` has been made safe to call without holding the GIL.
It will acquire the GIL itself when it needs it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A NEWS entry should be more concise, users can refer to docs for in depth explanations.

@StanFromIreland, I disagree. AFAIK you aren't a core dev or triager, I wish you wouldn't give other contributors questionable advice without a reference in such an authoritive-sounding way.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would update this and focus on how the new API differs from the old one: IIUC, "can be called without the GIL [or whatever PC phrasing] and doesn't acquire it unless a signal's handler needs to be called."

95 changes: 70 additions & 25 deletions 95 Modules/signalmodule.c
Original file line number Diff line number Diff line change
Expand Up @@ -1759,12 +1759,22 @@ _PySignal_Fini(void)
Py_CLEAR(state->ignore_handler);
}


/* Declared in pyerrors.h */
int
PyErr_CheckSignals(void)
/* Subroutine of _PyErr_CheckSignalsNoGIL. Does all the work that
needs the GIL. When called, 'tstate' must be the thread state for
the current thread, and the current thread must hold the GIL. */
static int
_PyErr_CheckSignalsHoldingGIL(PyThreadState *tstate, bool cycle_collect)
{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly start with assert(tstate != NULL); ???

PyThreadState *tstate = _PyThreadState_GET();
#if defined(Py_REMOTE_DEBUG) && defined(Py_SUPPORTS_REMOTE_DEBUG)
_PyRunRemoteDebugger(tstate);
#endif

/* It is necessary to repeat all of the checks of global flags
that were done in _PyErr_CheckSignalsNoGIL. At the time of
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That function does not exist -- not sure what you meant.

those checks, we might not have held the GIL; in between those
checks and when we acquired the GIL, some other thread may have
processed the events that were flagged. Since we now hold the
Comment on lines +1776 to +1777
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No other thread could have processed the events (only the main thread of the main interpreter can) but new events might have been added.

Suggested change
checks and when we acquired the GIL, some other thread may have
processed the events that were flagged. Since we now hold the
checks and when we acquired the GIL, events may have been
flagged or unflagged. Since we now hold the

GIL, a check now will be valid until we release it again. */

/* Opportunistically check if the GC is scheduled to run and run it
if we have a request. This is done here because native code needs
Expand All @@ -1777,24 +1787,8 @@ PyErr_CheckSignals(void)
_Py_RunGC(tstate);
}

#if defined(Py_REMOTE_DEBUG) && defined(Py_SUPPORTS_REMOTE_DEBUG)
_PyRunRemoteDebugger(tstate);
#endif

if (!_Py_ThreadCanHandleSignals(tstate->interp)) {
return 0;
}

return _PyErr_CheckSignalsTstate(tstate);
}


/* Declared in cpython/pyerrors.h */
int
_PyErr_CheckSignalsTstate(PyThreadState *tstate)
{
_Py_CHECK_EMSCRIPTEN_SIGNALS();
if (!_Py_atomic_load_int(&is_tripped)) {
if (!_Py_ThreadCanHandleSignals(tstate->interp)
|| !_Py_atomic_load_int(&is_tripped)) {
return 0;
}

Expand Down Expand Up @@ -1877,15 +1871,66 @@ _PyErr_CheckSignalsTstate(PyThreadState *tstate)
return 0;
}

/* Subroutine of the PyErr_CheckSignals family:
Determine whether there is actually any work needing to be done.
If so, acquire the GIL if necessary, and do that work. */
static int
_PyErr_CheckSignalsNoGIL(PyThreadState *tstate, bool cycle_collect)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bikeshedding: let's call this "NoTstate", because you'll still need a thread state on free-threaded builds.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a little weird to name a function BlahNoTstate when its first argument is a tstate. How about _PyErr_CheckSignals_MaybeDetached for this one and _PyErr_CheckSignals_Attached for the one that does require an attached thread state to call?

(Also, given that these are static functions, possibly they should be named more like check_signals_maybe_detached and check_signals_attached? I do not fully grok the coding style in this file.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, whatever. Just don't call it "GIL" :)

{
/* If this thread does not have a thread state at all, then it has
never been associated with the Python runtime, so it should not
attempt to handle signals or run the cycle collector. */
if (!tstate) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we should ignore this. This seems like blatant misuse--either return a failure or emit a fatal error.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All callers of this function get the tstate argument from PyGILState_GetThisThreadState(). I could be wrong about this, but I got the impression that that function could return NULL under circumstances where it would not be misuse to call PyErr_CheckSignals, such as the thread state merely having been set to NULL via PyThreadState_Swap. Perhaps it is the comment that is wrong? Anyway, the contract of PyErr_CheckSignals has always been that it fails if and only if a Python signal handler raised an exception, so it seemed safest to me to treat "we don't have a thread state" as "nothing to do" rather than some form of failure. I'm happy to be persuaded otherwise.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyGILState_GetThisThreadState only returns NULL when the thread hasn't ever had a thread state. That hasn't ever been supported for PyErr_CheckSignals, which is why I'm worried about implicitly failing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks all right to me in the new version of the PR -- the new function might be called under those circumstances, and no tstate means that it's definitely not the main thread of the main interpreter, so ignoring the call feels like the right thing to do. Right?

return 0;
}

_Py_CHECK_EMSCRIPTEN_SIGNALS();

/* Don't acquire the GIL if we don't have anything to do.
VERIFYME: I *think* every piece of this expression is safe to
execute without holding the GIL and is already sufficiently
atomic. */
Comment on lines +1928 to +1930
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I double checked and it looks safe to me, so you can drop this comment.

if ((!_Py_ThreadCanHandleSignals(tstate->interp)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is missing a check for the remote debugger interface. Otherwise this will not run the remote debugger code when users call this function without the GIL

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checks are the same ones that _PyRunRemoteDebugger do before entering the actual code

|| !_Py_atomic_load_int(&is_tripped))
&& (!cycle_collect
|| !_Py_eval_breaker_bit_is_set(tstate, _PY_GC_SCHEDULED_BIT))) {
return 0;
}

/* FIXME: Given that we already have 'tstate', is there a more efficient
way to do this? */
Comment on lines +1938 to +1939
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you want PyThreadState_Swap(tstate).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation for that function says "Swap the current thread state with the thread state given by the argument tstate, which may be NULL. The global interpreter lock must be held and is not released." So that really doesn't sound like what I should be using here.

What I think I need is more like PyEval_RestoreThread(tstate), except that (in GIL builds) if tstate already holds the GIL it should not deadlock attempting to acquire it again, and (in free-threaded builds) whatever the equivalent of that statement is. (I am only just now beginning to learn how free-threaded mode works.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 3.13 docs for PyThreadState_Swap are wrong!

Ignore the phrase "holds the GIL". Read it as "hold an attached thread state". You always need a thread state to call the C API, in both FT and GIL-icious builds.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zackw Please fix this.

PyGILState_STATE st = PyGILState_Ensure();
int err = _PyErr_CheckSignalsHoldingGIL(tstate, cycle_collect);
PyGILState_Release(st);

return err;
}

/* Declared in pycore_pyerrors.h.
This function may be called without the GIL. */
int
_PyErr_CheckSignalsTstate(PyThreadState *tstate)
{
return _PyErr_CheckSignalsNoGIL(tstate, true);
}

/* Declared in pycore_pyerrors.h.
This function may be called without the GIL. */
int
_PyErr_CheckSignals(void)
{
PyThreadState *tstate = _PyThreadState_GET();
return _PyErr_CheckSignalsTstate(tstate);
PyThreadState *tstate = PyGILState_GetThisThreadState();
return _PyErr_CheckSignalsNoGIL(tstate, false);
}

/* Declared in pyerrors.h.
This function may be called without the GIL. */
int
PyErr_CheckSignals(void)
{
PyThreadState *tstate = PyGILState_GetThisThreadState();
return _PyErr_CheckSignalsNoGIL(tstate, true);
}

/* Simulate the effect of a signal arriving. The next time PyErr_CheckSignals
is called, the corresponding Python signal handler will be raised.
Expand Down
Morty Proxy This is a proxified and sanitized view of the page, visit original site.