Writing Extension Modules
to be Interruptible

An abstract representation of my head, in profile

Zack Weinberg

https://www.owlfolio.org/

Million Concepts LLC PyCon US 2025

A Common Bug

(in compiled extension modules)

>>> import numpy as np
>>> rng = np.random.default_rng()
>>> def g(n=1000000000):
...     return rng.random(n)
...
>>> g()
^CTraceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in g
KeyboardInterrupt
>>>

 3.5 second delay 

What went wrong

  • NumPy didn’t ever call PyErr_CheckSignals
  • Common bug in compiled-code extensions
  • This talk is about:
    1. Why there’s no way to avoid needing to do this
    2. Why, today, many extensions don’t do this
    3. How we can make it easier to do this

There is no easy fix for this bug

  • There’s no workaround possible for users of buggy extensions
  • Core CPython changes will help but cannot eliminate the bug
  • Many extensions will need to change

This talk is about development of Python

You’ll get increasingly more out of it if…

  • you’ve written code in compiled languages
  • you’ve written compiled-code extensions for CPython
  • you’ve looked at or worked on the code of CPython itself
    (or PyPy, Perl, Ruby, Lua, etc.)
  • you’ve written code that uses Unix signal handlers

Why does this bug exist?

  • Not a problem for shell scripts
  • … but shell scripts don’t have structured exceptions
  • … and they get more help from the kernel

What Control-C Does1

  • Starts out like any keystroke
  • Converted to a signal, SIGINT
  • SIGINT delivered to Python interpreter
  • Python interpreter reacts by raising KeyboardInterrupt
  • C signal handler only sets a flag
  • Nothing more happens until control returns to the main interpreter loop
  • Main loop checks the flag in some (not all!) bytecode ops
  • If the flag is set, raises KeyboardInterrupt

1 Except on Windows

Signal Delivery

>>> import numpy as np
>>> rng = np.random.default_rng()
>>> def g(n=1000000000):
...     return rng.random(n)
...
>>> g()
^C
Program received signal SIGINT
random_standard_uniform_fill ()
    at 0x00007ffff00b23a7
(gdb) signal SIGINT
Continuing with signal SIGINT.
Breakpoint 2, signal_handler (sig_num=2)
    at ./Modules/signalmodule.c:347
(gdb)

Signal Delivery

Breakpoint 2, signal_handler (sig_num=2)
    at ./Modules/signalmodule.c:347
(gdb) backtrace
#0  signal_handler (sig_num=2)
#1  <signal handler called>
#2  random_standard_uniform_fill (…)
#3  __pyx_f_5numpy_6random_7_common_f…
#4  __pyx_pw_5numpy_6random_10_genera…
#5  method_vectorcall_FASTCALL_KEYWOR…
#6  _PyObject_VectorcallTstate (…)
#7  PyObject_Vectorcall (…)
#8  _PyEval_EvalFrameDefault (…)
#9  PyEval_EvalCode (…)

Signal Delivery

  • Signal delivery interrupts normal code execution
    • Exact analogy to hardware interrupts
    • Kernel “preempts” execution of CPython
    • Creates fake stack frames, to make CPU return to preemption point later
    • Resumes execution at beginning of signal_handler
  • Preempted code could have been doing anything
  • Therefore, unsafe to do much within a signal handler

What Control-C Does1

  • Starts out like any keystroke
  • Converted to a signal, SIGINT
  • SIGINT delivered to Python interpreter
  • Python interpreter reacts by raising KeyboardInterrupt
  • C signal handler only sets a flag
  • Nothing more happens until control returns to the main interpreter loop
  • Main loop checks the flag in some (not all!) bytecode ops
  • If the flag is set, raises KeyboardInterrupt

1 Except on Windows

Why don’t extensions check the flag?

  1. Need to call PyErr_CheckSignals is poorly documented
  2. Adding calls may require tricky refactoring
    • to make safe places to call it
    • to propagate a −1 return all the way up
  3. Calling it is expensive
    • especially if you released the global interpreter lock

int PyErr_CheckSignals()

Part of the Stable ABI.

This function interacts with Python’s signal handling.

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 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 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).

Note: The default Python signal handler for SIGINT raises the KeyboardInterrupt exception.

Why don’t extensions check the flag?

  1. Need to call PyErr_CheckSignals is poorly documented
  2. Adding calls may require tricky refactoring
    • to make safe places to call it
    • to propagate a −1 return all the way up
  3. Calling it is expensive
    • especially if you released the global interpreter lock

How to check: a simple example

void
random_standard_uniform_fill(
    bitgen_t *rng,
    npy_intp cnt, double *out
) {


  npy_intp i;
  for (i = 0; i < cnt; i++) {
    out[i] = next_double(rng);




  }

}
int
random_standard_uniform_fill(
    bitgen_t *rng,
    npy_intp cnt, double *out
) {


  npy_intp i;
  for (i = 0; i < cnt; i++) {
    out[i] = next_double(rng);
    if (PyErr_CheckSignals())
      return -1;


  }
  return 0;
}

