Skip to main content

Cooperative, prompt thread interruption for CPython (Linux / Darwin)

Project description

Python Interruptible Threads

Cooperative, prompt thread interruption for CPython. Call thread.interrupt() and a target thread raises an exception (ThreadInterrupted by default) — even when it is parked in time.sleep, select, asyncio.sleep, an Event/Queue/Condition wait, or a (helper-wrapped) blocking socket read.

It uses ctypes to reach PyThreadState_SetAsyncExc and patches a curated set of stdlib blocking primitives, so it works only on CPython and POSIX (Linux / Darwin).

from interruptible_threading import InterruptibleThread, ThreadInterrupted
import time

InterruptibleThread.install_patches()

def sleep_forever():
    try:
        while True:
            time.sleep(10)
    except ThreadInterrupted:
        print("interrupted")

t = InterruptibleThread(target=sleep_forever, daemon=True)
t.start()
...
t.interrupt()   # output: "interrupted"

Back-compat: earlier versions raised KeyboardInterrupt. To keep that behavior, InterruptibleThread.install_patches(interrupt_exc=KeyboardInterrupt), or install_patches(legacy_keyboardinterrupt=True) to deliver an exception caught by both ThreadInterrupted and KeyboardInterrupt handlers.

How it works

Pure-Python code is interrupted with PyThreadState_SetAsyncExc, an async exception that fires at the next bytecode boundary. That cannot break a thread sitting in a C-level blocking call, so install_patches() replaces time.sleep, selectors.DefaultSelector, select.select, and threading.Condition.wait (which also covers Event.wait and queue.Queue) with versions that wake promptly via a per-thread self-pipe / chunked polling.

The design rests on a single durable per-thread "interrupt pending" flag. interrupt() sets the flag first (under one lock), then issues a wakeup nudge; every blocking primitive checks the flag before parking and again after waking. This removes the time-of-check/time-of-use races inherent in choosing a delivery path from transient state, and makes interrupts maskable and pollable.

Why not signal.pthread_kill?

pthread_kill can unblock a syscall but cannot deliver an exception to a worker thread: CPython runs Python-level signal handlers only on the main thread, and PEP 475's EINTR auto-retry loops call PyErr_CheckSignals() — a no-op off the main thread — without consulting tstate->async_exc, so the syscall is transparently retried. The self-pipe + cooperative-primitive approach is the only way to get prompt, exception-bearing interruption of worker threads on CPython.

API

Name Purpose
InterruptibleThread(...) threading.Thread subclass with .interrupt(recursive=False).
InterruptibleThread.install_patches(interrupt_exc=ThreadInterrupted, legacy_keyboardinterrupt=False, monkeypatch_socket=False) Install the stdlib patches (process-wide).
InterruptibleThread.uninstall_patches() Restore the originals.
InterruptibleThread.run_interruptible(coro) Run a coroutine via asyncio.run with clean, cancellation-based interruption.
ThreadInterrupted Default interrupt exception (subclass of BaseException).
is_interrupted(thread=None) Whether an interrupt is pending (non-consuming).
clear_interrupt() Consume a pending interrupt without raising; returns whether one was pending.
check_interrupt() / interruptible_checkpoint() Raise if pending and not masked — for CPU-bound loops.
periodic_checkpoint(every=N) Context manager yielding a .tick() that checkpoints every N calls.
critical_section() / interrupts_disabled() Defer delivery during cleanup; deliver on exit.
interruptible_recv/send/accept(sock, ...) Interruptible blocking socket ops.
set_poll_interval(seconds) Tune the chunked-poll wakeup latency (default 50 ms).

Masking critical sections

from interruptible_threading import critical_section

with critical_section():
    commit()          # interrupts that arrive here are deferred...
release_resources()   # ...and delivered exactly once when the block exits

CPU-bound loops

from interruptible_threading import periodic_checkpoint

with periodic_checkpoint(every=1000) as ck:
    for row in huge_iterable:
        ck.tick()     # raises ThreadInterrupted promptly once interrupted
        crunch(row)

asyncio

import asyncio
from interruptible_threading import InterruptibleThread

def worker():
    try:
        InterruptibleThread.run_interruptible(main_coro())
    except ThreadInterrupted:
        print("cancelled cleanly")

run_interruptible cancels the loop's tasks via call_soon_threadsafe (proper finally / async with unwind) instead of injecting an exception into the selector.

Limitations

  • CPython + POSIX only. Relies on PyThreadState_SetAsyncExc, os.pipe, and select.
  • Uncovered blocking calls stay blocked until they return: synchronous regular-file disk I/O (open().read(), os.read on files), raw blocking socket.recv (use the interruptible_recv helpers or monkeypatch_socket=True), os.waitpid, and Lock.acquire on a builtin lock. The pending flag is honored at the next patched primitive / checkpoint, but the in-progress call is not broken.
  • C extensions that release the GIL and never re-enter Python (heavy NumPy kernels, compiled crypto) cannot be preempted; only cooperative check_interrupt() helps.
  • Chunked-poll primitives (Event/Queue/Condition) have up to _POLL_INTERVAL (default 50 ms) latency, tunable via set_poll_interval.
  • Async injection lands at an arbitrary bytecode boundary and is un-recallable, so critical_section() is airtight only for the flag-driven paths; prefer checkpoints/blocking primitives inside code that must not be interrupted mid-cleanup.
  • Catch-and-continue clears the flag explicitly. The interrupt-pending flag is durable (so an interrupt is never lost if the target parks in a blocking call before async injection can fire). Consequently, if you catch ThreadInterrupted and want to keep running, call clear_interrupt() — otherwise the next check_interrupt() / blocking primitive re-raises. This mirrors Java's Thread.interrupted().
  • install_patches() mutates global stdlib state. Code that captured references before install (e.g. from time import sleep) keeps the originals. Not for libraries to call implicitly.
  • The main thread is intentionally not interruptible by this mechanism, preserving real Ctrl-C / KeyboardInterrupt semantics.

Development

pip install -e .[test]   # or: make devdeps
make check               # blackcheck + ruff + mypy + pytest (with coverage)
pytest                   # just the tests

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

interruptible_threading-0.0.1.tar.gz (42.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

interruptible_threading-0.0.1-py3-none-any.whl (21.4 kB view details)

Uploaded Python 3

File details

Details for the file interruptible_threading-0.0.1.tar.gz.

File metadata

  • Download URL: interruptible_threading-0.0.1.tar.gz
  • Upload date:
  • Size: 42.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.4

File hashes

Hashes for interruptible_threading-0.0.1.tar.gz
Algorithm Hash digest
SHA256 bb1018f9f891f19711d4affa234db658723862c8e0e4bb6f25a8c4a9d02e907c
MD5 98033c9ca6eb385b5edd620cef18a2a0
BLAKE2b-256 ab0f326eb584d3c04dd61897724facea1e01d7e51c22a80c9d93ae0f8cf153e4

See more details on using hashes here.

File details

Details for the file interruptible_threading-0.0.1-py3-none-any.whl.

File metadata

File hashes

Hashes for interruptible_threading-0.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 2c25b0e8e701dc8fd50739c07d7df514f21ed71a5649b7a3f9873bbeae1b2be3
MD5 7aa4c5f382440f7cbb7db76d66503a4f
BLAKE2b-256 5ee0a20483a5d1919bd7b86200f7ed06cc70336e502b4a2473a0b45f81ff9967

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page