How to check: a simple example

int
random_standard_uniform_fill(
    bitgen_t *rng,
    npy_intp cnt, double *out
) {


  npy_intp i;
  for (i = 0; i < cnt; i++) {
    out[i] = next_double(rng);
    if (PyErr_CheckSignals())
      return -1;


  }
  return 0;
}
int
random_standard_uniform_fill(
    bitgen_t *rng,
    npy_intp cnt, double *out
) {
  PyGILState_STATE st;
  int err;
  npy_intp i;
  for (i = 0; i < cnt; i++) {
    out[i] = next_double(rng);
    st = PyGILState_Ensure();
    err = PyErr_CheckSignals();
    PyGILState_Release(st);
    if (err) return err;
  }
  return 0;
}

Why don’t extensions check the flag?

  1. Need to call PyErr_CheckSignals is poorly documented
  2. Adding calls may require tricky refactoring
    • to make safe places to call it
    • to propagate a −1 return all the way up
  3. Calling it is expensive
    • especially if you released the global interpreter lock

A more complicated example

Cost of checking for signals

Cost of checking for signals

What if we didn’t check every time?

Claw back more speed with coarse clock

Longer intervals don’t help

Recap

  • CPython interpreter cannot raise KeyboardInterrupt directly from its C-level signal handler
    • Must return to main interpreter loop before raising exceptions
    • Prompt return needs cooperation from compiled-code extensions
  • Many extensions do not cooperate
    • Need to do this has not been well advertised
    • Retrofit requires finicky code changes
    • Substantial runtime costs

Short term: look at the clock

int CheckSignalsOftenEnough(void) {
  static struct timespec last_check = { 0, 0 };
  struct timespec now;
  clock_gettime(CLOCK_MONOTONIC_COARSE, &now);

  if (timespec_difference_at_least(&now, &last_check, ONE_MS_IN_NS)) {
    last_check = now;
    PyGILState_STATE st = PyGILState_Ensure();
    int err = PyErr_CheckSignals();
    PyGILState_Release(st);
    return err;
  }
  return 0;
}

Full version: https://github.com/MillionConcepts/cpython-ext-ctrl-c/blob/main/CheckSignalsOftenEnough.c

How can we make this better?

  • Improve core documentation
    • Highlight need to call PyErr_CheckSignals regularly
    • Explain how to do so safely
    • Explain how to do so efficiently
    • gh-134075
zaniness: 0

How can we make this better?

  • Don’t require GIL to call PyErr_CheckSignals
    • Cost of frequent checks is mostly cost of locking
    • Signal flag is an atomic variable! Don’t need GIL to test it
    • Have PyErr_CheckSignals reclaim GIL itself, before running Python signal handlers, only if flag is set
    • (Still needs to be safe to take GIL at any callsite)
    • Proposed for Python 3.14: gh-133465
zaniness: 0

How can we make this better?

  • Make Cython, Numba, and similar tools insert checks for you
cdef random_standard_uniform_fill(
    bitgen_t rng,
    double [:] out,
) nogil:
    for i in range(len(out)):
        out[i] = next_double(rng)
int random_standard_uniform_fill(
    bitgen_t rng,
    __Pyx_memviewslice out
) {
  Py_ssize_t i;
  Py_ssize_t len;
  len = __Pyx_MemoryView_Len(out);
  for (i = 0; i < len; i += 1) {
    *((double *)
      ((out.data +
        i * out.strides[0]))) =
      next_double(rng);
    if (CheckSignalsOftenEnough())
      return -1;
  }
  return 0;
}
zaniness: 1

How can we make this better?

  • In tools like PyO3, model control-C as async cancellation
#[pyfunction]
async fn random_standard_uniform_fill(
    #[pyo3(cancel_handle)] mut cancel: CancelHandle,
    rng: BitGen,
    out: &mut f64[],
) {
    futures::select! {
        cancel.cancelled.fuse() => {},
        _ => {
            for i in 0..out.len() {
                out[i] = rng.next_double();
            }
        }
    }
}
zaniness: 5

How can we make this better?

  • Turn Python into a shell-structured language
  • Evaluate each step in a subprocess
>>> import numpy as np
>>> rng = np.random.default_rng()
>>> def g(n=1000000000):
...     return rng.random(n)
...
>>> g()
  • Pass results back via pipes or shared memory or something
  • Let the subprocess die on SIGINT
  • This is why the Unix shell doesn’t have the same problem
zaniness:

How can we make this better?

  • Brainstorming time!
    • Call out your ideas
    • Or ask a question
    • One sentence per person

Acknowledgments